diff --git a/AGENTS.md b/AGENTS.md index 600d948..6f0bc26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,39 +62,15 @@ ## Build ```bash -# Required env on this server (proxy + Android SDK) -export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 -export ANDROID_HOME=/home/dueattendant149/android-sdk -export ANDROID_SDK_ROOT=/home/dueattendant149/android-sdk ./gradlew :app:assembleOssDebug ``` -**Known build workarounds (Phase 1 baseline):** -- Gradle wrapper pinned to 8.13 (8.11.1 unavailable behind proxy; 9.5.1 incompatible with AGP 8.9). -- `externalNativeBuild` temporarily disabled because NDK 27.0.12077973 is requested but only NDK 28.2.x is installed. -- Network proxy is configured in `gradle.properties` (`systemProp.http[s].proxyHost/Port`). - ## Status - Phase 0: clone + package rename — completed -- Phase 1: KMP → Android-only — completed -- Phase 2: backend swap (bookshelf-api + ABS) — completed - - Phase 2.1: bookshelf-api + ABS API services/models, Hilt DI, Retrofit/OkHttp - - Phase 2.2: BookshelfViewModel + BookshelfLibraryScreen + nav entry - - Phase 2.3: ServerSettingsScreen (URL/token config) - - Phase 2.4: download/open ebook from server in reader - - Phase 2.5: removed OPDS/Gutenberg/LocalFolder/CloudSync code + stubs -- Phase 3: UI redesign (Myne-style) — completed - - Material You 3 shape scheme (round cards 8-32dp) - - Dynamic color (Android 12+ dynamicColorScheme + materialkolor seed) - - BookshelfLibraryScreen + ServerSettingsScreen redesigned with round cards, CenterAlignedTopAppBar -- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — completed - - RSVP engine (RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText) - - Audio player (AudioPlaybackService, ExoPlayer, MediaSession, PlaybackModule) - - TTS repository (RemoteTtsRepository + impl wrapping BookshelfApiRepository) - - Workers (no-op stubs for CacheDownload, ProgressSync, TtsDownload) -- Phase 5: reader engine integration — completed - - Checkpoint model (serializable reading position) - - ChapterDrawer (ModalBottomSheet chapter list) - - Volume keys + chapter navigation already present -- Phase 6: build, test, deploy — in progress \ No newline at end of file +- Phase 1: KMP → Android-only — pending +- Phase 2: backend swap (bookshelf-api + ABS) — pending +- Phase 3: UI redesign (Myne-style) — pending +- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending +- Phase 5: reader engine integration — pending +- Phase 6: build, test, deploy — pending \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cbfa2a8..0c234e9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,7 +11,6 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.ksp) - alias(libs.plugins.hilt) id("com.diffplug.spotless") version "8.2.1" alias(libs.plugins.kover) } @@ -47,9 +46,7 @@ fun configuredAppLocaleTags(): Set { } kotlin { - jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(21)) - } + jvmToolchain(21) } android { @@ -67,13 +64,11 @@ android { .map { it.toAndroidResourceConfiguration() } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" -/* externalNativeBuild { cmake { cppFlags += "" } } -*/ buildConfigField("boolean", "IS_PRO", "false") buildConfigField("boolean", "IS_OFFLINE", "false") } @@ -167,14 +162,12 @@ android { singleVariant("release") { } } -/* externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt") version = "3.22.1" } } -*/ testOptions { unitTests.isReturnDefaultValues = true unitTests.all { @@ -218,6 +211,8 @@ kover { //noinspection UseTomlInstead dependencies { + implementation(project(":shared")) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -229,16 +224,6 @@ dependencies { implementation(libs.androidx.material3.window.size.class1.android) implementation(libs.androidx.credentials) - // Hilt - implementation(libs.hilt.android) - ksp(libs.hilt.compiler) - implementation(libs.hilt.navigation.compose) - - // Networking - implementation(libs.retrofit) - implementation(libs.retrofit.kotlinx.serialization) - implementation(libs.okhttp.logging) - androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt index 8429f96..dd9963e 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt @@ -52,8 +52,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalContext -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi @@ -61,7 +59,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState -import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen import org.dueattendant149.bookreader.epubreader.EpubReaderScreen import org.dueattendant149.bookreader.feedback.FeedbackScreen import org.dueattendant149.bookreader.feedback.SupportProjectScreen @@ -82,8 +79,6 @@ object AppDestinations { const val FONTS_SCREEN_ROUTE = "fonts_screen_route" const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route" const val SETTINGS_SCREEN_ROUTE = "settings_screen_route" - const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library" - const val SERVER_SETTINGS_ROUTE = "server_settings" } fun shouldInterceptAppNavBack( @@ -432,34 +427,6 @@ fun AppNavigation( onBackClick = { navController.popBackStackIfReady() } ) } - - composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) { - val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel() - LaunchedEffect(bookshelfViewModel) { - bookshelfViewModel.downloadedFile.collect { uri -> - Timber.d("Downloaded book $uri, opening in reader") - viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false) - } - } - BookshelfLibraryScreen( - viewModel = bookshelfViewModel, - onItemClick = { item -> - Timber.d("Bookshelf item selected: ${item.id}") - bookshelfViewModel.downloadBook(item) - }, - onOpenSettings = { - navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE) - } - ) - } - - composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) { - val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel() - org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen( - viewModel = settingsViewModel, - onBackClick = { navController.popBackStackIfReady() } - ) - } } AnimatedVisibility( diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt new file mode 100644 index 0000000..198c806 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt @@ -0,0 +1,70 @@ +package org.dueattendant149.bookreader + +import android.util.Log +import org.dueattendant149.bookreader.data.BookMetadata +import org.dueattendant149.bookreader.data.RecentFileItem +import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp +import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp +import timber.log.Timber + +internal const val CloudSyncTraceTag = "EpistemeCloudSync" +internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations" + +internal fun logCloudSyncTrace(message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.d(CloudSyncTraceTag, text) + Timber.tag(CloudSyncTraceTag).d(text) +} + +internal fun logCloudSyncError(error: Throwable, message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.e(CloudSyncTraceTag, text, error) + Timber.tag(CloudSyncTraceTag).e(error, text) +} + +internal fun logCloudAnnotationSyncTrace(message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.d(CloudAnnotationSyncTraceTag, text) + Timber.tag(CloudAnnotationSyncTraceTag).d(text) +} + +internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) { + if (!BuildConfig.DEBUG) return + val text = message() + Log.e(CloudAnnotationSyncTraceTag, text, error) + Timber.tag(CloudAnnotationSyncTraceTag).e(error, text) +} + +internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String { + return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " + + "contentTs=$fileContentModifiedTimestamp " + + "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " + + "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " + + "bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}" +} + +internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String { + return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " + + "annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " + + "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " + + "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " + + "hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " + + "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}" +} + +internal fun String?.cloudSyncPreview(maxLength: Int = 80): String { + val value = this ?: return "null" + return if (value.length <= maxLength) value else value.take(maxLength) + "..." +} + +internal fun String?.cloudSyncAnnotationSummary(): String { + val value = this?.trim() ?: return "null" + return when { + value.isEmpty() -> "blank" + value == "[]" -> "empty" + else -> "present(${value.length})" + } +} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt deleted file mode 100644 index 5cd4c5f..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.dueattendant149.bookreader - -/** - * Stubs for removed CloudSyncTrace.kt. All calls are no-ops. - */ - -internal inline fun logCloudSyncTrace(crossinline message: () -> String) { - // no-op: cloud sync removed -} - -internal inline fun logCloudSyncError(throwable: Throwable, crossinline message: () -> String) { - // no-op: cloud sync removed -} - -internal inline fun logCloudSyncError(crossinline message: () -> String) { - // no-op: cloud sync removed -} - -internal inline fun logCloudAnnotationSyncTrace(crossinline message: () -> String) { - // no-op: cloud annotation sync removed -} - -internal inline fun logCloudAnnotationSyncError(throwable: Throwable, crossinline message: () -> String) { - // no-op: cloud annotation sync removed -} - -internal fun Any?.cloudSyncTraceSummary(label: String = ""): String = "" - -internal fun Any?.cloudSyncAnnotationSummary(): String = "" - -internal fun Any?.cloudSyncPreview(): String = this?.toString() ?: "" - -internal fun String.cloudSyncPreview(): String = this - -internal fun String.cloudSyncPreview(maxLen: Int): String = take(maxLen) - -internal fun String?.cloudSyncPreviewOrEmpty(): String = this ?: "" \ No newline at end of file diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt new file mode 100644 index 0000000..e4e67cd --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt @@ -0,0 +1,773 @@ +/* + * Episteme Reader - A native Android document reader. + * Copyright (C) 2026 Episteme + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * mail: epistemereader@gmail.com + */ +// FolderSyncWorker.kt +package org.dueattendant149.bookreader + +import android.content.Context +import timber.log.Timber +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkerParameters +import androidx.work.WorkManager +import org.dueattendant149.bookreader.data.RecentFileItem +import org.dueattendant149.bookreader.data.RecentFilesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import androidx.core.content.edit +import org.dueattendant149.bookreader.data.LocalSyncUtils +import org.dueattendant149.bookreader.data.FolderBookMetadata +import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata +import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem +import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer +import org.dueattendant149.bookreader.shared.EpubBookmark +import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.SharedFolderScannedFile +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark +import java.io.File +import android.provider.DocumentsContract + +class FolderSyncWorker( + private val appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + private val recentFilesRepository = RecentFilesRepository(appContext) + + companion object { + const val WORK_NAME = "FolderSyncWorker" + const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime" + const val KEY_METADATA_ONLY = "key_metadata_only" + const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri" + private val syncMutex = Mutex() + } + + override suspend fun doWork(): Result { + val workerStart = ReaderPerfLog.nowNanos() + val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false) + val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI) + val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) + + val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null) + val folders = SyncedFolderPrefs.decodeSyncedFolders( + jsonString = jsonString, + legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null), + syncableTypes = ANDROID_SYNCABLE_FILE_TYPES + ) + + if (folders.isEmpty()) { + ReaderPerfLog.w("FolderSync worker aborted: no linked folders") + return Result.success() + } + + val enabledFolders = folders.filter { it.localSyncEnabled } + val foldersToProcess = if (targetFolderUri.isNullOrBlank()) { + enabledFolders + } else { + enabledFolders.filter { it.uriString == targetFolderUri } + } + + if (foldersToProcess.isEmpty()) { + ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri") + return Result.success() + } + + ReaderPerfLog.d( + "FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " + + "target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly" + ) + + return withContext(Dispatchers.IO) { + syncMutex.withLock { + var allSuccess = true + + for (folderConfig in foldersToProcess) { + val success = performSyncForFolder(folderConfig, isMetadataOnly) + if (!success) allSuccess = false + } + + if (jsonString != null) { + try { + val array = org.json.JSONArray(jsonString) + val now = System.currentTimeMillis() + val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString } + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + if (obj.optString("uri") in processedUris) { + obj.put("lastScanTime", now) + } + } + prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) } + } catch (_: Exception) {} + } + + val elapsed = ReaderPerfLog.elapsedMs(workerStart) + ReaderPerfLog.i( + "FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " + + "folders=${foldersToProcess.size} elapsed=${elapsed}ms" + ) + + if (allSuccess) Result.success() else Result.failure() + } + } + } + + private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean { + val folderUriString = folderConfig.uriString + val allowedFileTypes = folderConfig.allowedFileTypes + if (folderUriString.isBlank()) return true + val folderUri = folderUriString.toUri() + val folderStart = ReaderPerfLog.nowNanos() + var dirsScanned = 0 + var filesSeen = 0 + var supportedBooksSeen = 0 + var dbFlushes = 0 + var sidecarsImported = 0 + var stoppedForUnlinkedFolder = false + + try { + if (!isFolderStillLinked(folderUriString)) { + ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString") + return true + } + + try { + appContext.contentResolver.takePersistableUriPermission( + folderUri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: SecurityException) { + return false + } + + val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) + if (documentTree == null || !documentTree.isDirectory) { + return false + } + + ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared") + + val folderMetadataMap = ReaderPerfLog.measureSuspend( + name = "FolderSync phase metadata-sidecars", + minLogMs = 25L, + details = { "metadataOnly=$metadataOnly" } + ) { + LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap() + } + ReaderPerfLog.d( + "FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString" + ) + + val existingFolderBooks = ReaderPerfLog.measureSuspend( + name = "FolderSync phase load-existing-db", + minLogMs = 25L + ) { + recentFilesRepository.getFilesBySourceFolder(folderUriString) + } + val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap() + + val scanResult = if (metadataOnly) { + AndroidFolderScanResult() + } else { + ReaderPerfLog.measureSuspend( + name = "FolderSync phase scan-folder", + minLogMs = 25L + ) { + scanFolderFiles( + folderUri = folderUri, + folderUriString = folderUriString, + allowedFileTypes = allowedFileTypes + ) + } + } + dirsScanned = scanResult.dirsScanned + filesSeen = scanResult.filesSeen + supportedBooksSeen = scanResult.files.size + stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder + + if (isStopped || stoppedForUnlinkedFolder) { + ReaderPerfLog.w( + "FolderSync folder aborted before shared engine stopped=$isStopped " + + "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString" + ) + return true + } + + val nowMillis = System.currentTimeMillis() + val folder = SyncedFolder( + uriString = folderUriString, + name = documentTree.name ?: folderConfig.name, + lastScanTime = nowMillis, + allowedFileTypes = allowedFileTypes, + localSyncEnabled = true + ) + val sharedState = SharedReaderScreenState( + rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() }, + syncedFolders = listOf(folder) + ) + val syncResult = LocalFolderSyncEngine.syncFolder( + state = sharedState, + folder = folder, + files = scanResult.files, + remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() }, + nowMillis = nowMillis, + metadataOnly = metadataOnly + ) + + if (syncResult.idMigrations.isNotEmpty()) { + val preloadedSidecars = ReaderPerfLog.measureSuspend( + name = "FolderSync phase migration-sidecars", + minLogMs = 25L + ) { + LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap() + } + syncResult.idMigrations.forEach { (oldId, newId) -> + Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId") + migrateFolderBookId( + folderUriString = folderUriString, + oldId = oldId, + newId = newId, + folderMetadataMap = folderMetadataMap, + preloadedSidecars = preloadedSidecars, + existingItemsMap = existingItemsMap + ) + } + } + + if (!isFolderStillLinked(folderUriString)) { + ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString") + stoppedForUnlinkedFolder = true + return true + } + + val scannedFilesById = scanResult.files.associateBy { it.stableBookId } + val syncedItems = syncResult.state.rawLibraryBooks.map { book -> + val existing = existingItemsMap[book.id] + val metadata = appliedMetadataFor( + book = book, + existing = existing, + metadata = folderMetadataMap[book.id] + ) + book.toFolderSyncRecentFileItem( + existing = existing, + appliedMetadata = metadata, + scannedFile = scannedFilesById[book.id], + nowMillis = nowMillis + ) + } + val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item } + + changedItems + .filter { item -> + val previous = existingItemsMap[item.bookId] + previous != null && folderFileContentChanged(previous, item) + } + .forEach { item -> + Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.") + recentFilesRepository.clearLocalCachesForBook(item.bookId) + } + + if (changedItems.isNotEmpty()) { + recentFilesRepository.addRecentFiles(changedItems) + dbFlushes++ + } + + if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) { + Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.") + recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList()) + } + + val booksForAnnotationSync = if (metadataOnly) { + syncedItems + } else { + ReaderPerfLog.measureSuspend( + name = "FolderSync phase load-post-scan-db", + minLogMs = 25L + ) { + recentFilesRepository.getFilesBySourceFolder(folderUriString) + } + } + sidecarsImported += importAnnotationSidecarsForBooks( + folderUri = folderUri, + folderUriString = folderUriString, + books = booksForAnnotationSync, + phase = if (metadataOnly) "metadata-only" else "post-scan" + ) + + val elapsed = ReaderPerfLog.elapsedMs(folderStart) + ReaderPerfLog.i( + "FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " + + "dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " + + "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " + + "remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " + + "removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " + + "dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " + + "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString" + ) + + if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) { + if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) { + ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString") + val metaRequest = OneTimeWorkRequestBuilder() + .setInputData( + androidx.work.Data.Builder() + .putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString) + .build() + ) + .build() + WorkManager.getInstance(appContext).enqueueUniqueWork( + MetadataExtractionWorker.WORK_NAME, + ExistingWorkPolicy.REPLACE, + metaRequest + ) + } else { + ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString") + } + } + + return true + + } catch (e: Exception) { + Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.") + return false + } + } + + private suspend fun importAnnotationSidecarsForBooks( + folderUri: android.net.Uri, + folderUriString: String, + books: List, + phase: String + ): Int { + if (books.isEmpty()) { + ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString") + return 0 + } + + val preloadedSidecars = ReaderPerfLog.measureSuspend( + name = "FolderSync phase annotation-sidecars", + minLogMs = 25L, + details = { "phase=$phase" } + ) { + LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri) + } + + ReaderPerfLog.d( + "FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString" + ) + + if (preloadedSidecars.isEmpty()) return 0 + + var imported = 0 + Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...") + for (book in books) { + if (isStopped || !isFolderStillLinked(folderUriString)) break + + val sidecarData = preloadedSidecars[book.bookId] ?: continue + val (remoteTs, jsonPayload) = sidecarData + + val safeSlashBookId = book.bookId.replace("/", "_") + val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_") + val localFiles = listOf( + File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"), + File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"), + File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"), + File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"), + File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json") + ) + val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L + + if (remoteTs > (localTs + 1000)) { + Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") + recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) + imported++ + } else { + Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.") + } + } + + ReaderPerfLog.i( + "FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString" + ) + return imported + } + + private data class AndroidFolderScanResult( + val files: List = emptyList(), + val dirsScanned: Int = 0, + val filesSeen: Int = 0, + val stoppedForUnlinkedFolder: Boolean = false + ) + + private fun scanFolderFiles( + folderUri: android.net.Uri, + folderUriString: String, + allowedFileTypes: Set + ): AndroidFolderScanResult { + Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...") + val contentResolver = appContext.contentResolver + val rootDocId = DocumentsContract.getTreeDocumentId(folderUri) + val dirQueue = ArrayDeque() + val scannedFiles = mutableListOf() + var dirsScanned = 0 + var filesSeen = 0 + var stoppedForUnlinkedFolder = false + dirQueue.add(rootDocId) + + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + + while (dirQueue.isNotEmpty()) { + if (isStopped) break + if (!isFolderStillLinked(folderUriString)) { + ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString") + stoppedForUnlinkedFolder = true + break + } + val currentDocId = dirQueue.removeFirst() + dirsScanned++ + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId) + + try { + contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) + val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + + while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) { + val docId = cursor.getString(idCol) + val name = cursor.getString(nameCol) ?: "" + val mimeType = cursor.getString(mimeCol) + filesSeen++ + + if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) { + ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString") + stoppedForUnlinkedFolder = true + break + } + + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) { + dirQueue.add(docId) + } + continue + } + + val type = getFileType(name, mimeType) + if ( + type == null || + type !in allowedFileTypes || + !isLocalFolderSyncEligibleFile(name, mimeType) || + name.endsWith(".json") || + name.startsWith(".") + ) { + continue + } + + val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) + val relativePath = buildRelativePath(rootDocId, docId, name) + scannedFiles += SharedFolderScannedFile( + name = name, + path = docUri.toString(), + sourceFolder = folderUriString, + relativePath = relativePath, + type = type, + size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L, + lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L + ) + } + } + } catch (e: Exception) { + Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId") + } + + if (stoppedForUnlinkedFolder) break + } + + return AndroidFolderScanResult( + files = scannedFiles, + dirsScanned = dirsScanned, + filesSeen = filesSeen, + stoppedForUnlinkedFolder = stoppedForUnlinkedFolder + ) + } + + private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem { + return SharedBookItem( + id = bookId, + path = uriString, + type = type, + displayName = displayName, + timestamp = lastModifiedTimestamp, + coverImagePath = coverImagePath, + title = title, + author = author, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription, + progressPercentage = progressPercentage, + isRecent = isRecent, + fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, + sourceFolder = sourceFolderUri, + folderTextMetadataParsed = folderTextMetadataParsed, + seriesName = seriesName, + seriesIndex = seriesIndex, + lastPageIndex = lastPage, + readerPosition = readerPositionOrNull(), + readerBookmarks = parseReaderBookmarks(), + readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson), + readingPositionModifiedTimestamp = readingPositionModifiedTimestamp + ) + } + + private fun SharedBookItem.toFolderSyncRecentFileItem( + existing: RecentFileItem?, + appliedMetadata: FolderBookMetadata?, + scannedFile: SharedFolderScannedFile?, + nowMillis: Long + ): RecentFileItem { + val contentChanged = existing != null && folderFileContentChanged(existing, this) + val localModifiedTimestamp = when { + appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp + contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp + timestamp > 0L -> timestamp + else -> nowMillis + } + val legacyPosition = readerPosition + val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id) + val mappedHighlightsJson = readerHighlights + .takeIf { it.isNotEmpty() } + ?.let(EpubAnnotationSerializer::highlightsToJson) + val bookmarksJson = if (appliedMetadata != null || existing == null) { + mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson + } else { + existing.bookmarksJson + } + val highlightsJson = if (appliedMetadata != null || existing == null) { + mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson + } else { + existing.highlightsJson + } + + return RecentFileItem( + bookId = id, + uriString = path, + type = type, + displayName = scannedFile?.name ?: existing?.displayName ?: displayName, + timestamp = when { + existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp + appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp + else -> existing.timestamp + }, + coverImagePath = coverImagePath, + title = title, + author = author, + lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex, + lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage, + lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi, + locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex, + locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset, + progressPercentage = progressPercentage, + isRecent = isRecent, + isAvailable = true, + lastModifiedTimestamp = localModifiedTimestamp, + isDeleted = false, + bookmarksJson = bookmarksJson, + sourceFolderUri = sourceFolder, + isReflowPreferred = existing?.isReflowPreferred ?: false, + customName = appliedMetadata?.customName ?: existing?.customName, + highlightsJson = highlightsJson, + fileSize = fileSize, + fileContentModifiedTimestamp = fileContentModifiedTimestamp, + seriesName = seriesName, + seriesIndex = seriesIndex, + description = description, + originalTitle = originalTitle, + originalAuthor = originalAuthor, + originalSeriesName = originalSeriesName, + originalSeriesIndex = originalSeriesIndex, + originalDescription = originalDescription, + folderTextMetadataParsed = folderTextMetadataParsed, + folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false, + readingPositionModifiedTimestamp = readingPositionModifiedTimestamp, + tags = existing?.tags.orEmpty() + ) + } + + private fun appliedMetadataFor( + book: SharedBookItem, + existing: RecentFileItem?, + metadata: FolderBookMetadata? + ): FolderBookMetadata? { + if (metadata == null) return null + val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE + return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified } + } + + private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? { + if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null + return ReaderLocator.fromLegacy( + chapterIndex = lastChapterIndex, + cfi = lastPositionCfi, + pageIndex = lastPage + ) + } + + private fun RecentFileItem.parseReaderBookmarks(): List { + return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson) + .mapIndexed { index, bookmark -> + val locator = bookmark.locator.withFallbacks( + chapterIndex = bookmark.chapterIndex, + cfi = bookmark.cfi, + pageIndex = bookmark.pageInChapter?.minus(1), + textQuote = bookmark.snippet + ) + val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0 + ReaderBookmark( + id = "bookmark_${bookId}_$index", + pageIndex = pageIndex.coerceAtLeast(0), + chapterTitle = bookmark.chapterTitle, + preview = bookmark.snippet, + locator = locator + ) + } + } + + private fun List.toAndroidBookmarksJson(bookId: String): String? { + val bookmarks = mapIndexed { index, bookmark -> + val locator = bookmark.locator + val chapterIndex = locator.chapterIndex ?: 0 + val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}" + EpubBookmark( + cfi = cfi, + chapterTitle = bookmark.chapterTitle, + label = null, + snippet = bookmark.preview, + pageInChapter = bookmark.pageIndex + 1, + totalPagesInChapter = null, + chapterIndex = chapterIndex, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = bookmark.pageIndex, + textQuote = bookmark.preview + ) + ) + } + return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson) + } + + private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean { + val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize + val modifiedChanged = next.fileContentModifiedTimestamp > 0L && + previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp + return sizeChanged || modifiedChanged + } + + private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean { + val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize + val modifiedChanged = next.fileContentModifiedTimestamp > 0L && + previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp + return sizeChanged || modifiedChanged + } + + private fun isFolderStillLinked(folderUriString: String): Boolean { + val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) + return SyncedFolderPrefs.isLocalSyncEnabled( + jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null), + legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null), + folderUriString = folderUriString, + syncableTypes = ANDROID_SYNCABLE_FILE_TYPES + ) + } + + private fun getFileType(name: String, mimeType: String?): FileType? { + return resolveFileTypeFromMetadata(name, mimeType) + } + + private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String { + val rootPath = rootDocId.substringAfter(':', "") + val docPath = docId.substringAfter(':', "") + if (docPath.isBlank()) return fallbackName + val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) { + docPath.removePrefix(rootPath).trimStart('/') + } else { + docPath.substringAfterLast('/', fallbackName) + } + return relative.ifBlank { fallbackName } + } + + private suspend fun migrateFolderBookId( + folderUriString: String, + oldId: String, + newId: String, + folderMetadataMap: MutableMap, + preloadedSidecars: MutableMap>, + existingItemsMap: MutableMap + ) { + if (oldId == newId) return + + recentFilesRepository.migrateBookIdLocally(oldId, newId) + + val oldMetadata = folderMetadataMap.remove(oldId) + if (oldMetadata != null && newId !in folderMetadataMap) { + val migratedMetadata = oldMetadata.copy(bookId = newId) + LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata) + folderMetadataMap[newId] = migratedMetadata + } + + val oldSidecar = preloadedSidecars.remove(oldId) + if (oldSidecar != null && newId !in preloadedSidecars) { + LocalSyncUtils.saveAnnotationSidecar( + context = appContext, + sourceFolderUri = folderUriString.toUri(), + bookId = newId, + jsonPayload = oldSidecar.second, + timestamp = oldSidecar.first + ) + preloadedSidecars[newId] = oldSidecar + } + + LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId) + + existingItemsMap.remove(oldId) + recentFilesRepository.getFileByBookId(newId)?.let { + existingItemsMap[newId] = it + } + } +} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt deleted file mode 100644 index 0e2cd6a..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.dueattendant149.bookreader - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters - -/** - * Stub for removed FolderSyncWorker. Keeps constants and a no-op CoroutineWorker - * so existing WorkManager enqueue calls compile. - */ -const val KEY_METADATA_ONLY = "metadata_only" -const val KEY_TARGET_FOLDER_URI = "target_folder_uri" -const val KEY_TRIGGER_REASON = "trigger_reason" - -class FolderSyncWorker( - appContext: Context, - params: WorkerParameters, -) : CoroutineWorker(appContext, params) { - override suspend fun doWork(): Result = Result.success() - - companion object { - const val WORK_NAME = "folder_sync_work" - const val WORK_NAME_ONETIME = "folder_sync_work_onetime" - const val KEY_METADATA_ONLY = "metadata_only" - const val KEY_TARGET_FOLDER_URI = "target_folder_uri" - const val KEY_TRIGGER_REASON = "trigger_reason" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt index aec1d24..fa79d21 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt @@ -80,7 +80,6 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Star import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip @@ -144,7 +143,14 @@ import coil.compose.AsyncImage import coil.decode.SvgDecoder import org.dueattendant149.bookreader.data.RecentFileItem import org.dueattendant149.bookreader.data.TagEntity +import org.dueattendant149.bookreader.opds.OpdsAcquisition +import org.dueattendant149.bookreader.opds.OpdsCatalog +import org.dueattendant149.bookreader.opds.OpdsDownloadState +import org.dueattendant149.bookreader.opds.OpdsEntry +import org.dueattendant149.bookreader.opds.OpdsRepository +import org.dueattendant149.bookreader.opds.OpdsViewModel import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import org.dueattendant149.bookreader.shared.opds.SharedOpdsLocalBookMatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -186,6 +192,9 @@ fun LibraryScreen( add(context.getString(R.string.tab_all_books)) add(context.getString(R.string.tab_shelves)) add(context.getString(R.string.tab_folders)) + if (!BuildConfig.IS_OFFLINE) { + add(context.getString(R.string.tab_catalogs)) + } } } val pagerState = rememberPagerState( @@ -369,10 +378,17 @@ fun LibraryScreen( viewModel.showBanner(context.getString(R.string.banner_downloaded, title)) viewModel.onFileSelected(uri, isFromRecent = false) }, - onStreamOpdsBook = { _, _ -> }, - onDeleteCatalogStreams = { }, + onStreamOpdsBook = { entry, catalog -> + viewModel.streamOpdsBook( + bookId = entry.id, + title = entry.title, + urlTemplate = entry.pseUrlTemplate!!, + pageCount = entry.pseCount!!, + catalogId = catalog?.id + ) + }, + onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog, onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) }, - onBookshelfClick = { navController.navigate(AppDestinations.BOOKSHELF_LIBRARY_ROUTE) }, usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName ) @@ -644,10 +660,9 @@ fun LibraryScreenContent( onRemoveFolderClick: (SyncedFolder) -> Unit, onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit, onOpdsBookDownloaded: (Uri, String) -> Unit, - onStreamOpdsBook: (Any, Any?) -> Unit, + onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onDeleteCatalogStreams: (String) -> Unit, onSettingsClick: () -> Unit, - onBookshelfClick: () -> Unit, usePdfFileNameAsDisplayName: Boolean, ) { val isBookContextualModeActive = selectedItems.isNotEmpty() @@ -789,9 +804,6 @@ fun LibraryScreenContent( Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search)) } } - IconButton(onClick = onBookshelfClick) { - Icon(Icons.Default.Cloud, contentDescription = "Server library") - } IconButton(onClick = onSettingsClick) { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) } @@ -947,6 +959,30 @@ fun LibraryScreenContent( selectedShelves = selectedShelves ) } + 2 -> { + FolderSyncScreen( + syncedFolders = syncedFolders, + allRecentFiles = rawLibraryFiles, + onAddFolderClick = onSelectSyncFolderClick, + onRemoveFolderClick = onRemoveFolderClick, + onFolderLocalSyncChange = onFolderLocalSyncChange, + onEditFolderFiltersClick = onEditFolderFiltersClick, + onScanNowClick = onScanNowClick, + onSyncMetadataClick = onSyncMetadataClick, + isLoading = isLoading || isRefreshing + ) + } + 3 -> { + if (!BuildConfig.IS_OFFLINE) { + OpdsTab( + localLibraryFiles = rawLibraryFiles, + onBookDownloaded = onOpdsBookDownloaded, + onReadBook = onItemClick, + onStreamBook = onStreamOpdsBook, + onDeleteCatalogStreams = onDeleteCatalogStreams + ) + } + } } } } @@ -1979,7 +2015,395 @@ private fun DeleteShelvesConfirmationDialog( } @Composable +private fun FolderSyncScreen( + syncedFolders: List, + allRecentFiles: List, + onAddFolderClick: () -> Unit, + onRemoveFolderClick: (SyncedFolder) -> Unit, + onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit, + onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit, + onScanNowClick: () -> Unit, + onSyncMetadataClick: () -> Unit, + isLoading: Boolean +) { + var editingFolder by remember { mutableStateOf(null) } + var disablingFolder by remember { mutableStateOf(null) } + val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled } + val folderStatsByUri = remember(allRecentFiles) { + allRecentFiles + .asSequence() + .filter { it.sourceFolderUri != null } + .groupBy { it.sourceFolderUri!! } + .mapValues { (_, files) -> + FolderFileStats( + totalBooks = files.size, + countsByType = files.groupingBy { it.type }.eachCount() + ) + } + } + + Scaffold( + floatingActionButton = { + if (syncedFolders.size < 10) { + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.fab_add_folder)) }, + icon = { Icon(Icons.Default.Add, "Add") }, + onClick = onAddFolderClick + ) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (syncedFolders.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + FilledTonalButton( + onClick = onScanNowClick, + enabled = !isLoading && hasEnabledSyncFolders, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.small + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Text(if (isLoading) stringResource(R.string.scanning) else stringResource(R.string.scan_all)) + } + + androidx.compose.material3.OutlinedButton( + onClick = onSyncMetadataClick, + enabled = !isLoading && hasEnabledSyncFolders, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.small + ) { + Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.sync_meta)) + } + } + } else { + EmptyState( + title = stringResource(R.string.sync_local_folders), + message = stringResource(R.string.sync_folders_desc), + onSelectFileClick = onAddFolderClick, + primaryButtonText = stringResource(R.string.action_select_folder), + modifier = Modifier.fillMaxSize() + ) + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 80.dp) + ) { + items(syncedFolders, key = { it.uriString }) { folder -> + FolderCard( + folder = folder, + stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty, + onRemoveClick = onRemoveFolderClick, + onLocalSyncToggleClick = { selectedFolder -> + if (selectedFolder.localSyncEnabled) { + disablingFolder = selectedFolder + } else { + onFolderLocalSyncChange(selectedFolder, true, false) + } + }, + onEditFiltersClick = { editingFolder = folder } + ) + } + } + } + } + + editingFolder?.let { folder -> + EditFolderFiltersDialog( + folder = folder, + onConfirm = { newFilters -> + onEditFolderFiltersClick(folder, newFilters) + editingFolder = null + }, + onDismiss = { editingFolder = null } + ) + } + + disablingFolder?.let { folder -> + AlertDialog( + onDismissRequest = { disablingFolder = null }, + title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) }, + text = { + Text( + stringResource( + R.string.dialog_disable_folder_local_sync_desc, + LOCAL_FOLDER_SYNC_DATA_DIR + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + onFolderLocalSyncChange(folder, false, true) + disablingFolder = null + } + ) { + Text(stringResource(R.string.action_disable_remove_sync_data)) + } + }, + dismissButton = { + Row { + TextButton(onClick = { disablingFolder = null }) { + Text(stringResource(R.string.action_cancel)) + } + TextButton( + onClick = { + onFolderLocalSyncChange(folder, false, false) + disablingFolder = null + } + ) { + Text(stringResource(R.string.action_disable_keep_sync_data)) + } + } + } + ) + } +} + +private data class FolderFileStats( + val totalBooks: Int, + val countsByType: Map +) { + companion object { + val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap()) + } +} + +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun FolderCard( + folder: SyncedFolder, + stats: FolderFileStats, + onRemoveClick: (SyncedFolder) -> Unit, + onLocalSyncToggleClick: (SyncedFolder) -> Unit, + onEditFiltersClick: (SyncedFolder) -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) } + val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime)) + + androidx.compose.material3.ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = Icons.Default.FolderSpecial, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = folder.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!folder.localSyncEnabled) { + Text( + text = stringResource(R.string.folder_local_sync_disabled), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, "Options") + } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_edit_filters)) }, + onClick = { + showMenu = false + onEditFiltersClick(folder) + } + ) + DropdownMenuItem( + text = { + Text( + if (folder.localSyncEnabled) { + stringResource(R.string.menu_disable_folder_local_sync) + } else { + stringResource(R.string.menu_enable_folder_local_sync) + } + ) + }, + onClick = { + showMenu = false + onLocalSyncToggleClick(folder) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_remove_folder)) }, + onClick = { + showMenu = false + onRemoveClick(folder) + }, + colors = androidx.compose.material3.MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error + ) + ) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.last_sync), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + Text(text = lastScanText, style = MaterialTheme.typography.bodySmall) + } + + Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) { + Text( + text = stringResource(R.string.books_count), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium) + } + } + + if (stats.countsByType.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + stats.countsByType.forEach { (type, count) -> + AssistChip( + onClick = { }, + label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) } + ) + } + } + } + } + } +} + +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun EditFolderFiltersDialog( + folder: SyncedFolder, + onConfirm: (Set) -> Unit, + onDismiss: () -> Unit +) { + var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Column { + Text( + text = stringResource(R.string.filter_file_types), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.filter_file_types_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) + + androidx.compose.foundation.layout.FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ANDROID_SYNCABLE_FILE_TYPES.forEach { type -> + val isSelected = type in selectedTypes + FilterChip( + selected = isSelected, + onClick = { + selectedTypes = if (isSelected) { + selectedTypes - type + } else { + selectedTypes + type + } + }, + label = { + Text( + text = type.name, + style = MaterialTheme.typography.labelLarge + ) + }, + leadingIcon = if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } else null, + shape = MaterialTheme.shapes.medium + ) + } + } + } + }, + confirmButton = { + androidx.compose.material3.Button( + onClick = { onConfirm(selectedTypes) }, + enabled = selectedTypes.isNotEmpty(), + shape = MaterialTheme.shapes.medium + ) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + ) +} + @OptIn(ExperimentalMaterial3Api::class) +@Composable fun LibraryFilterSheet( filters: LibraryFilters, allTags: List, @@ -2107,3 +2531,889 @@ fun LibraryFilterSheet( } } +@Composable +fun OpdsTab( + localLibraryFiles: List, + onBookDownloaded: (Uri, String) -> Unit, + onReadBook: (RecentFileItem) -> Unit, + onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit, + onDeleteCatalogStreams: (String) -> Unit, + opdsViewModel: OpdsViewModel = viewModel() +) { + val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle() + val downloadingState = uiState.downloadingState + val context = LocalContext.current + val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog) + var selectedEntry by remember { mutableStateOf(null) } + var showCatalogDialog by remember { mutableStateOf(false) } + var editingCatalog by remember { mutableStateOf(null) } + var catalogToDelete by remember { mutableStateOf(null) } + + BackHandler(enabled = uiState.isViewingCatalog) { + opdsViewModel.navigateBack() + } + + Box(modifier = Modifier.fillMaxSize()) { + if (!uiState.isViewingCatalog) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 88.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(uiState.catalogs, key = { it.id }) { catalog -> + OpdsCatalogCard( + catalog = catalog, + onClick = { opdsViewModel.openCatalog(catalog) }, + onEdit = if (catalog.isDefault) null else { + { + editingCatalog = catalog + showCatalogDialog = true + } + }, + onDelete = if (catalog.isDefault) null else { + { catalogToDelete = catalog } + }) + } + } + + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.fab_add_catalog)) }, + icon = { Icon(Icons.Default.Add, "Add") }, + onClick = { + editingCatalog = null + showCatalogDialog = true + }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp) + ) + } + } else { + // Screen 2: Viewing a specific feed/catalog + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + var showSearch by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + + val searchFocusRequester = remember { FocusRequester() } + + LaunchedEffect(showSearch) { + if (showSearch) { + delay(100) + searchFocusRequester.requestFocus() + } + } + + Box(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().height(64.dp) + .padding(horizontal = 4.dp) + ) { + IconButton(onClick = { + if (showSearch) { + showSearch = false + query = "" + } else { + opdsViewModel.navigateBack() + } + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + + if (showSearch) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text(stringResource(R.string.search_catalog_placeholder)) }, + modifier = Modifier.weight(1f).padding(vertical = 4.dp) + .focusRequester(searchFocusRequester), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + trailingIcon = { + IconButton(onClick = { + if (query.isNotBlank()) { + opdsViewModel.search(query) + showSearch = false + query = "" + } + }) { + Icon(Icons.Default.Search, "Search") + } + }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Search + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onSearch = { + if (query.isNotBlank()) { + opdsViewModel.search(query) + showSearch = false + query = "" + } + }) + ) + } else { + Text( + text = uiState.currentFeed?.title ?: stringResource(R.string.status_loading), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp) + ) + if (uiState.searchUrlTemplate != null) { + IconButton(onClick = { showSearch = true }) { + Icon(Icons.Default.Search, "Search") + } + } + } + } + + if (uiState.isLoading) { + androidx.compose.material3.LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) + ) + } + } + } + + if (uiState.currentFeed?.entries?.isEmpty() == true && !uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.feed_empty)) + } + } else { + val facets = uiState.currentFeed?.facets ?: emptyList() + if (facets.isNotEmpty()) { + val groups = facets.groupBy { it.group } + LazyRow( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + groups.forEach { (groupName, groupFacets) -> + item(key = groupName) { + var expanded by remember { mutableStateOf(false) } + val activeFacet = groupFacets.find { it.isActive } + ?: groupFacets.firstOrNull() + + Box { + FilterChip( + selected = activeFacet?.isActive == true, + onClick = { expanded = true }, + label = { Text(stringResource(R.string.filter_facet, groupName, activeFacet?.title ?: stringResource(R.string.action_select))) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + null + ) + }) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }) { + groupFacets.forEach { facet -> + DropdownMenuItem( + text = { Text(facet.title) }, + onClick = { + expanded = false + opdsViewModel.openFeedUrl(facet.url) + }, + trailingIcon = if (facet.isActive) { + { Icon(Icons.Default.Check, null) } + } else null) + } + } + } + } + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + val entries = uiState.currentFeed?.entries ?: emptyList() + itemsIndexed( + entries, + key = { index, item -> "${item.id}_$index" }) { index, entry -> + + if (index == entries.lastIndex) { + LaunchedEffect(index) { opdsViewModel.loadNextPage() } + } + + if (entry.isNavigation) { + OpdsNavigationCard(entry) { opdsViewModel.openFeedUrl(it) } + } else { + OpdsBookCard( + entry = entry, + localLibraryFiles = localLibraryFiles, + downloadState = downloadingState[entry.id], + coverImageLoader = coverImageLoader, + onDownloadClick = { acquisition -> + opdsViewModel.downloadBook( + entry, acquisition, context + ) { downloadedUri -> + onBookDownloaded(downloadedUri, entry.title) + } + }, + onReadClick = onReadBook, + onStreamClick = { + onStreamBook( + entry, + uiState.currentCatalog + ) + }, + onClick = { selectedEntry = entry }) + } + } + } + } + } + } + } + + // Error Banner overlay + uiState.errorMessage?.let { error -> + LaunchedEffect(error) { + delay(4000) + opdsViewModel.clearError() + } + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp) + .padding(bottom = 70.dp) + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp) + ) + } + } + + if (selectedEntry != null) { + OpdsBookDetailsSheet( + entry = selectedEntry!!, + localLibraryFiles = localLibraryFiles, + downloadState = downloadingState[selectedEntry!!.id], + coverImageLoader = coverImageLoader, + onDownloadFormat = { acquisition -> + opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri -> + onBookDownloaded(downloadedUri, selectedEntry!!.title) + } + }, + onReadClick = onReadBook, + onStreamClick = { selectedEntry?.let { onStreamBook(it, uiState.currentCatalog) } }, + onAuthorOrCategoryClick = { url, fallbackName -> + if (url != null) opdsViewModel.openFeedUrl(url) + else opdsViewModel.search(fallbackName) + selectedEntry = null + }, + onDismiss = { selectedEntry = null } + ) + } + } + + // Dynamic Add/Edit Dialog + if (showCatalogDialog) { + var newTitle by remember(editingCatalog) { mutableStateOf(editingCatalog?.title ?: "") } + var newUrl by remember(editingCatalog) { mutableStateOf(editingCatalog?.url ?: "") } + var newUsername by remember(editingCatalog) { mutableStateOf(editingCatalog?.username ?: "") } + var newPassword by remember(editingCatalog) { mutableStateOf(editingCatalog?.password ?: "") } + + val isEditMode = editingCatalog != null + + AlertDialog( + onDismissRequest = { + showCatalogDialog = false + editingCatalog = null + }, + title = { Text(if (isEditMode) stringResource(R.string.edit_catalog) else stringResource(R.string.add_opds_catalog)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = newTitle, + onValueChange = { newTitle = it }, + label = { Text(stringResource(R.string.catalog_name)) }, + singleLine = true + ) + OutlinedTextField( + value = newUrl, + onValueChange = { newUrl = it }, + label = { Text(stringResource(R.string.url)) }, + placeholder = { Text(stringResource(R.string.url_placeholder)) }, + singleLine = true + ) + Text(stringResource(R.string.auth_optional), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + OutlinedTextField( + value = newUsername, + onValueChange = { newUsername = it }, + label = { Text(stringResource(R.string.username)) }, + singleLine = true + ) + OutlinedTextField( + value = newPassword, + onValueChange = { newPassword = it }, + label = { Text(stringResource(R.string.password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password) + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (isEditMode) { + opdsViewModel.updateCatalog(editingCatalog!!.id, newTitle, newUrl, newUsername, newPassword) + } else { + opdsViewModel.addCatalog(newTitle, newUrl, newUsername, newPassword) + } + showCatalogDialog = false + editingCatalog = null + }, + enabled = newTitle.isNotBlank() && newUrl.isNotBlank() + ) { Text(stringResource(R.string.action_save)) } + }, + dismissButton = { + TextButton(onClick = { + showCatalogDialog = false + editingCatalog = null + }) { Text(stringResource(R.string.action_cancel)) } + } + ) + } + + if (catalogToDelete != null) { + val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true } + AlertDialog( + onDismissRequest = { catalogToDelete = null }, + title = { Text(stringResource(R.string.delete_catalog)) }, + text = { + Column { + Text(stringResource(R.string.delete_catalog_desc, catalogToDelete!!.title)) + if (streamedBooksCount > 0) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.delete_catalog_warning, streamedBooksCount), + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + opdsViewModel.removeCatalog(catalogToDelete!!.id) + if (streamedBooksCount > 0) { + onDeleteCatalogStreams(catalogToDelete!!.id) + } + catalogToDelete = null + }, + colors = androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { + TextButton(onClick = { catalogToDelete = null }) { Text(stringResource(R.string.action_cancel)) } + } + ) + } +} + +@Composable +private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader { + val context = LocalContext.current.applicationContext + val username = catalog?.username + val password = catalog?.password + val imageLoader = remember(context, username, password) { + ImageLoader.Builder(context) + .okHttpClient { + OpdsRepository.sharedHttpClient.newBuilder() + .authenticator(OpdsRepository.OpdsAuthenticator(username, password)) + .build() + } + .components { + add(SvgDecoder.Factory()) + } + .build() + } + DisposableEffect(imageLoader) { + onDispose { imageLoader.shutdown() } + } + return imageLoader +} + +@Composable +fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + Icon(Icons.Default.FolderSpecial, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (catalog.isDefault) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small + ) { + Text(stringResource(R.string.preset_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + Text(catalog.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (onEdit != null) { + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.label_edit)) + } + } + if (onDelete != null) { + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_remove)) + } + } + } + } +} + +@Composable +fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) { + Surface( + onClick = { entry.navigationUrl?.let { onClick(it) } }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(entry.title, style = MaterialTheme.typography.titleMedium) + entry.summary?.let { + val cleanSummary = remember(it) { Jsoup.parse(it).text() } + Text(cleanSummary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +fun OpdsBookCard( + entry: OpdsEntry, + localLibraryFiles: List, + downloadState: OpdsDownloadState?, + coverImageLoader: ImageLoader, + onDownloadClick: (OpdsAcquisition) -> Unit, + onReadClick: (RecentFileItem) -> Unit, + onStreamClick: () -> Unit, + onClick: () -> Unit +) { + val libraryItem = remember(entry, localLibraryFiles) { + SharedOpdsLocalBookMatcher.find( + entry = entry, + books = localLibraryFiles, + title = { it.title }, + displayName = { it.displayName }, + path = { it.uriString } + ) + } + val isDownloading = downloadState?.isDownloading == true + val progress = downloadState?.progress + val uniqueAcquisitions = remember(entry.acquisitions) { + entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority } + } + var showFormatMenu by remember { mutableStateOf(false) } + + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(12.dp)) { + AsyncImage( + model = entry.coverUrl, + contentDescription = null, + imageLoader = coverImageLoader, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 70.dp, height = 100.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + entry.author?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + entry.summary?.let { + val cleanSummary = remember(it) { Jsoup.parse(it).text() } + Text(cleanSummary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + + if (libraryItem != null) { + androidx.compose.material3.OutlinedButton( + onClick = { onReadClick(libraryItem) }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.action_read)) + } + } else if (isDownloading) { + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.labelMedium) + Spacer(modifier = Modifier.weight(1f)) + if (progress != null) { + Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium) + } + } + Spacer(modifier = Modifier.height(4.dp)) + if (progress != null) { + androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) + } else { + androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (entry.isStreamable) { + FilledTonalButton( + onClick = onStreamClick, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.action_stream)) + } + } + + Box { + FilledTonalButton( + onClick = { + if (uniqueAcquisitions.size == 1) { + onDownloadClick(uniqueAcquisitions.first()) + } else if (uniqueAcquisitions.size > 1) { + showFormatMenu = true + } + }, + enabled = uniqueAcquisitions.isNotEmpty(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + if (uniqueAcquisitions.isEmpty()) { + Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.action_unavailable)) + } else { + Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.action_download)) + } + } + } + DropdownMenu( + expanded = showFormatMenu, + onDismissRequest = { showFormatMenu = false } + ) { + uniqueAcquisitions.forEach { acq -> + DropdownMenuItem( + text = { Text(acq.formatName) }, + onClick = { + showFormatMenu = false + onDownloadClick(acq) + } + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OpdsBookDetailsSheet( + entry: OpdsEntry, + localLibraryFiles: List, + downloadState: OpdsDownloadState?, + coverImageLoader: ImageLoader, + onDownloadFormat: (OpdsAcquisition) -> Unit, + onReadClick: (RecentFileItem) -> Unit, + onStreamClick: () -> Unit, + onAuthorOrCategoryClick: (String?, String) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val libraryItem = remember(entry, localLibraryFiles) { + SharedOpdsLocalBookMatcher.find( + entry = entry, + books = localLibraryFiles, + title = { it.title }, + displayName = { it.displayName }, + path = { it.uriString } + ) + } + val isDownloading = downloadState?.isDownloading == true + val progress = downloadState?.progress + val uniqueAcquisitions = remember(entry.acquisitions) { + entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority } + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + AsyncImage( + model = entry.coverUrl, + contentDescription = null, + imageLoader = coverImageLoader, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = 160.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + lineHeight = 28.sp + ) + + if (entry.authors.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + entry.authors.forEach { author -> + Text( + text = author.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { + onAuthorOrCategoryClick(author.url, author.name) + } + ) + } + } + } + + entry.series?.takeIf { it.isNotBlank() }?.let { series -> + Spacer(modifier = Modifier.height(8.dp)) + val seriesText = if (!entry.seriesIndex.isNullOrBlank()) "$series #${entry.seriesIndex}" else series + Text( + text = seriesText, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable { + onAuthorOrCategoryClick(null, series) + } + ) + } + } + } + + if (libraryItem != null) { + androidx.compose.material3.Button( + onClick = { + onDismiss() + onReadClick(libraryItem) + }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.action_read)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.action_read), fontWeight = FontWeight.Bold) + } + } + + if (isDownloading) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + if (progress != null) { + Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.titleMedium) + } + } + Spacer(modifier = Modifier.height(8.dp)) + if (progress != null) { + androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().height(8.dp)) + } else { + androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(8.dp)) + } + } + } else if (uniqueAcquisitions.isNotEmpty() || entry.isStreamable) { + if (entry.isStreamable) { + androidx.compose.material3.Button( + onClick = { + onStreamClick() + onDismiss() + }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.action_stream_now), fontWeight = FontWeight.Bold) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + if (uniqueAcquisitions.isNotEmpty()) { + Text(stringResource(R.string.download_format), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + uniqueAcquisitions.forEach { acq -> + FilledTonalButton(onClick = { onDownloadFormat(acq) }) { + Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(acq.formatName, fontWeight = FontWeight.Bold) + } + } + } + } + } else { + Text(stringResource(R.string.no_supported_formats), color = MaterialTheme.colorScheme.error) + } + + if (entry.categories.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + entry.categories.distinct().forEach { category -> + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant, + onClick = { onAuthorOrCategoryClick(null, category) } + ) { + Text( + text = category, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + } + } + } + + val hasSecondaryMeta = !entry.publisher.isNullOrBlank() || !entry.published.isNullOrBlank() || !entry.language.isNullOrBlank() + if (hasSecondaryMeta) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + entry.publisher?.takeIf { it.isNotBlank() }?.let { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.publisher), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + entry.published?.takeIf { it.isNotBlank() }?.let { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.published), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + val cleanDate = it.substringBefore("T") + Text(cleanDate, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + } + entry.language?.takeIf { it.isNotBlank() }?.let { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.language), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + } + } + } + } + + val summary = entry.summary + if (!summary.isNullOrBlank()) { + Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + + val cleanSummary = remember(summary) { + val preProcessed = summary + .replace("
", "\n") + .replace("

", "\n\n") + Jsoup.parse(preProcessed).text().trim() + } + + Text( + text = cleanSummary, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 24.sp, + modifier = Modifier.padding(bottom = 48.dp) + ) + } else { + Spacer(modifier = Modifier.height(48.dp)) + } + } + } +} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt index 56d19a3..20d419c 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/MyApplication.kt @@ -24,11 +24,9 @@ import android.webkit.WebView import coil.ImageLoader import coil.ImageLoaderFactory import coil.decode.SvgDecoder -import dagger.hilt.android.HiltAndroidApp import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher import timber.log.Timber // Add this -@HiltAndroidApp class MyApplication : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt new file mode 100644 index 0000000..2f53581 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt @@ -0,0 +1,10 @@ +package org.dueattendant149.bookreader.opds + +typealias OpdsCatalog = org.dueattendant149.bookreader.shared.opds.OpdsCatalog +typealias OpdsFacet = org.dueattendant149.bookreader.shared.opds.OpdsFacet +typealias OpdsFeed = org.dueattendant149.bookreader.shared.opds.OpdsFeed +typealias OpdsAuthor = org.dueattendant149.bookreader.shared.opds.OpdsAuthor +typealias OpdsAcquisition = org.dueattendant149.bookreader.shared.opds.OpdsAcquisition +typealias OpdsEntry = org.dueattendant149.bookreader.shared.opds.OpdsEntry +typealias OpdsDownloadState = org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState +typealias OpdsScreenState = org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt new file mode 100644 index 0000000..d8af46f --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt @@ -0,0 +1,3 @@ +package org.dueattendant149.bookreader.opds + +typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt new file mode 100644 index 0000000..b19d280 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt @@ -0,0 +1,214 @@ +package org.dueattendant149.bookreader.opds + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs +import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.security.MessageDigest +import java.util.UUID + +class OpdsRepository(context: Context) : SharedOpdsRepository { + private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE) + private val parser = OpdsParser() + + companion object { + private const val KEY_CATALOGS_JSON = "opds_catalogs_json" + + val sharedHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor { chain -> + val originalRequest = chain.request() + val requestWithUserAgent = originalRequest.newBuilder() + .header("User-Agent", "EpistemeReader/1.0 (Android)") + .build() + chain.proceed(requestWithUserAgent) + } + .build() + } + } + + private val httpClient = sharedHttpClient + + override fun loadCatalogs(): List { + val jsonString = prefs.getString(KEY_CATALOGS_JSON, null) + val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString) + val catalogs = decodedCatalogs.ifEmpty { + SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() } + } + if (decodedCatalogs.isEmpty()) { + saveCatalogs(catalogs) + } + return catalogs + } + + fun getCatalogs(): List = loadCatalogs() + + override suspend fun getSearchTemplate( + openSearchUrl: String, + username: String?, + password: String? + ): String? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(openSearchUrl).build() + val response = getAuthenticatedClient(username, password).newCall(request).execute() + val body = response.body?.string() ?: return@withContext null + parser.extractOpenSearchTemplate(body, openSearchUrl) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch OpenSearch template") + null + } + } + + fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) { + saveCatalogs( + SharedOpdsCatalogs.addCatalog( + catalogs = loadCatalogs(), + title = title, + url = url, + username = username, + password = password, + idFactory = { UUID.randomUUID().toString() } + ) + ) + } + + fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { + saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password)) + } + + fun removeCatalog(id: String) { + saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id)) + } + + override fun saveCatalogs(catalogs: List) { + prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) } + } + + fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient { + return httpClient.newBuilder() + .authenticator(OpdsAuthenticator(username, password)) + .build() + } + + class OpdsAuthenticator(private val user: String?, private val pass: String?) : okhttp3.Authenticator { + private var cnonceCount = 0 + + override fun authenticate(route: okhttp3.Route?, response: okhttp3.Response): Request? { + if (user.isNullOrBlank() || pass.isNullOrBlank()) return null + + if (response.request.header("Authorization") != null) { + return null + } + + val wwwAuth = response.header("WWW-Authenticate") ?: return null + + if (wwwAuth.startsWith("Basic", ignoreCase = true)) { + val credential = okhttp3.Credentials.basic(user, pass) + return response.request.newBuilder().header("Authorization", credential).build() + } + + if (wwwAuth.startsWith("Digest", ignoreCase = true)) { + val realm = extractParam(wwwAuth, "realm") ?: "" + val nonce = extractParam(wwwAuth, "nonce") ?: "" + val qop = selectAuthQop(extractParam(wwwAuth, "qop")) + val opaque = extractParam(wwwAuth, "opaque") + + cnonceCount++ + val nc = String.format("%08x", cnonceCount) + val cnonce = UUID.randomUUID().toString().replace("-", "") + + val url = response.request.url + val uri = url.encodedPath + (if (url.encodedQuery != null) "?${url.encodedQuery}" else "") + + val ha1 = md5("$user:$realm:$pass") + val ha2 = md5("${response.request.method}:$uri") + + val responseHash = if (qop != null) { + md5("$ha1:$nonce:$nc:$cnonce:$qop:$ha2") + } else { + md5("$ha1:$nonce:$ha2") + } + + val digestHeader = buildString { + append("Digest username=\"$user\", ") + append("realm=\"$realm\", ") + append("nonce=\"$nonce\", ") + append("uri=\"$uri\", ") + append("response=\"$responseHash\"") + if (qop != null) { + append(", qop=$qop, nc=$nc, cnonce=\"$cnonce\"") + } + if (opaque != null) { + append(", opaque=\"$opaque\"") + } + } + + return response.request.newBuilder() + .header("Authorization", digestHeader) + .build() + } + + return null + } + + private fun extractParam(header: String, param: String): String? { + val match = Regex("$param=\"([^\"]+)\"").find(header) ?: Regex("$param=([^,\\s]+)").find(header) + return match?.groupValues?.get(1) + } + + private fun selectAuthQop(value: String?): String? { + return value + ?.split(',') + ?.map { it.trim().trim('"') } + ?.firstOrNull { it.equals("auth", ignoreCase = true) } + } + + private fun md5(input: String): String { + val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + } + + + override suspend fun fetchFeed(url: String, username: String?, password: String?): Result = withContext(Dispatchers.IO) { + Timber.tag("OpdsDebug").d("Starting fetch for URL: $url") + try { + val client = getAuthenticatedClient(username, password) + + val request = Request.Builder() + .url(url.trim()) + .header("User-Agent", "EpistemeReader/1.0 (Android)") + .build() + + Timber.tag("OpdsDebug").d("Executing network call...") + val response = client.newCall(request).execute() + + Timber.tag("OpdsDebug").d("Response Code: ${response.code}") + + if (!response.isSuccessful) { + val errorMsg = "HTTP ${response.code}: ${response.message}" + Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg") + return@withContext Result.failure(Exception(errorMsg)) + } + + val bodyString = response.body?.string() + if (bodyString.isNullOrBlank()) { + return@withContext Result.failure(Exception("Empty response body")) + } + + val feed = parser.parse(bodyString, url) + + Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.") + Result.success(feed) + } catch (e: Exception) { + Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url") + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt new file mode 100644 index 0000000..55388c2 --- /dev/null +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt @@ -0,0 +1,172 @@ +package org.dueattendant149.bookreader.opds + +import android.app.Application +import android.content.Context +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.shared.opds.SharedOpdsController +import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Request +import okhttp3.Response +import timber.log.Timber +import java.io.File +import java.util.UUID + +class OpdsViewModel(application: Application) : AndroidViewModel(application) { + private val repository = OpdsRepository(application) + private val controller = SharedOpdsController( + repository = repository, + feedLoadErrorMessage = { error -> + application.getString(R.string.opds_error_load_feed, error.message.orEmpty()) + }, + idFactory = { UUID.randomUUID().toString() } + ) + + private val _uiState = MutableStateFlow(controller.state) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadNextPage() { + viewModelScope.launch { + controller.loadNextPage(::emitState) + } + } + + fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) { + val downloadUrl = acquisition.url + val catalog = _uiState.value.currentCatalog + viewModelScope.launch { + updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f)) + try { + val tempFile = withContext(Dispatchers.IO) { + val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password) + val request = Request.Builder().url(downloadUrl).build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw OpdsDownloadFailedException( + context.getString(R.string.opds_error_download_failed, response.message) + ) + } + + val body = response.body + ?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response)) + val contentLength = body.contentLength() + val ext = resolveOpdsDownloadExtension(acquisition, response) + val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50) + val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext") + + body.byteStream().use { input -> + tempFile.outputStream().use { output -> + val buffer = ByteArray(8 * 1024) + var totalRead = 0L + var lastProgressUpdate = System.currentTimeMillis() + + while (true) { + val bytesRead = input.read(buffer) + if (bytesRead == -1) break + output.write(buffer, 0, bytesRead) + totalRead += bytesRead + + if (contentLength > 0) { + val now = System.currentTimeMillis() + if (now - lastProgressUpdate > 200) { + val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f) + withContext(Dispatchers.Main) { + updateDownloadState( + entry.id, + OpdsDownloadState(isDownloading = true, progress = progress) + ) + } + lastProgressUpdate = now + } + } + } + } + } + tempFile + } + } + + onDownloaded(Uri.fromFile(tempFile)) + } catch (e: Exception) { + Timber.e(e, "Download error") + val message = if (e is OpdsDownloadFailedException) { + e.message.orEmpty() + } else { + context.getString(R.string.opds_error_download_error, e.message.orEmpty()) + } + emitState(controller.setErrorMessage(message)) + } finally { + updateDownloadState(entry.id, null) + } + } + } + + private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String { + return SharedOpdsDownloadNamer.resolveExtension( + acquisition = acquisition, + contentDisposition = response.header("Content-Disposition"), + urlPathSegment = Uri.parse(acquisition.url).lastPathSegment + ) + } + + fun addCatalog(title: String, url: String, username: String?, password: String?) { + emitState(controller.addCatalog(title, url, username, password)) + } + + fun removeCatalog(id: String) { + emitState(controller.removeCatalog(id)) + } + + fun openCatalog(catalog: OpdsCatalog) { + viewModelScope.launch { + controller.openCatalog(catalog, ::emitState) + } + } + + fun openFeedUrl(url: String) { + viewModelScope.launch { + controller.openFeedUrl(url, ::emitState) + } + } + + fun navigateBack(): Boolean { + val returnsToPreviousFeed = controller.hasFeedHistory() + viewModelScope.launch { + controller.navigateBack(::emitState) + } + return returnsToPreviousFeed + } + + fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { + emitState(controller.updateCatalog(id, title, url, username, password)) + } + + fun search(query: String) { + viewModelScope.launch { + controller.search(query, ::emitState) + } + } + + fun clearError() { + emitState(controller.clearError()) + } + + private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) { + emitState(controller.updateDownloadState(entryId, downloadState)) + } + + private fun emitState(state: OpdsScreenState) { + _uiState.value = state + } + + private class OpdsDownloadFailedException(message: String) : Exception(message) +} diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt index 912a2d4..94c0e7d 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt @@ -783,7 +783,20 @@ class OpdsStreamDocumentWrapper( ) : ReaderDocument { private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() } - private val client = okhttp3.OkHttpClient.Builder().build() + private val catalog = catalogId?.let { + org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it } + } + + private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder() + .apply { + val streamCatalog = catalog + val username = streamCatalog?.username + val password = streamCatalog?.password + if (!username.isNullOrBlank() && !password.isNullOrBlank()) { + authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password)) + } + } + .build() private fun createErrorPageBytes(): ByteArray { val bitmap = createBitmap(800, 1200) @@ -814,7 +827,18 @@ class OpdsStreamDocumentWrapper( } } - val finalUrlTemplate = urlTemplate + val streamCatalog = catalog + val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) { + try { + val oldUrl = java.net.URL(urlTemplate) + val newUrl = java.net.URL(streamCatalog.url) + val oldBase = "${oldUrl.protocol}://${oldUrl.authority}" + val newBase = "${newUrl.protocol}://${newUrl.authority}" + urlTemplate.replace(oldBase, newBase) + } catch (_: Exception) { + urlTemplate + } + } else urlTemplate val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString()) .replace("{maxWidth}", "1600") diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Shapes.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Shapes.kt deleted file mode 100644 index a7e4dda..0000000 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Shapes.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.dueattendant149.bookreader.ui.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Shapes -import androidx.compose.ui.unit.dp - -/** - * Myne-style Material You 3 shape scheme: generous rounded corners. - */ -val AppShapes = Shapes( - extraSmall = RoundedCornerShape(8.dp), - small = RoundedCornerShape(12.dp), - medium = RoundedCornerShape(16.dp), - large = RoundedCornerShape(24.dp), - extraLarge = RoundedCornerShape(32.dp), -) \ No newline at end of file diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt index daf31e4..080c31c 100644 --- a/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt +++ b/app/src/main/java/com/dueattendant149/bookreader/reader/ui/theme/Theme.kt @@ -178,7 +178,6 @@ fun AppTheme( MaterialTheme( colorScheme = finalColorScheme, typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography, - shapes = AppShapes, content = content ) } diff --git a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt deleted file mode 100644 index a668bc2..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsScreen.kt +++ /dev/null @@ -1,144 +0,0 @@ -package org.dueattendant149.bookreader.bookshelf - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ServerSettingsScreen( - viewModel: ServerSettingsViewModel, - onBackClick: () -> Unit, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var bookshelfUrl by remember(uiState.bookshelfUrl) { mutableStateOf(uiState.bookshelfUrl) } - var absUrl by remember(uiState.absUrl) { mutableStateOf(uiState.absUrl) } - var absToken by remember(uiState.absToken) { mutableStateOf(uiState.absToken) } - - Scaffold( - topBar = { - androidx.compose.material3.CenterAlignedTopAppBar( - title = { Text("Server settings") }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp, vertical = 8.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - SettingsSectionCard(title = "Bookshelf API") { - OutlinedTextField( - value = bookshelfUrl, - onValueChange = { - bookshelfUrl = it - viewModel.updateBookshelfUrl(it) - }, - label = { Text("https://books.dueattendant149.org/") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - - SettingsSectionCard(title = "Audiobookshelf") { - OutlinedTextField( - value = absUrl, - onValueChange = { - absUrl = it - viewModel.updateAbsUrl(it) - }, - label = { Text("https://abs.dueattendant149.org/") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = absToken, - onValueChange = { - absToken = it - viewModel.updateAbsToken(it) - }, - label = { Text("Bearer token") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - } - - Button( - onClick = viewModel::save, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large - ) { - Text("Save") - } - - if (uiState.saved) { - Text( - "Saved", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } - } -} - -@Composable -private fun SettingsSectionCard( - title: String, - content: @Composable () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 12.dp) - ) - content() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt deleted file mode 100644 index eaaf09a..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/ServerSettingsViewModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.dueattendant149.bookreader.bookshelf - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.dueattendant149.bookreader.data.settings.ServerSettings -import javax.inject.Inject - -data class ServerSettingsUiState( - val bookshelfUrl: String = "", - val absUrl: String = "", - val absToken: String = "", - val saved: Boolean = false, -) - -@HiltViewModel -class ServerSettingsViewModel - @Inject - constructor( - private val serverSettings: ServerSettings, - ) : ViewModel() { - - private val _uiState = MutableStateFlow(ServerSettingsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - _uiState.value = ServerSettingsUiState( - bookshelfUrl = serverSettings.getBookshelfUrl() ?: "", - absUrl = serverSettings.getAbsUrl() ?: "", - absToken = serverSettings.getAbsToken() ?: "", - ) - } - - fun updateBookshelfUrl(url: String) { - _uiState.value = _uiState.value.copy(bookshelfUrl = url, saved = false) - } - - fun updateAbsUrl(url: String) { - _uiState.value = _uiState.value.copy(absUrl = url, saved = false) - } - - fun updateAbsToken(token: String) { - _uiState.value = _uiState.value.copy(absToken = token, saved = false) - } - - fun save() { - viewModelScope.launch { - serverSettings.setBookshelfUrl(_uiState.value.bookshelfUrl.trim()) - serverSettings.setAbsUrl(_uiState.value.absUrl.trim()) - serverSettings.setAbsToken(_uiState.value.absToken.trim()) - _uiState.value = _uiState.value.copy(saved = true) - } - } - } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt deleted file mode 100644 index c3b4432..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookreader.audio - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent -import android.os.Build -import android.os.Bundle -import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService -import dagger.hilt.android.AndroidEntryPoint -import org.dueattendant149.bookreader.R -import org.dueattendant149.bookreader.audio.Book -import org.dueattendant149.bookreader.MainActivity -import javax.inject.Inject - -@AndroidEntryPoint -@OptIn(UnstableApi::class) -class AudioPlaybackService : MediaSessionService() { - - @Inject - lateinit var exoPlayer: ExoPlayer - - private var mediaSession: MediaSession? = null - - override fun onCreate() { - super.onCreate() - createNotificationChannel() - val intent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) - mediaSession = MediaSession.Builder(this, exoPlayer) - .setSessionActivity(sessionActivity) - .build() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java) - } else { - @Suppress("DEPRECATION") - intent?.getParcelableExtra(EXTRA_BOOK) - } - - val notification = buildNotification(book) - startForeground(NOTIFICATION_ID, notification) - - return super.onStartCommand(intent, flags, startId) - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - return mediaSession - } - - override fun onTaskRemoved(rootIntent: Intent?) { - val player = mediaSession?.player ?: exoPlayer - if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) { - stopSelf() - } - } - - override fun onDestroy() { - mediaSession?.run { - player.release() - release() - } - mediaSession = null - super.onDestroy() - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - getString(R.string.audio_playback_channel), - NotificationManager.IMPORTANCE_LOW - ).apply { - description = getString(R.string.audio_playback_channel_description) - } - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager.createNotificationChannel(channel) - } - } - - private fun buildNotification(book: Book?): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(book?.title ?: getString(R.string.app_name)) - .setContentText(book?.author ?: "") - .setSmallIcon(R.mipmap.ic_launcher) - .setOngoing(true) - .setSilent(true) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .build() - } - - companion object { - const val EXTRA_BOOK = "extra_book" - private const val CHANNEL_ID = "audio_playback" - private const val NOTIFICATION_ID = 1 - } -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt deleted file mode 100644 index bcf6258..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookreader.audio - -/** - * Placeholder for the audio player progress state. - * - * TODO: When implementing the ExoPlayer/MediaSession audio player, - * hook [org.dueattendant149.bookreader.domain.use_case.remote.SyncPlaybackProgressUseCase] - * into the playback position update flow, e.g.: - * - * ``` - * val book = ... - * val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile - * val position = player.currentPosition.coerceAtLeast(0L) - * val duration = player.duration.coerceAtLeast(book.audioDuration) - * syncPlaybackProgressUseCase(book, currentFile, position, duration) - * ``` - */ -data class AudioPlayerProgress( - val bookId: Int, - val currentFile: String, - val position: Long, - val duration: Long, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt deleted file mode 100644 index 62de39f..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.dueattendant149.bookreader.audio - -data class AudioTrack( - val fileId: String, - val title: String, - val durationMs: Long, - val order: Int, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt deleted file mode 100644 index 381f7be..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.dueattendant149.bookreader.audio - -/** - * Minimal Book model stub for AudioPlaybackService. - * Ported from Book's Story domain model. - */ -data class Book( - val id: Long = 0, - val title: String = "", - val author: String = "", - val remoteId: String = "", - val libraryId: String = "", - val hasAudio: Boolean = false, - val audioDuration: Long = 0L, - val audioCurrentFile: String = "", - val audioCurrentPosition: Long = 0L, - val coverUrl: String = "", -) \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt deleted file mode 100644 index aa8b896..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt +++ /dev/null @@ -1,222 +0,0 @@ -package org.dueattendant149.bookreader.bookshelf - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse -import timber.log.Timber - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BookshelfLibraryScreen( - viewModel: BookshelfViewModel, - onItemClick: (UnifiedItemResponse) -> Unit, - onOpenSettings: () -> Unit = {}, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - if (uiState.libraries.isEmpty()) { - viewModel.loadLibraries() - } - } - - LaunchedEffect(viewModel) { - viewModel.downloadedFile.collect { uri -> - Timber.d("Downloaded book to $uri, opening in reader") - } - } - - Scaffold( - topBar = { - androidx.compose.material3.CenterAlignedTopAppBar( - title = { Text("Server Library") }, - actions = { - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = "Server settings") - } - } - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - if (uiState.isLoading && uiState.libraries.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - return@Scaffold - } - - uiState.error?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(16.dp) - ) - } - - val selected = uiState.selectedLibrary - if (selected == null) { - Text( - text = "Select a library", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) - LazyColumn( - contentPadding = PaddingValues(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(uiState.libraries) { library -> - LibraryCard( - library = library, - onClick = { viewModel.selectLibrary(library) } - ) - } - } - } else { - Text( - text = selected.name, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) - if (uiState.isLoading) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - if (uiState.downloadingItem != null) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - LazyColumn( - contentPadding = PaddingValues(16.dp), - modifier = Modifier.fillMaxSize() - ) { - items(uiState.items) { item -> - ItemCard( - item = item, - isDownloading = uiState.downloadingItem?.id == item.id, - onClick = { - viewModel.downloadBook(item) - onItemClick(item) - } - ) - } - } - } - } - } -} - -@Composable -private fun LibraryCard( - library: LibraryResponse, - onClick: () -> Unit, -) { - Card( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - text = library.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = "${library.itemCount} items · ${library.mediaType}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - } - } -} - -@Composable -private fun ItemCard( - item: UnifiedItemResponse, - isDownloading: Boolean, - onClick: () -> Unit, -) { - Card( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - shape = MaterialTheme.shapes.large, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - text = item.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - if (item.author.isNotBlank()) { - Text( - text = item.author, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - } - Text( - text = item.type, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - if (isDownloading) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) - Text( - "Downloading...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt deleted file mode 100644 index 072d823..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt +++ /dev/null @@ -1,176 +0,0 @@ -package org.dueattendant149.bookreader.bookshelf - -import android.app.Application -import android.net.Uri -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import okhttp3.ResponseBody -import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse -import java.io.File -import javax.inject.Inject - -@HiltViewModel -class BookshelfViewModel - @Inject - constructor( - application: Application, - private val repository: BookshelfApiRepository, - ) : AndroidViewModel(application) { - - private val _uiState = MutableStateFlow(BookshelfUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _downloadedFile = MutableSharedFlow(extraBufferCapacity = 1) - val downloadedFile: SharedFlow = _downloadedFile.asSharedFlow() - - fun loadLibraries() { - _uiState.value = _uiState.value.copy(isLoading = true, error = null) - viewModelScope.launch { - repository.getLibraries() - .onSuccess { libraries -> - _uiState.value = _uiState.value.copy( - libraries = libraries, - isLoading = false, - error = null - ) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = error.message ?: "Failed to load libraries" - ) - } - } - } - - fun selectLibrary(library: LibraryResponse) { - _uiState.value = _uiState.value.copy(selectedLibrary = library, items = emptyList()) - loadItems(library.id) - } - - fun loadItems(libraryId: String) { - _uiState.value = _uiState.value.copy(isLoading = true, error = null) - viewModelScope.launch { - repository.getLibraryItems(libraryId) - .onSuccess { response -> - _uiState.value = _uiState.value.copy( - items = response.items, - isLoading = false, - error = null - ) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = error.message ?: "Failed to load items" - ) - } - } - } - - fun search(libraryId: String, query: String) { - if (query.isBlank()) { - loadItems(libraryId) - return - } - _uiState.value = _uiState.value.copy(isLoading = true, error = null) - viewModelScope.launch { - repository.searchLibrary(libraryId, query) - .onSuccess { searchItems -> - val mapped = searchItems.map { bookItem -> - UnifiedItemResponse( - id = bookItem.id, - title = bookItem.title, - author = bookItem.author, - type = bookItem.mediaType, - mediaType = bookItem.mediaType, - libraryId = bookItem.libraryId, - coverUrl = bookItem.coverUrl - ) - } - _uiState.value = _uiState.value.copy( - items = mapped, - isLoading = false, - error = null - ) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = error.message ?: "Search failed" - ) - } - } - } - - fun downloadBook(item: UnifiedItemResponse) { - _uiState.value = _uiState.value.copy(downloadingItem = item) - viewModelScope.launch { - repository.downloadEbook(item.id) - .onSuccess { body -> - val file = saveToCache(item, body) - if (file != null) { - _downloadedFile.tryEmit(Uri.fromFile(file)) - } - _uiState.value = _uiState.value.copy(downloadingItem = null) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - downloadingItem = null, - error = error.message ?: "Download failed" - ) - } - } - } - - private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? { - return runCatching { - val cacheDir = getApplication().cacheDir - val dir = File(cacheDir, "bookshelf_downloads").apply { mkdirs() } - val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60) - val ext = guessExtension(item) - val file = File(dir, "${safeName}_${item.id}.$ext") - file.outputStream().use { out -> body.byteStream().copyTo(out) } - file - }.getOrElse { - _uiState.value = _uiState.value.copy( - downloadingItem = null, - error = "Failed to save file: ${it.message}" - ) - null - } - } - - private fun guessExtension(item: UnifiedItemResponse): String = when { - item.mediaType.contains("epub", ignoreCase = true) -> "epub" - item.mediaType.contains("pdf", ignoreCase = true) -> "pdf" - item.mediaType.contains("fb2", ignoreCase = true) -> "fb2" - item.mediaType.contains("mobi", ignoreCase = true) -> "mobi" - item.mediaType.contains("azw3", ignoreCase = true) -> "azw3" - item.mediaType.contains("docx", ignoreCase = true) -> "docx" - item.mediaType.contains("odt", ignoreCase = true) -> "odt" - item.mediaType.contains("txt", ignoreCase = true) -> "txt" - item.mediaType.contains("md", ignoreCase = true) -> "md" - else -> "epub" - } - } - - -data class BookshelfUiState( - val libraries: List = emptyList(), - val selectedLibrary: LibraryResponse? = null, - val items: List = emptyList(), - val isLoading: Boolean = false, - val error: String? = null, - val downloadingItem: UnifiedItemResponse? = null, -) \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt deleted file mode 100644 index db19620..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiClientFactory.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.audiobookshelf - -import android.util.Log -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import org.dueattendant149.bookreader.domain.util.fixUriScheme -import retrofit2.Retrofit -import retrofit2.converter.kotlinx.serialization.asConverterFactory -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Builds (and caches) a Retrofit-backed [AudiobookshelfApiService] for the configured - * Audiobookshelf instance. All requests are authenticated with the provided bearer token. - */ -@Singleton -class AudiobookshelfApiClientFactory - @Inject - constructor( - private val json: Json, - private val okHttpClient: OkHttpClient, - ) { - private var cachedUrl: String? = null - private var cachedToken: String? = null - private var cachedClient: AudiobookshelfApiService? = null - - @Synchronized - fun provideClient( - url: String, - token: String, - ): AudiobookshelfApiService? { - val fixedUrl = url.fixUriScheme() - if (fixedUrl == cachedUrl && token == cachedToken && cachedClient != null) { - return cachedClient - } - - val authClient = - okHttpClient - .newBuilder() - .addInterceptor { chain -> - val request = - chain - .request() - .newBuilder() - .header("Authorization", "Bearer $token") - .build() - chain.proceed(request) - } - .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) - .build() - - return runCatching { - Retrofit - .Builder() - .client(authClient) - .baseUrl(fixedUrl) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() - .create(AudiobookshelfApiService::class.java) - }.onFailure { - Log.e("AudiobookshelfApiFactory", "Failed to create client for $fixedUrl", it) - }.getOrNull().also { - cachedUrl = fixedUrl - cachedToken = token - cachedClient = it - } - } - - fun clearCache() { - cachedUrl = null - cachedToken = null - cachedClient = null - } - } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt deleted file mode 100644 index 1322334..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfApiService.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.audiobookshelf - -import okhttp3.ResponseBody -import org.dueattendant149.bookreader.data.remote.audiobookshelf.PlaybackProgressUpdateRequest -import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Path -import retrofit2.http.Streaming - -/** - * Direct Audiobookshelf API calls for audio files and covers. - * bookshelf-api does not proxy audio streams, so the app talks to ABS directly. - */ -interface AudiobookshelfApiService { - - @Streaming - @GET("api/items/{itemId}/file/{fileId}") - suspend fun downloadAudioFile( - @Path("itemId") itemId: String, - @Path("fileId") fileId: String, - ): Response - - @Streaming - @GET("api/items/{itemId}/download") - suspend fun downloadBook( - @Path("itemId") itemId: String, - ): Response - - @GET("api/items/{itemId}/cover") - suspend fun getCover( - @Path("itemId") itemId: String, - ): Response - - @GET("api/me/progress/{itemId}") - suspend fun getProgress( - @Path("itemId") itemId: String, - ): Response - - @GET("api/items/{itemId}") - suspend fun getItem( - @Path("itemId") itemId: String, - ): Response - - @POST("api/me/progress/{itemId}") - suspend fun updateProgress( - @Path("itemId") itemId: String, - @Body request: PlaybackProgressUpdateRequest, - ): Response -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt deleted file mode 100644 index 266ab4d..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/AudiobookshelfRepository.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.audiobookshelf - -import okhttp3.ResponseBody -import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse -import org.dueattendant149.bookreader.data.settings.ServerSettings -import retrofit2.Response -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Thin repository over [AudiobookshelfApiService]. Direct ABS calls for audio, - * cover and progress. bookshelf-api does not proxy audio streams. - */ -@Singleton -class AudiobookshelfRepository - @Inject - constructor( - private val clientFactory: AudiobookshelfApiClientFactory, - private val serverSettings: ServerSettings, - ) { - private suspend fun client() = - serverSettings.getAbsUrl()?.let { url -> - serverSettings.getAbsToken()?.let { token -> - clientFactory.provideClient(url, token) - } - } - - suspend fun getItem(itemId: String): Result { - val client = client() ?: return Result.failure(absNotConfigured()) - return safe { client.getItem(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun getCover(itemId: String): Result { - val client = client() ?: return Result.failure(absNotConfigured()) - return safe { client.getCover(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun downloadBook(itemId: String): Result { - val client = client() ?: return Result.failure(absNotConfigured()) - return safe { client.downloadBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun downloadAudioFile( - itemId: String, - fileId: String, - ): Result { - val client = client() ?: return Result.failure(absNotConfigured()) - return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun getProgress(itemId: String): Result { - val client = client() ?: return Result.failure(absNotConfigured()) - return safe { client.getProgress(itemId).unwrapBody() ?: "" } - } - - suspend fun updateProgress( - itemId: String, - request: PlaybackProgressUpdateRequest, - ): Result { - val client = client() ?: return Result.failure(absNotConfigured()) - return safe { client.updateProgress(itemId, request).unwrapBody() ?: "" } - } - - private fun absNotConfigured() = IllegalStateException("Audiobookshelf URL/token not configured") - - private inline fun safe(block: () -> T): Result = runCatching(block) - - private fun Response.unwrapBody(): T? { - if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}") - return body() - } - } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt deleted file mode 100644 index 6367890..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.audiobookshelf - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Request body for updating media progress directly on an Audiobookshelf server. - * - * Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract. - */ -@Serializable -data class PlaybackProgressUpdateRequest( - @SerialName("currentTime") - val currentTime: Double, - @SerialName("duration") - val duration: Double, - @SerialName("progress") - val progress: Double, - @SerialName("episodeId") - val episodeId: String? = null, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt deleted file mode 100644 index 3bbb58d..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/audiobookshelf/model/AbsItemResponse.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.audiobookshelf.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class AbsItemResponse( - val id: String = "", - val media: AbsMedia? = null, -) - -@Serializable -data class AbsMedia( - val metadata: AbsMediaMetadata? = null, - @SerialName("audioFiles") - val audioFiles: List = emptyList(), -) - -@Serializable -data class AbsMediaMetadata( - val title: String? = null, -) - -@Serializable -data class AbsAudioFile( - val ino: String = "", - val metadata: AbsAudioFileMetadata? = null, - val duration: Double = 0.0, -) - -@Serializable -data class AbsAudioFileMetadata( - val filename: String? = null, - val ext: String? = null, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt deleted file mode 100644 index 9a035ae..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiClientFactory.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi - -import android.util.Log -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import org.dueattendant149.bookreader.domain.util.fixUriScheme -import okhttp3.MediaType.Companion.toMediaType -import retrofit2.Retrofit -import retrofit2.converter.kotlinx.serialization.asConverterFactory -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Builds (and caches) a Retrofit-backed [BookshelfApiService] for the configured - * bookshelf-api instance. - */ -@Singleton -class BookshelfApiClientFactory - @Inject - constructor( - private val json: Json, - private val okHttpClient: OkHttpClient, - ) { - private var cachedUrl: String? = null - private var cachedClient: BookshelfApiService? = null - - @Synchronized - fun provideClient(url: String): BookshelfApiService? { - val fixedUrl = url.fixUriScheme() - if (fixedUrl == cachedUrl && cachedClient != null) { - return cachedClient - } - - return runCatching { - Retrofit - .Builder() - .client( - okHttpClient - .newBuilder() - .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC - }) - .build(), - ) - .baseUrl(fixedUrl) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() - .create(BookshelfApiService::class.java) - }.onFailure { - Log.e("BookshelfApiClientFactory", "Failed to create BookshelfApiService for $fixedUrl", it) - }.getOrNull().also { - cachedUrl = fixedUrl - cachedClient = it - } - } - - fun clearCache() { - cachedUrl = null - cachedClient = null - } - } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt deleted file mode 100644 index 050c12f..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiRepository.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi - -import okhttp3.MultipartBody -import okhttp3.RequestBody -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse -import org.dueattendant149.bookreader.data.settings.ServerSettings -import retrofit2.Response -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Thin repository over [BookshelfApiService]. Returns raw Retrofit responses - * so callers can decide how to map them to local entities. - */ -@Singleton -class BookshelfApiRepository - @Inject - constructor( - private val clientFactory: BookshelfApiClientFactory, - private val serverSettings: ServerSettings, - ) { - private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) } - - suspend fun health(): Boolean { - val client = client() ?: return false - return runCatching { client.health().isSuccessful }.getOrDefault(false) - } - - suspend fun getLibraries(): Result> { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.getLibraries().unwrapBody().orEmpty() } - } - - suspend fun getLibraryItems(libraryId: String): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.getLibraryItems(libraryId).unwrapBody() ?: LibraryItemsResponse() } - } - - suspend fun searchLibrary( - libraryId: String, - query: String, - ): Result> { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.searchLibrary(libraryId, query).unwrapBody().orEmpty() } - } - - suspend fun search(request: SearchRequest): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.search(request).unwrapBody() ?: SearchResponse() } - } - - suspend fun getBook(itemId: String): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.getBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun downloadEbook(itemId: String): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.downloadEbook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun getAudioTracks(itemId: String): Result> { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.getAudioTracks(itemId).unwrapBody().orEmpty() } - } - - suspend fun downloadAudioFile( - itemId: String, - fileId: String, - ): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun updateReadingProgress( - itemId: String, - request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest, - ): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.updateReadingProgress(itemId, request).let {} } - } - - suspend fun updatePlaybackProgress( - itemId: String, - request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest, - ): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.updatePlaybackProgress(itemId, request).let {} } - } - - suspend fun getTtsEngines(): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.getTtsEngines().unwrapBody() ?: TtsEnginesResponse() } - } - - suspend fun getTtsVoices(engine: String? = null): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.getTtsVoices(engine).unwrapBody() ?: TtsVoicesResponse() } - } - - suspend fun createTtsJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.createTtsJob(request).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun getTtsJobStatus(jobId: String): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.getTtsJobStatus(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun listTtsJobs(): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.listTtsJobs().unwrapBody() ?: TtsJobsResponse() } - } - - suspend fun downloadTtsAudio(jobId: String): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.downloadTtsAudio(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") } - } - - suspend fun startDownload(request: DownloadRequest): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.startDownload(request).unwrapBody() ?: DownloadResponse() } - } - - suspend fun listDownloads(): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.listDownloads().unwrapBody() ?: DownloadsListResponse() } - } - - suspend fun uploadBook( - file: MultipartBody.Part, - bookType: RequestBody, - ): Result { - val client = client() ?: return Result.failure(serverNotConfigured()) - return safe { client.uploadBook(file, bookType).unwrapBody() ?: UploadBookResponse(success = false) } - } - - private fun serverNotConfigured() = IllegalStateException("Bookshelf API URL not configured") - - private inline fun safe(block: () -> T): Result = runCatching(block) - - private fun Response.unwrapBody(): T? { - if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}") - return body() - } - } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt deleted file mode 100644 index 1541afe..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/BookshelfApiService.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi - -import okhttp3.ResponseBody -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexResponse -import okhttp3.MultipartBody -import okhttp3.RequestBody -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.Multipart -import retrofit2.http.Part -import retrofit2.http.Path -import retrofit2.http.Query -import retrofit2.http.Streaming -import retrofit2.http.POST - -/** - * Retrofit description of the Bookshelf API (bookshelf-api:8073). - * All endpoints are relative to the configured base URL. - */ -interface BookshelfApiService { - - // Health - @GET("health") - suspend fun health(): Response - - // Libraries / books - @GET("api/v1/books/libraries") - suspend fun getLibraries(): Response> - - @Streaming - @GET("api/v1/books/{itemId}/ebook") - suspend fun downloadEbook( - @Path("itemId") itemId: String, - ): Response - - @GET("api/v1/books/{itemId}/tracks") - suspend fun getAudioTracks( - @Path("itemId") itemId: String, - ): Response> - - @Streaming - @GET("api/v1/books/{itemId}/file/{fileId}") - suspend fun downloadAudioFile( - @Path("itemId") itemId: String, - @Path("fileId") fileId: String, - ): Response - - @Streaming - @GET("api/v1/books/{itemId}") - suspend fun getBook( - @Path("itemId") itemId: String, - ): Response - - @Streaming - @POST("api/v1/books/{itemId}/progress") - suspend fun updateReadingProgress( - @Path("itemId") itemId: String, - @Body request: ProgressUpdateRequest, - ): Response - - @Streaming - @POST("api/v1/books/{itemId}/playback-progress") - suspend fun updatePlaybackProgress( - @Path("itemId") itemId: String, - @Body request: PlaybackProgressUpdateRequest, - ): Response - - @GET("api/v1/books/library/{libraryId}/items") - suspend fun getLibraryItems( - @Path("libraryId") libraryId: String, - ): Response - - @GET("api/v1/books/library/{libraryId}/search") - suspend fun searchLibrary( - @Path("libraryId") libraryId: String, - @Query("q") query: String, - ): Response> - - @POST("api/v1/search") - suspend fun search( - @Body request: SearchRequest, - ): Response - - // TTS - @GET("api/v1/tts/engines") - suspend fun getTtsEngines(): Response - - @GET("api/v1/tts/voices") - suspend fun getTtsVoices( - @Query("engine") engine: String? = null, - ): Response - - @POST("api/v1/tts") - suspend fun createTtsJob( - @Body request: TtsCreateRequest, - ): Response - - @GET("api/v1/tts/{jobId}") - suspend fun getTtsJobStatus( - @Path("jobId") jobId: String, - ): Response - - @GET("api/v1/tts/jobs/list") - suspend fun listTtsJobs(): Response - - @Streaming - @GET("api/v1/tts/{jobId}/download") - suspend fun downloadTtsAudio( - @Path("jobId") jobId: String, - ): Response - - @Multipart - @POST("api/v1/upload/book") - suspend fun uploadBook( - @Part file: MultipartBody.Part, - @Part("book_type") bookType: RequestBody, - ): Response - - // Downloads / sources - @POST("api/v1/download") - suspend fun startDownload( - @Body request: DownloadRequest, - ): Response - - @GET("api/v1/download/list") - suspend fun listDownloads(): Response - - @POST("api/v1/podcasts") - suspend fun downloadPodcast( - @Body request: PodcastRequest, - ): Response - - @POST("api/v1/sources/yandex") - suspend fun downloadYandex( - @Body request: YandexRequest, - ): Response -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt deleted file mode 100644 index fec67b8..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/ServerStatusMonitor.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookreader.data.remote.bookshelfapi - -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.dueattendant149.bookreader.data.settings.ServerSettings -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ServerStatusMonitor - @Inject - constructor( - private val clientFactory: BookshelfApiClientFactory, - private val serverSettings: ServerSettings, - ) { - private val _isOnline = MutableStateFlow(false) - val isOnline = _isOnline.asStateFlow() - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var pingJob: Job? = null - - fun start() { - if (pingJob != null) return - pingJob = scope.launch { - while (true) { - val online = checkHealth() - _isOnline.value = online - delay(30_000L) - } - } - } - - fun stop() { - pingJob?.cancel() - pingJob = null - } - - private suspend fun checkHealth(): Boolean { - val url = serverSettings.getBookshelfUrl() ?: return false - val client = clientFactory.provideClient(url) ?: return false - return runCatching { - val response = client.health() - response.isSuccessful - }.onFailure { - Log.d("ServerStatusMonitor", "Health check failed: ${it.message}") - }.getOrDefault(false) - } - } diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt deleted file mode 100644 index 64f8727..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/AudioTrackResponse.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class AudioTrackResponse( - @SerialName("file_id") - val fileId: String, - val title: String = "", - val duration: Double = 0.0, - val size: Long = 0L, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt deleted file mode 100644 index bf2a1d1..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/BookItemResponse.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class BookItemResponse( - val id: String, - val title: String = "", - val author: String = "", - @SerialName("media_type") - val mediaType: String = "", - @SerialName("library_id") - val libraryId: String = "", - @SerialName("cover_url") - val coverUrl: String = "", - val duration: Double = 0.0, - val size: Long = 0L, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt deleted file mode 100644 index f7bb64e..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/DownloadResponses.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class DownloadRequest( - val source: String = "", - val title: String = "", - val author: String = "", - @SerialName("download_url") - val downloadUrl: String? = null, - @SerialName("magnet_url") - val magnetUrl: String? = null, - @SerialName("info_hash") - val infoHash: String? = null, - val md5: String? = null, - val url: String? = null, - @SerialName("media_type") - val mediaType: String = "ebook", - @SerialName("download_protocol") - val downloadProtocol: String = "", -) - -@Serializable -data class DownloadResponse( - val success: Boolean = false, - val error: String? = null, - @SerialName("download_id") - val downloadId: String? = null, -) - -@Serializable -data class DownloadStatusResponse( - val title: String = "", - val status: String = "", - val progress: Double = 0.0, - val speed: String = "", - val error: String? = null, -) - -@Serializable -data class DownloadsListResponse( - val downloads: List = emptyList(), -) - -@Serializable -data class PodcastRequest( - val url: String, - val title: String? = null, - val format: String = "m4a", -) - -@Serializable -data class PodcastResponse( - val url: String = "", - val title: String = "", - val tracks: Int = 0, - val files: List = emptyList(), - @SerialName("abs_scan_triggered") - val absScanTriggered: Boolean = false, -) - -@Serializable -data class YandexRequest( - val bookid: String, -) - -@Serializable -data class YandexResponse( - val bookid: String, - val files: List = emptyList(), - @SerialName("abs_scan_triggered") - val absScanTriggered: Boolean = false, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt deleted file mode 100644 index af4ce25..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/LibraryResponse.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class LibraryResponse( - val id: String, - val name: String = "", - @SerialName("media_type") - val mediaType: String = "", - @SerialName("item_count") - val itemCount: Int = 0, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt deleted file mode 100644 index 8776e6f..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Request body for updating the audio playback progress of a book on the bookshelf-api. - */ -@Serializable -data class PlaybackProgressUpdateRequest( - val itemId: String, - val libraryId: String, - @SerialName("current_file") - val currentFile: String, - @SerialName("current_position") - val currentPosition: Long, - val duration: Long, - @SerialName("updated_at") - val updatedAt: Long, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt deleted file mode 100644 index fb411a5..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Request body for updating the local reading progress of a book on the bookshelf-api. - */ -@Serializable -data class ProgressUpdateRequest( - val itemId: String, - val libraryId: String, - val scrollIndex: Int, - val scrollOffset: Int, - val progress: Float, - @SerialName("last_chapter") - val lastChapter: String? = null, - @SerialName("updated_at") - val updatedAt: Long, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt deleted file mode 100644 index 6ecc8d4..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/SearchResponse.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class SearchResponse( - val results: List = emptyList(), - @SerialName("search_time_ms") - val searchTimeMs: Int = 0, - val total: Int = 0, -) - -@Serializable -data class SearchResultItemResponse( - val source: String = "", - val title: String = "", - val author: String = "", - val format: String = "", - @SerialName("media_type") - val mediaType: String = "", - @SerialName("size_human") - val sizeHuman: String = "", - val seeders: Int? = null, - val score: Double = 0.0, - val guid: String = "", - val md5: String = "", - @SerialName("magnet_url") - val magnetUrl: String = "", - @SerialName("download_url") - val downloadUrl: String = "", - val url: String = "", - @SerialName("cover_url") - val coverUrl: String = "", - @SerialName("info_hash") - val infoHash: String = "", - @SerialName("download_protocol") - val downloadProtocol: String = "", -) - -@Serializable -data class SearchRequest( - val query: String, - val author: String? = null, - @SerialName("media_type") - val mediaType: String? = null, - val format: String? = null, - val language: String? = null, - @SerialName("year_from") - val yearFrom: Int? = null, - @SerialName("year_to") - val yearTo: Int? = null, - val limit: Int = 50, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt deleted file mode 100644 index 6f6cbe4..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/TtsResponses.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class TtsCreateRequest( - @SerialName("book_id") - val bookId: String, - val engine: String = "silero", - @SerialName("voice_id") - val voiceId: String = "", - val speed: Double = 1.0, -) - -@Serializable -data class TtsJobResponse( - @SerialName("job_id") - val jobId: String, - @SerialName("book_id") - val bookId: String, - val title: String = "", - val author: String = "", - val engine: String = "", - @SerialName("voice_id") - val voiceId: String = "", - val speed: Double = 1.0, - val status: String = "", - val progress: Double = 0.0, - @SerialName("current_chapter") - val currentChapter: String = "", - @SerialName("total_chapters") - val totalChapters: Int = 0, - @SerialName("completed_chapters") - val completedChapters: Int = 0, - @SerialName("output_path") - val outputPath: String = "", - val error: String = "", - @SerialName("created_at") - val createdAt: Double = 0.0, - @SerialName("started_at") - val startedAt: Double = 0.0, - @SerialName("completed_at") - val completedAt: Double = 0.0, -) - -@Serializable -data class TtsJobsResponse( - val jobs: List = emptyList(), -) - -@Serializable -data class TtsEnginesResponse( - val engines: List = emptyList(), -) - -@Serializable -data class TtsVoicesResponse( - val voices: List = emptyList(), -) - -@Serializable -data class TtsEngineResponse( - val id: String, - val name: String, - val capabilities: TtsEngineCapabilitiesResponse, -) - -@Serializable -data class TtsEngineCapabilitiesResponse( - @SerialName("supports_streaming") - val supportsStreaming: Boolean = false, - @SerialName("supports_cloning") - val supportsCloning: Boolean = false, - @SerialName("max_text_length") - val maxTextLength: Int = 5000, - @SerialName("needs_network") - val needsNetwork: Boolean = false, -) - -@Serializable -data class TtsVoiceResponse( - val id: String, - val name: String, - val language: String, - val engine: String, - val gender: String = "", - val quality: String = "", - @SerialName("requires_reference") - val requiresReference: Boolean = false, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt deleted file mode 100644 index d157d2b..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UnifiedItemResponse.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class LibraryItemsResponse( - val library: LibraryResponse = LibraryResponse(""), - val items: List = emptyList(), -) - -@Serializable -data class UnifiedItemResponse( - val id: String, - val title: String = "", - val authors: List = emptyList(), - val author: String = "", - val type: String = "", - @SerialName("media_type") - val mediaType: String = "", - @SerialName("library_id") - val libraryId: String = "", - @SerialName("cover_url") - val coverUrl: String = "", - val duration: Double = 0.0, - val size: Long = 0L, - @SerialName("progress") - val progress: UnifiedItemProgressResponse? = null, -) - -@Serializable -data class UnifiedItemProgressResponse( - val reading: ReadingProgressResponse? = null, - val playback: PlaybackProgressResponse? = null, -) - -@Serializable -data class ReadingProgressResponse( - @SerialName("itemId") - val itemId: String = "", - @SerialName("libraryId") - val libraryId: String = "", - @SerialName("scrollIndex") - val scrollIndex: Int = 0, - @SerialName("scrollOffset") - val scrollOffset: Int = 0, - val progress: Float = 0f, - @SerialName("last_chapter") - val lastChapter: String? = null, - @SerialName("updatedAt") - val updatedAt: Long = 0L, -) - -@Serializable -data class PlaybackProgressResponse( - @SerialName("itemId") - val itemId: String = "", - @SerialName("libraryId") - val libraryId: String = "", - @SerialName("current_file") - val currentFile: String = "", - @SerialName("current_position") - val currentPosition: Long = 0L, - val duration: Long = 0L, - @SerialName("updatedAt") - val updatedAt: Long = 0L, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt b/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt deleted file mode 100644 index 755c7e8..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/remote/bookshelfapi/model/UploadBookResponse.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.dueattendant149.bookreader.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class UploadBookResponse( - val success: Boolean = false, - val filename: String = "", - val type: String = "", - val path: String = "", - @SerialName("error") - val errorMessage: String? = null, -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt b/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt deleted file mode 100644 index 4bb5b4e..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/data/settings/ServerSettings.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.dueattendant149.bookreader.data.settings - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -private const val PREFS_NAME = "book_reader_server_settings" -private const val KEY_BOOKSHELF_URL = "bookshelf_url" -private const val KEY_ABS_URL = "abs_url" -private const val KEY_ABS_TOKEN = "abs_token" - -/** - * Minimal server URL/token storage. Replaces the full DataStore-based settings - * from Book's Story for Phase 2 backend scaffolding. - */ -@Singleton -class ServerSettings - @Inject - constructor( - @ApplicationContext context: Context, - ) { - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - - fun getBookshelfUrl(): String? = prefs.getString(KEY_BOOKSHELF_URL, null) - - fun setBookshelfUrl(url: String) { - prefs.edit { putString(KEY_BOOKSHELF_URL, url) } - } - - fun getAbsUrl(): String? = prefs.getString(KEY_ABS_URL, null) - - fun setAbsUrl(url: String) { - prefs.edit { putString(KEY_ABS_URL, url) } - } - - fun getAbsToken(): String? = prefs.getString(KEY_ABS_TOKEN, null) - - fun setAbsToken(token: String) { - prefs.edit { putString(KEY_ABS_TOKEN, token) } - } - } diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt deleted file mode 100644 index 9f8b78b..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/di/BackendModule.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.dueattendant149.bookreader.di - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfApiClientFactory -import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfRepository -import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiClientFactory -import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository -import org.dueattendant149.bookreader.data.settings.ServerSettings -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object BackendModule { - - @Provides - @Singleton - fun provideServerSettings(@ApplicationContext context: Context): ServerSettings = ServerSettings(context) - - @Provides - @Singleton - fun provideBookshelfApiClientFactory( - json: Json, - okHttpClient: OkHttpClient, - ): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient) - - @Provides - @Singleton - fun provideBookshelfApiRepository( - clientFactory: BookshelfApiClientFactory, - serverSettings: ServerSettings, - ): BookshelfApiRepository = BookshelfApiRepository(clientFactory, serverSettings) - - @Provides - @Singleton - fun provideAudiobookshelfApiClientFactory( - json: Json, - okHttpClient: OkHttpClient, - ): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient) - - @Provides - @Singleton - fun provideAudiobookshelfRepository( - clientFactory: AudiobookshelfApiClientFactory, - serverSettings: ServerSettings, - ): AudiobookshelfRepository = AudiobookshelfRepository(clientFactory, serverSettings) -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt deleted file mode 100644 index 9398e1b..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/di/NetworkModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.dueattendant149.bookreader.di - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object NetworkModule { - - @Provides - @Singleton - fun provideJson(): Json = Json { - ignoreUnknownKeys = true - explicitNulls = false - isLenient = true - } - - @Provides - @Singleton - fun provideOkHttpClient(): OkHttpClient = OkHttpClient - .Builder() - .addInterceptor( - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC - } - ) - .build() -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt deleted file mode 100644 index 5434d28..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.dueattendant149.bookreader.di - -import android.content.Context -import androidx.media3.exoplayer.ExoPlayer -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object PlaybackModule { - - @Provides - @Singleton - fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer = - ExoPlayer.Builder(context).build() -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt b/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt deleted file mode 100644 index feedbf8..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/domain/util/UriUtils.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.dueattendant149.bookreader.domain.util - -import android.net.Uri - -private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://") - -/** - * Returns true if the string is non-blank, has an http/https scheme, - * and can be parsed by [Uri.parse] into a host-bearing URI. - */ -fun String.isValidUri(): Boolean { - val trimmed = trim() - if (trimmed.isBlank()) return false - if (!trimmed.hasUriScheme()) return false - - val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return false - return !uri.host.isNullOrBlank() -} - -/** - * Returns true if the string starts with http:// or https:// (case-insensitive). - */ -fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this) - -private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$") - -/** - * Adds https:// (or http:// for bare IP addresses) if the string has no scheme. - */ -fun String.ensureUriScheme(): String { - val trimmed = trim() - if (trimmed.hasUriScheme()) return trimmed - val hostPart = trimmed.substringBefore("/") - val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://" - return "$scheme$trimmed" -} - -/** - * Adds a trailing slash if missing. - */ -fun String.ensureTrailingSlash(): String { - val trimmed = trim() - return if (trimmed.endsWith("/")) trimmed else "$trimmed/" -} - -/** - * Normalizes a URL for Retrofit: trims whitespace, adds a scheme if missing, - * and ensures a trailing slash. - */ -fun String.normalizeUri(): String = ensureUriScheme().ensureTrailingSlash() - -/** - * Legacy alias for [normalizeUri]. - */ -fun String.fixUriScheme(): String = normalizeUri() - -/** - * Derives a likely Audiobookshelf URL from a Bookshelf API URL. - * Replaces a known bookshelf-api port (8073) with the default ABS port (13378), - * or appends :13378 when no port is present. Returns null if the input is invalid. - */ -fun String.deriveAbsUrl(): String? { - if (!isValidUri()) return null - val normalized = normalizeUri() - val uri = Uri.parse(normalized) - val host = uri.host ?: return null - val port = uri.port - val scheme = uri.scheme?.lowercase() ?: "https" - - val derivedPort = when (port) { - 8073 -> 13378 - -1 -> 13378 - else -> port - } - return "$scheme://$host:$derivedPort/" -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/reader/ChapterDrawer.kt b/app/src/main/java/org/dueattendant149/bookreader/reader/ChapterDrawer.kt deleted file mode 100644 index 7d8b08f..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/reader/ChapterDrawer.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.dueattendant149.bookreader.reader - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp - -data class ChapterItem( - val title: String, - val pageIndex: Int, - val progress: Float = 0f, -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ChapterDrawer( - chapters: List, - currentChapterIndex: Int, - onChapterSelected: (Int) -> Unit, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Chapters", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 12.dp) - ) - - LazyColumn { - itemsIndexed(chapters) { index, chapter -> - val isCurrent = index == currentChapterIndex - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onChapterSelected(index) } - .padding(vertical = 12.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = chapter.title, - style = if (isCurrent) MaterialTheme.typography.titleMedium - else MaterialTheme.typography.bodyMedium, - color = if (isCurrent) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = "${(chapter.progress * 100).toInt()}%", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (isCurrent) { - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "●", - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodySmall - ) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/reader/Checkpoint.kt b/app/src/main/java/org/dueattendant149/bookreader/reader/Checkpoint.kt deleted file mode 100644 index 30c2016..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/reader/Checkpoint.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.dueattendant149.bookreader.reader - -import kotlinx.serialization.Serializable - -@Serializable -data class Checkpoint( - val bookId: String, - val chapterIndex: Int = 0, - val pageIndex: Int = 0, - val scrollOffset: Int = 0, - val progress: Float = 0f, - val timestamp: Long = System.currentTimeMillis(), -) diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt deleted file mode 100644 index c8399df..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.dueattendant149.bookreader.rsvp - -sealed class ReaderText { - data class Paragraph(val text: String) : ReaderText() - data class Heading(val text: String) : ReaderText() - data class Chapter(val title: String) : ReaderText() - data class Separator(val text: String = "") : ReaderText() - data class Image(val alt: String = "") : ReaderText() -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt deleted file mode 100644 index 8bf2271..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookreader.rsvp - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -/** - * Drives RSVP playback for a fixed token stream. - * - * @param scope scope used for the playback loop (typically `viewModelScope`). - */ -class RsvpEngine(private val scope: CoroutineScope) { - - data class State( - val currentIndex: Int = 0, - val isPlaying: Boolean = false, - val tokens: List = emptyList(), - val wpm: Int = 350, - val pauseOnParagraphEnd: Boolean = true, - val pauseOnChapterEnd: Boolean = true, - val pauseOnLongWords: Boolean = true, - ) { - val progress: Float - get() = if (tokens.isEmpty()) 0f - else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1) - - val currentToken: RsvpToken? - get() = tokens.getOrNull(currentIndex) - - val isAtEnd: Boolean - get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex - } - - private val _state = MutableStateFlow(State()) - val state: StateFlow = _state.asStateFlow() - - private var playbackJob: Job? = null - - fun setTokens(tokens: List) { - stop() - _state.value = _state.value.copy( - tokens = tokens, - currentIndex = 0, - isPlaying = false, - ) - } - - fun setWpm(wpm: Int) { - _state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500)) - } - - fun setPauseOnParagraphEnd(value: Boolean) { - _state.value = _state.value.copy(pauseOnParagraphEnd = value) - } - - fun setPauseOnChapterEnd(value: Boolean) { - _state.value = _state.value.copy(pauseOnChapterEnd = value) - } - - fun setPauseOnLongWords(value: Boolean) { - _state.value = _state.value.copy(pauseOnLongWords = value) - } - - fun seekToIndex(index: Int) { - val s = _state.value - if (s.tokens.isEmpty()) return - val clamped = index.coerceIn(0, s.tokens.lastIndex) - _state.value = s.copy(currentIndex = clamped) - } - - fun seekToProgress(progress: Float) { - val s = _state.value - if (s.tokens.isEmpty()) return - val clamped = progress.coerceIn(0f, 1f) - val idx = (clamped * s.tokens.lastIndex).toInt() - _state.value = s.copy(currentIndex = idx) - } - - fun skipForward(count: Int = 10) { - seekToIndex(_state.value.currentIndex + count) - } - - fun skipBackward(count: Int = 10) { - seekToIndex(_state.value.currentIndex - count) - } - - fun play() { - val s = _state.value - if (s.tokens.isEmpty()) return - if (s.isAtEnd) { - seekToIndex(0) - } - if (_state.value.isPlaying) return - _state.value = _state.value.copy(isPlaying = true) - startPlaybackLoop() - } - - fun pause() { - _state.value = _state.value.copy(isPlaying = false) - playbackJob?.cancel() - playbackJob = null - } - - fun toggle() { - if (_state.value.isPlaying) pause() else play() - } - - fun stop() { - pause() - } - - private fun startPlaybackLoop() { - playbackJob?.cancel() - playbackJob = scope.launch { - while (isActive && _state.value.isPlaying) { - val snapshot = _state.value - if (snapshot.isAtEnd) { - _state.value = snapshot.copy(isPlaying = false) - return@launch - } - - val token = snapshot.tokens[snapshot.currentIndex] - delay(delayMsFor(token, snapshot)) - - val after = _state.value - if (!after.isPlaying) return@launch - if (after.tokens.isEmpty()) return@launch - - _state.value = after.copy( - currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex) - ) - } - } - } - - /** - * Compute the delay (in ms) before advancing past [token]. - * - * Base = 60_000 / wpm, adjusted by: - * - per-word multiplier (long words / digits) - * - paragraph-end bonus (configurable, default +200 ms) - * - chapter-end bonus (configurable, default +400 ms) - */ - private fun delayMsFor(token: RsvpToken, snapshot: State): Long { - val wpm = snapshot.wpm.coerceAtLeast(50) - val baseMs = 60_000.0 / wpm - var ms = baseMs * token.multiplier - - if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) { - ms += 200.0 - } - if (token.isChapterEnd && snapshot.pauseOnChapterEnd) { - ms += 400.0 - } - if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) { - // multiplier already applied above; keep it - } else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) { - ms = baseMs - } - return ms.toLong().coerceAtLeast(20L) - } -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt deleted file mode 100644 index 69edbeb..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookreader.rsvp - -import androidx.compose.runtime.Immutable - -/** - * One word/segment in the RSVP stream. - * - * @param word the original word as it appears in the book. - * @param pivotIndex index of the optimal recognition point within [word]; - * characters before are [prefix], the pivot is [pivot] char, after are [suffix]. - * @param isParagraphEnd true if the token is the last word of a paragraph (longer pause). - * @param isChapterEnd true if the token follows a chapter heading. - * @param multiplier duration multiplier (1.0 baseline) for per-word pacing. - */ -@Immutable -data class RsvpToken( - val word: String, - val pivotIndex: Int, - val isParagraphEnd: Boolean, - val isChapterEnd: Boolean, - val multiplier: Float = 1f, -) { - val prefix: String - get() = word.substring(0, pivotIndex) - - val pivot: String - get() = word.substring(pivotIndex, pivotIndex + 1) - - val suffix: String - get() = word.substring(pivotIndex + 1) -} - -/** - * Optimal recognition point (ORP) lookup for word lengths 1..13+. - * Indices are 0-based positions of the focal character. - * Values follow the heuristic table from Spritz/speed-reading literature: - * 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3. - */ -object OrpTable { - private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3) - - fun pivotFor(word: String): Int { - val len = word.length - if (len == 0) return 0 - if (len <= TABLE.size) return TABLE[len - 1] - return (len * 0.3f).toInt().coerceIn(1, len - 1) - } -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt deleted file mode 100644 index b23d76b..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookreader.rsvp - -import org.dueattendant149.bookreader.rsvp.ReaderText - -/** - * Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader) - * into a flat list of [RsvpToken]s suitable for RSVP playback. - * - * - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image]. - * - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`. - * - Marks the first word after a chapter heading as `isChapterEnd = true`. - * - Strips punctuation-only tokens and empty whitespace tokens. - */ -object RsvpTokenizer { - - /** - * Tokenize a list of [ReaderText] blocks (as produced by the reader parser). - */ - fun tokenizeReaderText(blocks: List): List { - val tokens = ArrayList(blocks.size * 8) - var lastWasParagraphEnd = false - var pendingChapterEnd = false - - for (block in blocks) { - when (block) { - is ReaderText.Chapter -> { - pendingChapterEnd = true - lastWasParagraphEnd = false - } - - is ReaderText.Paragraph -> { - val raw = block.text - if (raw.isBlank()) { - lastWasParagraphEnd = true - } else { - tokenizeLine( - line = raw, - isParagraphEnd = lastWasParagraphEnd, - isChapterEnd = pendingChapterEnd, - out = tokens, - ) - lastWasParagraphEnd = false - pendingChapterEnd = false - } - } - - is ReaderText.Heading -> { - val raw = block.text - if (raw.isNotBlank()) { - tokenizeLine( - line = raw, - isParagraphEnd = lastWasParagraphEnd, - isChapterEnd = pendingChapterEnd, - out = tokens, - ) - lastWasParagraphEnd = false - pendingChapterEnd = false - } - } - - is ReaderText.Separator -> lastWasParagraphEnd = true - is ReaderText.Image -> Unit - } - } - - return tokens - } - - private fun tokenizeLine( - line: String, - isParagraphEnd: Boolean, - isChapterEnd: Boolean, - out: MutableList, - ) { - val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() } - if (words.isEmpty()) return - - val lastIndex = words.lastIndex - for ((idx, word) in words.withIndex()) { - val cleaned = cleanWord(word) - if (cleaned.isEmpty()) continue - val pivot = OrpTable.pivotFor(cleaned) - val paragraphEnd = isParagraphEnd && idx == lastIndex - val multiplier = computeMultiplier(cleaned) - out.add( - RsvpToken( - word = cleaned, - pivotIndex = pivot, - isParagraphEnd = paragraphEnd, - isChapterEnd = isChapterEnd && idx == 0, - multiplier = multiplier, - ) - ) - } - } - - private val WORD_SPLIT_REGEX = Regex("\\s+") - - private fun cleanWord(raw: String): String { - val sb = StringBuilder(raw.length) - var i = 0 - val len = raw.length - while (i < len) { - val c = raw[i] - if (c.isLetterOrDigit() || c == '-' || c == '\'') { - sb.append(c) - } - i++ - } - return sb.toString() - } - - /** - * Per-word pacing multiplier. - * - * - Long words (>9 chars) get +30% time for recognition. - * - Numbers/digits get +20% time. - * - Baseline 1.0 for normal words. - */ - private fun computeMultiplier(word: String): Float { - val len = word.length - val base = when { - len > 9 -> 1.3f - len > 6 -> 1.1f - else -> 1.0f - } - val hasDigit = word.any { it.isDigit() } - return if (hasDigit) base + 0.2f else base - } -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt deleted file mode 100644 index 0f5c2e6..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.dueattendant149.bookreader.tts - -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse -import okhttp3.ResponseBody - -interface RemoteTtsRepository { - suspend fun fetchEngines(): Result - suspend fun fetchVoices(engine: String? = null): Result - suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result - suspend fun getJob(jobId: String): Result - suspend fun downloadAudio(jobId: String): Result -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt deleted file mode 100644 index ce836e0..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.dueattendant149.bookreader.tts - -import okhttp3.ResponseBody -import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse -import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class RemoteTtsRepositoryImpl - @Inject - constructor( - private val bookshelfApi: BookshelfApiRepository, - ) : RemoteTtsRepository { - override suspend fun fetchEngines(): Result = bookshelfApi.getTtsEngines() - override suspend fun fetchVoices(engine: String?): Result = bookshelfApi.getTtsVoices(engine) - override suspend fun createJob(request: TtsCreateRequest): Result = bookshelfApi.createTtsJob(request) - override suspend fun getJob(jobId: String): Result = bookshelfApi.getTtsJobStatus(jobId) - override suspend fun downloadAudio(jobId: String): Result = bookshelfApi.downloadTtsAudio(jobId) - } diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt deleted file mode 100644 index a632e0f..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookreader.tts - -data class TtsEngine( - val id: String, - val name: String, - val supportsStreaming: Boolean, - val supportsCloning: Boolean, - val maxTextLength: Int, - val needsNetwork: Boolean, -) - -data class TtsVoice( - val id: String, - val name: String, - val language: String, - val engine: String, - val gender: String, - val quality: String, - val requiresReference: Boolean, -) - -data class TtsJob( - val jobId: String, - val bookId: String, - val title: String, - val author: String, - val engine: String, - val voiceId: String, - val speed: Double, - val status: String, - val progress: Double, - val currentChapter: String, - val totalChapters: Int, - val completedChapters: Int, - val outputPath: String, - val error: String, - val createdAt: Double, - val startedAt: Double, - val completedAt: Double, -) { - val isCompleted: Boolean get() = status == "completed" - val isFailed: Boolean get() = status == "failed" || status == "error" - val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending" -} diff --git a/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt b/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt deleted file mode 100644 index b487f14..0000000 --- a/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dueattendant149.bookreader.work - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters - -/** - * Stub for CacheDownloadWorker. No-op until full Book's Story data layer is ported. - */ -class CacheDownloadWorker( - appContext: Context, - params: WorkerParameters, -) : CoroutineWorker(appContext, params) { - override suspend fun doWork(): Result = Result.success() -} - -/** - * Stub for ProgressSyncWorker. No-op until full Book's Story data layer is ported. - */ -class ProgressSyncWorker( - appContext: Context, - params: WorkerParameters, -) : CoroutineWorker(appContext, params) { - override suspend fun doWork(): Result = Result.success() -} - -/** - * Stub for TtsDownloadWorker. No-op until full Book's Story data layer is ported. - */ -class TtsDownloadWorker( - appContext: Context, - params: WorkerParameters, -) : CoroutineWorker(appContext, params) { - override suspend fun doWork(): Result = Result.success() -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt deleted file mode 100644 index 21048f6..0000000 --- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.dueattendant149.bookreader.shared - -fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt b/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt deleted file mode 100644 index 7027816..0000000 --- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.dueattendant149.bookreader.shared.reader - -import android.util.Log -import org.dueattendant149.bookreader.BuildConfig - -const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics" -const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" -const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff" - -val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG - -fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { - if (!BuildConfig.DEBUG) return false - return tag == SharedEpubCutoffDiagnosticsTag || - runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false) -} - -fun writeSharedReaderDiagnostic(tag: String, message: String) { - if (!BuildConfig.DEBUG) return - Log.d(tag, message) -} - -inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) { - if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) { - writeSharedReaderDiagnostic(tag, message()) - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4db678c..f8c6274 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2066,6 +2066,4 @@ Reader text, highlights, and locations stay unchanged. %1$s -> %2$s - Audio Playback - Audio playback controls diff --git a/build.gradle.kts b/build.gradle.kts index 90f1ee0..5568021 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,10 +3,9 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.compose) apply false - alias(libs.plugins.kotlin.serialization) apply false - alias(libs.plugins.kotlin.ksp) apply false - alias(libs.plugins.hilt) apply false + alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.kover) apply false } @@ -19,6 +18,7 @@ subprojects { val rootTest = rootProject.tasks.named("test") tasks.matching { it.name == "allTests" || + it.name == "desktopTest" || it.name.endsWith("DebugUnitTest") }.configureEach { rootTest.configure { @@ -29,14 +29,3 @@ subprojects { maxHeapSize = "4g" } } - -allprojects { - configurations.all { - resolutionStrategy { - force("org.jetbrains.kotlin:kotlin-stdlib:2.1.20") - force("org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20") - force("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.20") - force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20") - } - } -} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 0000000..e0cd987 --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,1621 @@ +import org.gradle.api.GradleException +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.Sync +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.bundling.Compression +import org.gradle.api.tasks.bundling.Tar +import org.gradle.jvm.tasks.Jar +import org.gradle.process.ExecOperations +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.gradle.work.DisableCachingByDefault +import java.io.File +import java.security.MessageDigest +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.Properties +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream +import javax.imageio.ImageIO +import javax.inject.Inject + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.compose.multiplatform) +} + +@DisableCachingByDefault(because = "Verification task has no outputs.") +abstract class CheckBundledPdfiumRuntimeTask : DefaultTask() { + @get:Input + abstract val bundleRootPath: Property + + @get:Input + abstract val libraryPath: Property + + @TaskAction + fun checkRuntime() { + val bundleRoot = File(bundleRootPath.get()) + val library = bundleRoot.resolve(libraryPath.get()) + if (!library.isFile) { + throw GradleException( + "Missing bundled Pdfium runtime at ${library.absolutePath}. " + + "Expected ${libraryPath.get()} inside ${bundleRoot.absolutePath}." + ) + } + } +} + +@DisableCachingByDefault(because = "Renames package output produced by jpackage.") +abstract class RenameDesktopMsiOutputTask : DefaultTask() { + @get:Input + abstract val msiDirectoryPath: Property + + @get:Input + abstract val packageName: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val architecture: Property + + @TaskAction + fun renameOutput() { + val msiDirectory = File(msiDirectoryPath.get()) + val outputPackageName = packageName.get() + val outputPackageVersion = packageVersion.get() + val source = msiDirectory.resolve("$outputPackageName-$outputPackageVersion.msi") + if (!source.isFile) return + + val target = msiDirectory.resolve("$outputPackageName-$outputPackageVersion-${architecture.get()}.msi") + if (target.exists() && !target.delete()) { + throw GradleException("Could not replace existing MSI at ${target.absolutePath}.") + } + if (!source.renameTo(target)) { + throw GradleException("Could not rename MSI from ${source.absolutePath} to ${target.absolutePath}.") + } + } +} + +@DisableCachingByDefault(because = "Generates an MSIX manifest from package metadata.") +abstract class GenerateDesktopMsixManifestTask : DefaultTask() { + @get:Input + abstract val identityName: Property + + @get:Input + abstract val publisher: Property + + @get:Input + abstract val publisherDisplayName: Property + + @get:Input + abstract val packageName: Property + + @get:Input + abstract val packageDescription: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val architecture: Property + + @get:Input + abstract val executablePath: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + fun xmlEscaped(value: String): String { + return value.replace("&", "&") + .replace("\"", """) + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + } + + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + """ + + + + + ${xmlEscaped(packageName.get())} + ${xmlEscaped(publisherDisplayName.get())} + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + """.trimIndent() + "\n", + Charsets.UTF_8 + ) + } +} + +@DisableCachingByDefault(because = "Generates fixed-size MSIX logo assets from the desktop icon.") +abstract class GenerateDesktopMsixAssetsTask : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val sourceIconFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val source = ImageIO.read(sourceIconFile.get().asFile) + ?: throw GradleException("Could not read MSIX source icon ${sourceIconFile.get().asFile.absolutePath}.") + val output = outputDirectory.get().asFile + output.mkdirs() + writePng(source, output.resolve("Square44x44Logo.png"), 44) + writePng(source, output.resolve("Square150x150Logo.png"), 150) + writePng(source, output.resolve("StoreLogo.png"), 50) + } + + private fun writePng(source: BufferedImage, target: File, size: Int) { + val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + graphics.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC + ) + graphics.setRenderingHint( + RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY + ) + graphics.drawImage(source, 0, 0, size, size, null) + } finally { + graphics.dispose() + } + ImageIO.write(image, "png", target) + } +} + +@DisableCachingByDefault(because = "Packages the staged MSIX app image with Windows SDK makeappx.") +abstract class PackageDesktopMsixTask @Inject constructor( + private val execOperations: ExecOperations +) : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val packageRootDirectory: DirectoryProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @get:Input + abstract val makeAppxPath: Property + + @get:Input + abstract val hostOsId: Property + + @get:Input + abstract val hostArchId: Property + + @TaskAction + fun packageMsix() { + if (hostOsId.get() != "windows" || hostArchId.get() != "x64") { + throw GradleException( + "MSIX packaging requires a Windows x64 packaging host. " + + "Current host: ${hostOsId.get()} ${hostArchId.get()}." + ) + } + + val makeAppx = File(makeAppxPath.get()) + if (!makeAppx.isFile) { + throw GradleException( + "Windows SDK makeappx.exe was not found at ${makeAppx.absolutePath}. " + + "Install the Windows SDK MSIX packaging tools or set " + + "-PdesktopMakeAppxPath=." + ) + } + + val output = outputFile.get().asFile + output.parentFile.mkdirs() + if (output.exists() && !output.delete()) { + throw GradleException("Could not replace existing MSIX at ${output.absolutePath}.") + } + + execOperations.exec { + executable = makeAppx.absolutePath + args( + "pack", + "/d", + packageRootDirectory.get().asFile.absolutePath, + "/p", + output.absolutePath, + "/o" + ) + } + } +} + +@DisableCachingByDefault(because = "Signs the MSIX package with Windows SDK signtool.") +abstract class SignDesktopMsixTask @Inject constructor( + private val execOperations: ExecOperations +) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val unsignedMsixFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val certificateFile: RegularFileProperty + + @get:Input + abstract val signToolPath: Property + + @get:Input + abstract val certificatePassword: Property + + @get:Input + abstract val timestampUrl: Property + + @TaskAction + fun signMsix() { + val signTool = File(signToolPath.get()) + if (!signTool.isFile) { + throw GradleException( + "Windows SDK signtool.exe was not found at ${signTool.absolutePath}. " + + "Install the Windows SDK or set -PdesktopSignToolPath=." + ) + } + + val signArgs = mutableListOf( + "sign", + "/fd", + "SHA256", + "/f", + certificateFile.get().asFile.absolutePath + ) + val password = certificatePassword.get().trim() + if (password.isNotEmpty()) { + signArgs += listOf("/p", password) + } + val timestamp = timestampUrl.get().trim() + if (timestamp.isNotEmpty()) { + signArgs += listOf("/tr", timestamp, "/td", "SHA256") + } + signArgs += unsignedMsixFile.get().asFile.absolutePath + + execOperations.exec { + executable = signTool.absolutePath + args(signArgs) + } + } +} + +@DisableCachingByDefault(because = "Generates local desktop service config for native packages.") +abstract class GenerateDesktopCloudConfigTask : DefaultTask() { + @get:Input + abstract val configValues: MapProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val file = outputFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + configValues.get().entries.joinToString(separator = "\n", postfix = "\n") { (key, value) -> + "$key=${value.replace("\\", "\\\\").replace("\n", "")}" + } + ) + } +} + +@DisableCachingByDefault(because = "Verification task has no outputs.") +abstract class VerifyDesktopNativePackagingTask : DefaultTask() { + @get:Input + abstract val supportedHost: Property + + @get:Input + abstract val hostOsId: Property + + @get:Input + abstract val hostArchId: Property + + @get:Input + abstract val missingStandardServiceConfig: ListProperty + + @TaskAction + fun verify() { + if (!supportedHost.get()) { + throw GradleException( + "Desktop native packaging is currently release-supported only on Windows x64 and Linux x64. " + + "Current host: ${hostOsId.get()} ${hostArchId.get()}." + ) + } + val missing = missingStandardServiceConfig.get() + if (missing.isNotEmpty()) { + throw GradleException( + "Standard desktop packages require account/sync service config. Missing: " + + missing.joinToString(", ") + ". " + + "Set DESKTOP_FIREBASE_WEB_API_KEY and DESKTOP_GOOGLE_OAUTH_CLIENT_ID, " + + "use -PdesktopFlavor=oss for the offline build, or set " + + "-PdesktopAllowUnconfiguredStandardServices=true for a local non-GA package." + ) + } + } +} + +@DisableCachingByDefault(because = "Generates AUR package metadata from the local Linux distributable.") +abstract class PrepareDesktopAurPackageTask : DefaultTask() { + @get:Input + abstract val aurPackageName: Property + + @get:Input + abstract val providedPackageName: Property + + @get:Input + abstract val packageVersion: Property + + @get:Input + abstract val packageRelease: Property + + @get:Input + abstract val packageDescription: Property + + @get:Input + abstract val appDisplayName: Property + + @get:Input + abstract val installDirectoryName: Property + + @get:Input + abstract val launcherName: Property + + @get:Input + abstract val executableName: Property + + @get:Input + abstract val sourceUrl: Property + + @get:Input + abstract val projectUrl: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val linuxTarFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun prepare() { + val output = outputDirectory.get().asFile + val sourceTar = linuxTarFile.get().asFile + if (!sourceTar.isFile) { + throw GradleException("Missing Linux tarball for AUR packaging: ${sourceTar.absolutePath}") + } + + output.deleteRecursively() + output.mkdirs() + + val stagedTar = output.resolve(sourceTar.name) + sourceTar.copyTo(stagedTar, overwrite = true) + val sha256 = stagedTar.sha256() + val configuredSourceUrl = sourceUrl.get().trim() + val sourceEntry = if (configuredSourceUrl.isBlank()) { + stagedTar.name + } else { + "${stagedTar.name}::$configuredSourceUrl" + } + + output.resolve("PKGBUILD").writeText( + aurPkgbuild( + pkgname = aurPackageName.get(), + providedPackage = providedPackageName.get(), + pkgver = packageVersion.get(), + pkgrel = packageRelease.get(), + pkgdesc = packageDescription.get(), + appName = appDisplayName.get(), + installDir = installDirectoryName.get(), + launcher = launcherName.get(), + executable = executableName.get(), + source = sourceEntry, + sha256 = sha256, + projectUrl = projectUrl.get() + ) + ) + output.resolve(".SRCINFO").writeText( + aurSrcInfo( + pkgname = aurPackageName.get(), + providedPackage = providedPackageName.get(), + pkgver = packageVersion.get(), + pkgrel = packageRelease.get(), + pkgdesc = packageDescription.get(), + source = sourceEntry, + sha256 = sha256, + projectUrl = projectUrl.get() + ) + ) + } + + private fun File.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun shellSingleQuoted(value: String): String { + return "'" + value.replace("'", "'\"'\"'") + "'" + } + + private fun archRuntimeDependencies(): List { + return listOf( + "alsa-lib", + "atk", + "cairo", + "dbus", + "expat", + "fontconfig", + "freetype2", + "gcc-libs", + "gdk-pixbuf2", + "glib2", + "glibc", + "gtk3", + "libcups", + "libarchive", + "libsecret", + "libx11", + "libxcomposite", + "libxdamage", + "libxext", + "libxi", + "libxrandr", + "libxrender", + "libxtst", + "nss", + "pango", + "zlib" + ) + } + + private fun aurPkgbuild( + pkgname: String, + providedPackage: String, + pkgver: String, + pkgrel: String, + pkgdesc: String, + appName: String, + installDir: String, + launcher: String, + executable: String, + source: String, + sha256: String, + projectUrl: String + ): String { + val desktopFile = "$providedPackage.desktop" + val iconName = providedPackage + val depends = archRuntimeDependencies() + val mimeTypes = archDesktopMimeTypes() + return """ +pkgname=${shellSingleQuoted(pkgname)} +pkgver=${shellSingleQuoted(pkgver)} +pkgrel=${shellSingleQuoted(pkgrel)} +pkgdesc=${shellSingleQuoted(pkgdesc)} +arch=('x86_64') +url=${shellSingleQuoted(projectUrl)} +license=('AGPL-3.0-only') +depends=(${depends.joinToString(" ") { shellSingleQuoted(it) }}) +provides=(${shellSingleQuoted(providedPackage)}) +conflicts=(${shellSingleQuoted(providedPackage)}) +source=(${shellSingleQuoted(source)}) +sha256sums=(${shellSingleQuoted(sha256)}) +options=('!debug') + +package() { + install -dm755 "${'$'}pkgdir/opt/$installDir" + cp -a "$installDir/." "${'$'}pkgdir/opt/$installDir/" + chmod 755 "${'$'}pkgdir/opt/$installDir/bin/$executable" + + install -dm755 "${'$'}pkgdir/usr/bin" + ln -sf "/opt/$installDir/bin/$executable" "${'$'}pkgdir/usr/bin/$launcher" + + install -Dm644 "${'$'}pkgdir/opt/$installDir/share/licenses/LICENSE" "${'$'}pkgdir/usr/share/licenses/${'$'}pkgname/LICENSE" + + local icon_path + icon_path="${'$'}(find "${'$'}pkgdir/opt/$installDir" -name 'episteme_icon.png' -print -quit)" + if [[ -n "${'$'}icon_path" ]]; then + install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/icons/hicolor/512x512/apps/$iconName.png" + install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/pixmaps/$iconName.png" + fi + + install -Dm644 /dev/stdin "${'$'}pkgdir/usr/share/applications/$desktopFile" <<'EOF' +[Desktop Entry] +Type=Application +Name=$appName +Comment=$pkgdesc +Exec=$launcher %F +Icon=$iconName +Terminal=false +Categories=Office;Viewer; +MimeType=${mimeTypes.joinToString(";")}; +EOF +} +""".trimIndent() + "\n" + } + + private fun aurSrcInfo( + pkgname: String, + providedPackage: String, + pkgver: String, + pkgrel: String, + pkgdesc: String, + source: String, + sha256: String, + projectUrl: String + ): String { + val depends = archRuntimeDependencies() + return """ +pkgbase = $pkgname + pkgdesc = $pkgdesc + pkgver = $pkgver + pkgrel = $pkgrel + url = $projectUrl + arch = x86_64 + license = AGPL-3.0-only +${depends.joinToString("\n") { "\tdepends = $it" }} + provides = $providedPackage + conflicts = $providedPackage + source = $source + sha256sums = $sha256 + +pkgname = $pkgname +""".trimIndent() + "\n" + } + + private fun archDesktopMimeTypes(): List { + return listOf( + "application/pdf", + "application/epub+zip", + "application/x-mobipocket-ebook", + "application/vnd.amazon.ebook", + "application/vnd.amazon.mobi8-ebook", + "text/markdown", + "text/x-markdown", + "text/plain", + "text/html", + "application/xhtml+xml", + "application/x-fictionbook+xml", + "application/x-zip-compressed-fb2", + "application/zip", + "application/vnd.comicbook+zip", + "application/x-cbz", + "application/vnd.comicbook-rar", + "application/x-cbr", + "application/x-rar-compressed", + "application/x-cb7", + "application/x-7z-compressed", + "application/vnd.comicbook+tar", + "application/x-cbt", + "application/x-tar", + "application/tar", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/x-vnd.oasis.opendocument.text-flat-xml" + ) + } +} + +@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.") +abstract class StripInvalidJarSignaturesTask : DefaultTask() { + @get:Input + abstract val jarDirectoryPath: Property + + @TaskAction + fun stripSignatures() { + val jarDirectory = File(jarDirectoryPath.get()) + if (!jarDirectory.isDirectory) return + + var strippedJarCount = 0 + jarDirectory.walkTopDown() + .filter { it.isFile && it.extension.equals("jar", ignoreCase = true) } + .forEach { jar -> + val strippedEntries = stripInvalidJarSignatures(jar) + if (strippedEntries > 0) { + strippedJarCount += 1 + logger.lifecycle("Stripped $strippedEntries stale jar signature entr${if (strippedEntries == 1) "y" else "ies"} from ${jar.name}") + } + } + + if (strippedJarCount > 0) { + logger.lifecycle("Stripped stale jar signatures from $strippedJarCount ProGuard output jar${if (strippedJarCount == 1) "" else "s"}.") + } + } + + private fun stripInvalidJarSignatures(jar: File): Int { + val temp = Files.createTempFile(jar.parentFile.toPath(), "${jar.nameWithoutExtension}-unsigned-", ".jar") + var strippedEntries = 0 + + ZipFile(jar).use { source -> + ZipOutputStream(Files.newOutputStream(temp)).use { target -> + val seenEntries = mutableSetOf() + val entries = source.entries() + while (entries.hasMoreElements()) { + val sourceEntry = entries.nextElement() + val entryName = sourceEntry.name + if (!seenEntries.add(entryName)) continue + if (isJarSignatureResource(entryName)) { + strippedEntries += 1 + continue + } + + val targetEntry = ZipEntry(entryName) + if (sourceEntry.time >= 0) { + targetEntry.time = sourceEntry.time + } + target.putNextEntry(targetEntry) + if (!sourceEntry.isDirectory) { + source.getInputStream(sourceEntry).use { input -> + input.copyTo(target) + } + } + target.closeEntry() + } + } + } + + if (strippedEntries > 0) { + try { + Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } else { + Files.deleteIfExists(temp) + } + + return strippedEntries + } + + private fun isJarSignatureResource(entryName: String): Boolean { + val normalized = entryName.replace('\\', '/').uppercase() + if (!normalized.startsWith("META-INF/")) return false + + val metaInfName = normalized.removePrefix("META-INF/") + if (metaInfName.contains("/")) return false + + return metaInfName.startsWith("SIG-") || + metaInfName.endsWith(".SF") || + metaInfName.endsWith(".DSA") || + metaInfName.endsWith(".RSA") || + metaInfName.endsWith(".EC") + } +} + +fun desktopOsId(osName: String = System.getProperty("os.name")): String { + val normalized = osName.lowercase() + return when { + normalized.startsWith("windows") -> "windows" + normalized == "linux" || normalized.contains("linux") -> "linux" + normalized.startsWith("mac") || normalized.contains("darwin") -> "macos" + else -> "other" + } +} + +fun desktopArchId(osArch: String = System.getProperty("os.arch")): String { + return when (osArch.lowercase()) { + "amd64", "x86_64", "x64" -> "x64" + "aarch64", "arm64" -> "arm64" + "x86", "i386", "i686" -> "x86" + else -> "unknown" + } +} + +fun desktopSwtArtifactId( + osName: String = System.getProperty("os.name"), + osArch: String = System.getProperty("os.arch") +): String? { + return when (desktopOsId(osName)) { + "windows" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.win32.win32.aarch64" + else -> "org.eclipse.swt.win32.win32.x86_64" + } + + "linux" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.gtk.linux.aarch64" + else -> "org.eclipse.swt.gtk.linux.x86_64" + } + + "macos" -> when (desktopArchId(osArch)) { + "arm64" -> "org.eclipse.swt.cocoa.macosx.aarch64" + else -> "org.eclipse.swt.cocoa.macosx.x86_64" + } + + else -> null + } +} + +fun desktopPdfiumDirectoryName( + osName: String = System.getProperty("os.name"), + osArch: String = System.getProperty("os.arch") +): String { + return when (desktopOsId(osName)) { + "windows" -> "win-${desktopArchId(osArch)}-v8" + "linux" -> "linux-${desktopArchId(osArch)}-v8" + "macos" -> "mac-${desktopArchId(osArch)}-v8" + else -> "${desktopArchId(osArch)}-v8" + } +} + +fun desktopPdfiumLibraryPath( + osName: String = System.getProperty("os.name"), + osArch: String = System.getProperty("os.arch") +): String { + return when (desktopOsId(osName)) { + "windows" -> "bin/pdfium.dll" + "linux" -> "lib/libpdfium.so" + "macos" -> "lib/libpdfium.dylib" + else -> "lib/pdfium" + } +} + +fun desktopJdkToolName(toolName: String, osName: String = System.getProperty("os.name")): String { + return if (desktopOsId(osName) == "windows") "$toolName.exe" else toolName +} + +fun File.asDesktopJdkHome(): File { + val absolute = absoluteFile + return when { + absolute.isFile && absolute.parentFile?.name?.equals("bin", ignoreCase = true) == true -> + absolute.parentFile?.parentFile ?: absolute + + absolute.isDirectory && absolute.name.equals("bin", ignoreCase = true) -> + absolute.parentFile ?: absolute + + absolute.resolve("Contents/Home/bin").isDirectory -> + absolute.resolve("Contents/Home") + + else -> absolute + } +} + +fun File.desktopJdkTool(toolName: String, osName: String = System.getProperty("os.name")): File { + return resolve("bin/${desktopJdkToolName(toolName, osName)}") +} + +fun File.isDesktopPackagingJdk(osName: String = System.getProperty("os.name")): Boolean { + return desktopJdkTool("java", osName).isFile && + desktopJdkTool("jlink", osName).isFile && + desktopJdkTool("jpackage", osName).isFile +} + +fun File.desktopJdkMajorVersion(): Int? { + val releaseFile = resolve("release") + if (!releaseFile.isFile) return null + + val version = runCatching { + releaseFile.useLines { lines -> + lines.firstOrNull { it.startsWith("JAVA_VERSION=") } + ?.substringAfter("=") + ?.trim() + ?.trim('"') + } + }.getOrNull() ?: return null + + return version.removePrefix("1.").substringBefore(".").toIntOrNull() +} + +fun safeChildDirectories(root: File): List { + return runCatching { + root.listFiles()?.filter { it.isDirectory }.orEmpty() + }.getOrDefault(emptyList()) +} + +fun stableDesktopJdkCandidates(candidates: List, preferredMajorVersion: Int = 21): List { + return candidates + .map { it.asDesktopJdkHome() } + .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() } + .sortedWith( + compareBy { if (it.desktopJdkMajorVersion() == preferredMajorVersion) 0 else 1 } + .thenBy { it.desktopJdkMajorVersion() ?: Int.MAX_VALUE } + .thenBy { it.absolutePath.lowercase() } + ) +} + +fun desktopPathJdkCandidates(osName: String = System.getProperty("os.name")): List { + return System.getenv("PATH") + ?.split(File.pathSeparator) + .orEmpty() + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { File(it).resolve(desktopJdkToolName("jpackage", osName)) } + .filter { it.isFile } + .mapNotNull { it.parentFile?.parentFile } + .toList() +} + +fun desktopGradleJdkCandidates(): List { + val userHome = System.getProperty("user.home")?.let(::File) ?: return emptyList() + return stableDesktopJdkCandidates(safeChildDirectories(userHome.resolve(".gradle/jdks"))) +} + +fun desktopPlatformJdkCandidates(osName: String = System.getProperty("os.name")): List { + val roots = when (desktopOsId(osName)) { + "windows" -> listOfNotNull( + System.getenv("ProgramFiles")?.let { File(it, "Java") }, + System.getenv("ProgramFiles")?.let { File(it, "Eclipse Adoptium") }, + System.getenv("ProgramFiles")?.let { File(it, "Microsoft") }, + System.getenv("ProgramFiles(x86)")?.let { File(it, "Java") } + ) + + "linux" -> listOf( + File("/usr/lib/jvm"), + File("/usr/java"), + File("/opt/java"), + File("/opt/jdk") + ) + + "macos" -> listOfNotNull( + File("/Library/Java/JavaVirtualMachines"), + System.getProperty("user.home")?.let { File(it, "Library/Java/JavaVirtualMachines") } + ) + + else -> emptyList() + } + + return stableDesktopJdkCandidates(roots + roots.flatMap(::safeChildDirectories)) +} + +fun findDesktopPackagingJavaHome( + explicitCandidates: List, + implicitCandidates: List, + osName: String = System.getProperty("os.name") +): File? { + val explicitJavaHome = explicitCandidates.firstOrNull { it.isNotBlank() } + if (explicitJavaHome != null) { + val candidate = File(explicitJavaHome).asDesktopJdkHome() + if (!candidate.isDesktopPackagingJdk(osName)) { + throw GradleException( + "Desktop packaging JDK must include java, jlink, and jpackage under " + + "${candidate.resolve("bin").absolutePath}. " + + "Set -PdesktopPackagingJavaHome= or DESKTOP_PACKAGING_JAVA_HOME to a full JDK." + ) + } + return candidate + } + + return implicitCandidates + .map { it.asDesktopJdkHome() } + .distinctBy { runCatching { it.canonicalPath }.getOrElse { _ -> it.absolutePath }.lowercase() } + .firstOrNull { it.isDesktopPackagingJdk(osName) } +} + +fun normalizeDesktopPackageVersion(rawVersion: String): String { + val coreVersion = rawVersion.trim() + .substringBefore("-") + .substringBefore("+") + .takeIf { it.isNotBlank() } + ?: "1.0.0" + val parts = coreVersion.split(".") + val numericParts = parts.map { it.toIntOrNull() } + val normalizedParts = when { + parts.size in 1..3 && numericParts.all { it != null } -> + numericParts.map { it ?: 0 } + List(3 - parts.size) { 0 } + + else -> throw GradleException( + "desktopPackageVersion must be numeric MAJOR[.MINOR[.BUILD]], but was '$rawVersion'." + ) + } + val (major, minor, build) = normalizedParts + if (major !in 0..255 || minor !in 0..255 || build !in 0..65535) { + throw GradleException( + "desktopPackageVersion '$rawVersion' is outside the Windows package version range. " + + "Expected MAJOR 0..255, MINOR 0..255, BUILD 0..65535." + ) + } + return "$major.$minor.$build" +} + +fun normalizeDesktopVersionName(rawVersion: String): String { + return rawVersion.trim().takeIf { it.isNotBlank() } ?: "1.0.0" +} + +fun normalizeDesktopFlavor(rawFlavor: String): String { + val flavor = rawFlavor.trim().lowercase() + return when (flavor) { + "oss", "oss-offline", "episteme-oss" -> "oss-offline" + else -> "standard" + } +} + +fun normalizeDesktopPackageArchitecture(osArch: String): String { + val normalizedArch = desktopArchId(osArch) + return if (normalizedArch != "unknown") { + normalizedArch + } else { + osArch.lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .ifBlank { "unknown" } + } +} + +fun desktopDefaultPackageFormats(osName: String = System.getProperty("os.name")): String { + return when (desktopOsId(osName)) { + "windows" -> "msi" + "linux" -> "deb,rpm" + "macos" -> "dmg" + else -> "" + } +} + +fun desktopTargetFormatForId(format: String): TargetFormat { + return when (format.lowercase()) { + "exe" -> TargetFormat.Exe + "msi" -> TargetFormat.Msi + "deb" -> TargetFormat.Deb + "rpm" -> TargetFormat.Rpm + "dmg" -> TargetFormat.Dmg + "pkg" -> TargetFormat.Pkg + else -> throw GradleException( + "Unsupported desktopPackageFormats entry '$format'. " + + "Use one or more of: msi, exe, deb, rpm, dmg, pkg." + ) + } +} + +fun desktopPackageFormatId(format: TargetFormat): String { + return when (format) { + TargetFormat.Exe -> "exe" + TargetFormat.Msi -> "msi" + TargetFormat.Deb -> "deb" + TargetFormat.Rpm -> "rpm" + TargetFormat.Dmg -> "dmg" + TargetFormat.Pkg -> "pkg" + else -> format.name.lowercase() + } +} + +fun desktopPackageFormatSupportedOnHost( + format: TargetFormat, + osName: String = System.getProperty("os.name") +): Boolean { + return when (desktopOsId(osName)) { + "windows" -> format == TargetFormat.Msi || format == TargetFormat.Exe + "linux" -> format == TargetFormat.Deb || format == TargetFormat.Rpm + "macos" -> format == TargetFormat.Dmg || format == TargetFormat.Pkg + else -> false + } +} + +fun normalizeDesktopPackageFormats( + rawFormats: String, + osName: String = System.getProperty("os.name") +): List { + val formats = rawFormats + .split(',', ';', ' ', '\n', '\t') + .map { it.trim() } + .filter { it.isNotBlank() } + .map(::desktopTargetFormatForId) + .distinct() + if (formats.isEmpty()) { + throw GradleException( + "desktopPackageFormats resolved to no package formats for ${desktopOsId(osName)}. " + + "Set -PdesktopPackageFormats=msi on Windows or -PdesktopPackageFormats=deb,rpm on Linux." + ) + } + val unsupported = formats.filterNot { desktopPackageFormatSupportedOnHost(it, osName) } + if (unsupported.isNotEmpty()) { + throw GradleException( + "desktopPackageFormats=${formats.joinToString(",") { desktopPackageFormatId(it) }} does not match " + + "the current packaging host ${desktopOsId(osName)}. Unsupported here: " + + unsupported.joinToString(",") { desktopPackageFormatId(it) } + "." + ) + } + return formats +} + +fun normalizeDesktopMsixVersion(rawVersion: String): String { + val parts = rawVersion.trim().split('.') + if (parts.size !in 3..4 || parts.any { it.isBlank() || it.all(Char::isDigit).not() }) { + throw GradleException( + "desktopMsixVersion must be a numeric Windows package version with three or four parts, " + + "for example 1.0.1 or 1.0.1.0." + ) + } + val normalized = if (parts.size == 3) parts + "0" else parts + normalized.forEach { part -> + val value = part.toIntOrNull() + if (value == null || value !in 0..65535) { + throw GradleException("desktopMsixVersion part '$part' is outside the MSIX range 0..65535.") + } + } + return normalized.joinToString(".") +} + +fun normalizeDesktopMsixIdentityName(rawName: String): String { + val normalized = rawName.trim() + if (!Regex("[A-Za-z0-9][A-Za-z0-9.-]{2,49}").matches(normalized)) { + throw GradleException( + "desktopMsixIdentityName must be 3-50 characters using letters, numbers, dots, or hyphens." + ) + } + return normalized +} + +fun desktopMsixArchitecture(osArch: String = System.getProperty("os.arch")): String { + return when (desktopArchId(osArch)) { + "x64" -> "x64" + "arm64" -> "arm64" + "x86" -> "x86" + else -> "neutral" + } +} + +fun latestExistingFile(candidates: List): File? { + return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath } +} + +fun windowsSdkToolCandidates(toolName: String): List { + val roots = listOfNotNull( + System.getenv("WindowsSdkDir")?.let(::File), + File("C:/Program Files (x86)/Windows Kits/10"), + File("C:/Program Files/Windows Kits/10"), + File("C:/Program Files (x86)/Windows Kits/10/App Certification Kit"), + File("C:/Program Files/Windows Kits/10/App Certification Kit") + ).distinctBy { it.absolutePath.lowercase() } + val sdkBins = roots.flatMap { root -> + safeChildDirectories(root.resolve("bin")).flatMap { versionDir -> + listOf( + versionDir.resolve("x64/$toolName.exe"), + versionDir.resolve("x86/$toolName.exe"), + versionDir.resolve(toolName) + ) + } + } + val directBins = roots.map { root -> root.resolve("$toolName.exe") } + val pathBins = (System.getenv("PATH") ?: "") + .split(File.pathSeparator) + .filter { it.isNotBlank() } + .map { File(it).resolve("$toolName.exe") } + return sdkBins + directBins + pathBins +} + +fun findWindowsSdkTool(toolName: String, explicitPath: String?): File { + val explicit = explicitPath?.trim()?.takeIf { it.isNotEmpty() }?.let(::File) + if (explicit != null) return explicit + return latestExistingFile(windowsSdkToolCandidates(toolName)) + ?: File(rootProject.projectDir, "__missing_windows_sdk_tool__/$toolName.exe") +} + +val desktopVersionName = "1.0.1" +val desktopFlavor = providers.gradleProperty("desktopFlavor") + .orElse("standard") + .map(::normalizeDesktopFlavor) + .get() +val isOssOfflineDesktop = desktopFlavor == "oss-offline" +val desktopDiagnostics = providers.gradleProperty("desktopDiagnostics") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) +val desktopDiagnosticTags = providers.gradleProperty("desktopDiagnosticTags") + .orElse("") +val desktopResolvedVersionName = providers.gradleProperty("desktopVersionName") + .orElse(providers.gradleProperty("desktopVersion")) + .orElse(desktopVersionName) + .map(::normalizeDesktopVersionName) +val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion") + .orElse(desktopResolvedVersionName) + .map(::normalizeDesktopPackageVersion) +val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme" +val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme" +val desktopPackageDescription = if (isOssOfflineDesktop) { + "Episteme oss offline desktop reader" +} else { + "Episteme desktop reader" +} +val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan") +val desktopVendorName = desktopVendor.get() +val desktopProjectUrl = providers.gradleProperty("desktopProjectUrl") + .orElse("https://github.com/Aryan-Raj3112/episteme") +val desktopOsName = System.getProperty("os.name") +val desktopOsArch = System.getProperty("os.arch") +val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch) +val desktopAurPackageName = providers.gradleProperty("desktopAurPackageName") + .orElse(if (isOssOfflineDesktop) "episteme-oss-bin" else "episteme-bin") +val desktopAurPackageRelease = providers.gradleProperty("desktopAurPackageRelease") + .orElse("1") +val desktopAurSourceUrl = providers.gradleProperty("desktopAurSourceUrl") + .orElse("") +val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats") + .orElse(desktopDefaultPackageFormats(desktopOsName)) + .map { normalizeDesktopPackageFormats(it, desktopOsName) } + .get() +val desktopMsixIdentityName = providers.gradleProperty("desktopMsixIdentityName") + .orElse(if (isOssOfflineDesktop) "Aryan.EpistemeOss" else "Aryan.Episteme") + .map(::normalizeDesktopMsixIdentityName) + .get() +val desktopMsixPublisher = providers.gradleProperty("desktopMsixPublisher") + .orElse("CN=$desktopVendorName") +val desktopMsixPublisherDisplayName = providers.gradleProperty("desktopMsixPublisherDisplayName") + .orElse(desktopVendor) +val desktopMsixVersion = providers.gradleProperty("desktopMsixVersion") + .orElse(desktopPackageVersion) + .map(::normalizeDesktopMsixVersion) + .get() +val desktopMsixArchitecture = desktopMsixArchitecture(desktopOsArch) +val desktopMakeAppxPath = providers.gradleProperty("desktopMakeAppxPath").orNull +val desktopSignToolPath = providers.gradleProperty("desktopSignToolPath").orNull +val desktopMsixCertificatePath = providers.gradleProperty("desktopMsixCertificatePath").orNull +val desktopMsixCertificatePassword = providers.gradleProperty("desktopMsixCertificatePassword") + .orElse("") +val desktopMsixTimestampUrl = providers.gradleProperty("desktopMsixTimestampUrl") + .orElse("http://timestamp.digicert.com") +val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") && + desktopArchId(desktopOsArch) == "x64" +val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) + .get() +val desktopSwtVersion = "3.133.0" +val desktopSwtDependency = desktopSwtArtifactId(desktopOsName, desktopOsArch) + ?.let { artifactId -> "org.eclipse.platform:$artifactId:$desktopSwtVersion" } +val generatedDesktopResourcesDir = layout.buildDirectory.dir("generated/desktopAppResources") +val generatedDesktopCloudConfigFile = layout.buildDirectory.file("generated/desktopCloudConfig/desktop-cloud.properties") +val generatedDesktopStringResourcesDir = layout.buildDirectory.dir("generated/desktopStringResources") +val rootLocalProperties = Properties() +val rootLocalPropertiesFile = rootProject.file("local.properties") +if (rootLocalPropertiesFile.exists()) { + rootLocalPropertiesFile.inputStream().use(rootLocalProperties::load) +} +fun desktopConfigValue(vararg keys: String): String { + return keys.firstNotNullOfOrNull { key -> + providers.gradleProperty(key).orNull + ?: rootLocalProperties.getProperty(key) + ?: System.getenv(key) + }?.trim().orEmpty() +} +val desktopCloudConfig = mapOf( + "AI_WORKER_URL" to desktopConfigValue("DESKTOP_AI_WORKER_URL", "AI_WORKER_URL"), + "TTS_WORKER_URL" to desktopConfigValue("DESKTOP_TTS_WORKER_URL", "TTS_WORKER_URL"), + "FIREBASE_WEB_API_KEY" to desktopConfigValue("DESKTOP_FIREBASE_WEB_API_KEY", "FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"), + "FIREBASE_PROJECT_ID" to desktopConfigValue("DESKTOP_FIREBASE_PROJECT_ID", "FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" }, + "GOOGLE_OAUTH_CLIENT_ID" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"), + "GOOGLE_OAUTH_CLIENT_SECRET" to desktopConfigValue("DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET") +) +val desktopAllowUnconfiguredStandardServices = providers.gradleProperty("desktopAllowUnconfiguredStandardServices") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) + .get() +val desktopMissingStandardServiceConfig = if (isOssOfflineDesktop || desktopAllowUnconfiguredStandardServices) { + emptyList() +} else { + listOf("FIREBASE_WEB_API_KEY", "GOOGLE_OAUTH_CLIENT_ID") + .filter { key -> desktopCloudConfig[key].isNullOrBlank() } +} +val bundledPdfiumDir = layout.projectDirectory.dir( + "../third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}" +) +val bundledPdfiumLibraryPath = desktopPdfiumLibraryPath(desktopOsName, desktopOsArch) +val desktopWindowsIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme.ico") +val desktopLinuxIconFile = layout.projectDirectory.file("src/desktopMain/resources/episteme_icon.png") +val desktopWindowsUpgradeUuid = if (isOssOfflineDesktop) { + "ca13b201-940a-420a-8a3f-16e7d83d12a8" +} else { + "c04c5823-b25a-4f38-a1cf-0da7b02ac397" +} +val desktopPackagingJavaHome = findDesktopPackagingJavaHome( + explicitCandidates = listOfNotNull( + providers.gradleProperty("desktopPackagingJavaHome").orNull, + providers.environmentVariable("DESKTOP_PACKAGING_JAVA_HOME").orNull, + providers.environmentVariable("JPACKAGE_HOME").orNull + ), + implicitCandidates = buildList { + addAll(desktopGradleJdkCandidates()) + providers.gradleProperty("org.gradle.java.home").orNull?.let { add(File(it)) } + providers.environmentVariable("GRADLE_LOCAL_JAVA_HOME").orNull?.let { add(File(it)) } + providers.environmentVariable("JAVA_HOME").orNull?.let { add(File(it)) } + providers.environmentVariable("JDK_HOME").orNull?.let { add(File(it)) } + add(File(System.getProperty("java.home"))) + addAll(desktopPathJdkCandidates(desktopOsName)) + addAll(desktopPlatformJdkCandidates(desktopOsName)) + }, + osName = desktopOsName +)?.absolutePath + +val checkBundledPdfiumRuntime by tasks.registering(CheckBundledPdfiumRuntimeTask::class) { + bundleRootPath.set(bundledPdfiumDir.asFile.absolutePath) + libraryPath.set(bundledPdfiumLibraryPath) +} + +val generateDesktopCloudConfig by tasks.registering(GenerateDesktopCloudConfigTask::class) { + configValues.set(desktopCloudConfig) + outputFile.set(generatedDesktopCloudConfigFile) +} + +val prepareBundledDesktopResources by tasks.registering(Sync::class) { + dependsOn(checkBundledPdfiumRuntime, generateDesktopCloudConfig) + from(bundledPdfiumDir) { + into("common/third_party/pdfium/${desktopPdfiumDirectoryName(desktopOsName, desktopOsArch)}") + } + into("common") { + from(generatedDesktopCloudConfigFile) + } + into(generatedDesktopResourcesDir) +} + +val prepareDesktopStringResources by tasks.registering(Sync::class) { + // Reuse Android string resources as the localization source for desktop. + from(rootProject.layout.projectDirectory.dir("app/src/main/res")) { + include("values*/strings.xml") + include("values*/plurals.xml") + into("desktop-android-res") + } + into(generatedDesktopStringResourcesDir) +} + +val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagingTask::class) { + supportedHost.set(desktopNativePackageSupportedHost) + hostOsId.set(desktopOsId(desktopOsName)) + hostArchId.set(desktopArchId(desktopOsArch)) + missingStandardServiceConfig.set(desktopMissingStandardServiceConfig) +} + +val desktopDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main/app/$desktopPackageName") +val desktopReleaseDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main-release/app/$desktopPackageName") +val desktopLinuxTarFileName = "${desktopLinuxPackageName}-${desktopPackageVersion.get()}-linux-$desktopPackageArchitecture.tar.gz" +val desktopAurOutputDir = layout.buildDirectory.dir("aur/${desktopAurPackageName.get()}") +val desktopMsixPackageDir = layout.buildDirectory.dir("msix/package") +val desktopMsixAssetsDir = layout.buildDirectory.dir("msix/generated/assets") +val desktopMsixManifestFile = layout.buildDirectory.file("msix/generated/AppxManifest.xml") +val desktopMsixOutputFile = layout.buildDirectory.file( + "compose/binaries/main-release/msix/${desktopLinuxPackageName}-${desktopPackageVersion.get()}-windows-$desktopPackageArchitecture.msix" +) + +val packageLinuxTar by tasks.registering(Tar::class) { + group = "distribution" + description = "Packages the Linux desktop distributable as a tar.gz for Arch/AUR packaging." + dependsOn("createDistributable") + + archiveFileName.set(desktopLinuxTarFileName) + destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main/linux-tar")) + compression = Compression.GZIP + + from(desktopDistributableAppDir) { + into(desktopLinuxPackageName) + } + from(desktopLinuxIconFile) { + into("$desktopLinuxPackageName/share") + } + from(rootProject.layout.projectDirectory.file("LICENSE")) { + into("$desktopLinuxPackageName/share/licenses") + } +} + +val prepareAurPackage by tasks.registering(PrepareDesktopAurPackageTask::class) { + group = "distribution" + description = "Generates a local AUR package directory with PKGBUILD and .SRCINFO." + dependsOn(packageLinuxTar) + + aurPackageName.set(desktopAurPackageName) + providedPackageName.set(desktopLinuxPackageName) + packageVersion.set(desktopPackageVersion) + packageRelease.set(desktopAurPackageRelease) + packageDescription.set(desktopPackageDescription) + appDisplayName.set(desktopPackageName) + installDirectoryName.set(desktopLinuxPackageName) + launcherName.set(desktopLinuxPackageName) + executableName.set(desktopPackageName) + sourceUrl.set(desktopAurSourceUrl) + projectUrl.set(desktopProjectUrl) + linuxTarFile.set(packageLinuxTar.flatMap { it.archiveFile }) + outputDirectory.set(desktopAurOutputDir) +} + +tasks.register("packageAur") { + group = "distribution" + description = "Builds the generated AUR package with makepkg. Run this on Arch Linux." + dependsOn(prepareAurPackage) + + commandLine("makepkg", "-sf", "--cleanbuild") + workingDir = desktopAurOutputDir.get().asFile +} + +val generateDesktopMsixManifest by tasks.registering(GenerateDesktopMsixManifestTask::class) { + identityName.set(desktopMsixIdentityName) + publisher.set(desktopMsixPublisher) + publisherDisplayName.set(desktopMsixPublisherDisplayName) + packageName.set(desktopPackageName) + packageDescription.set(desktopPackageDescription) + packageVersion.set(desktopMsixVersion) + architecture.set(desktopMsixArchitecture) + executablePath.set("$desktopPackageName.exe") + outputFile.set(desktopMsixManifestFile) +} + +val generateDesktopMsixAssets by tasks.registering(GenerateDesktopMsixAssetsTask::class) { + sourceIconFile.set(desktopLinuxIconFile) + outputDirectory.set(desktopMsixAssetsDir) +} + +val prepareReleaseMsixPackage by tasks.registering(Sync::class) { + group = "distribution" + description = "Stages the release Windows app image and MSIX metadata for makeappx." + dependsOn("createReleaseDistributable", generateDesktopMsixManifest, generateDesktopMsixAssets) + + from(desktopReleaseDistributableAppDir) + from(desktopMsixManifestFile) + from(desktopMsixAssetsDir) { + into("Assets") + } + into(desktopMsixPackageDir) +} + +val packageReleaseMsix by tasks.registering(PackageDesktopMsixTask::class) { + group = "distribution" + description = "Packages the release Windows app image as an MSIX using Windows SDK makeappx." + dependsOn(prepareReleaseMsixPackage) + + val makeAppx = findWindowsSdkTool("makeappx", desktopMakeAppxPath) + packageRootDirectory.set(desktopMsixPackageDir) + outputFile.set(desktopMsixOutputFile) + makeAppxPath.set(makeAppx.absolutePath) + hostOsId.set(desktopOsId(desktopOsName)) + hostArchId.set(desktopArchId(desktopOsArch)) +} + +val signReleaseMsix = desktopMsixCertificatePath?.trim()?.takeIf { it.isNotEmpty() }?.let { certificatePath -> + tasks.register("signReleaseMsix") { + group = "distribution" + description = "Signs the release MSIX with signtool when -PdesktopMsixCertificatePath is configured." + dependsOn(packageReleaseMsix) + + val signTool = findWindowsSdkTool("signtool", desktopSignToolPath) + val resolvedCertificateFile = File(certificatePath).let { file -> + if (file.isAbsolute) file else project.file(certificatePath) + } + unsignedMsixFile.set(desktopMsixOutputFile) + certificateFile.set(resolvedCertificateFile) + signToolPath.set(signTool.absolutePath) + certificatePassword.set(desktopMsixCertificatePassword) + timestampUrl.set(desktopMsixTimestampUrl) + } +} + +kotlin { + jvm("desktop") + jvmToolchain(21) + + sourceSets { + val desktopMain by getting { + resources.srcDir(prepareDesktopStringResources) + dependencies { + implementation(project(":shared")) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + desktopSwtDependency?.let { dependency -> + compileOnly(dependency) + runtimeOnly(dependency) + } + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("net.java.dev.jna:jna:5.17.0") + implementation("org.apache.commons:commons-compress:1.28.0") + implementation("org.tukaani:xz:1.10") + implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1") + } + } + val desktopTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +compose.desktop { + application { + mainClass = "org.dueattendant149.bookreader.desktop.LauncherKt" + desktopPackagingJavaHome?.let { javaHome = it } + + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") + jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor") + jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}") + jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}") + jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") + + buildTypes.release.proguard { + // ProGuard still rewrites and shrinks release jars even when optimization and + // obfuscation are disabled. That has produced invalid stack-map frames in large + // Compose/PDF lambdas and stripped WebView bridge behavior in packaged MSIs. + isEnabled.set(desktopReleaseProguardEnabled) + obfuscate.set(false) + // Compose/Kotlin generated methods can produce very large stack-map frames. + // ProGuard optimization has emitted invalid frames for SharedAppTheme in release builds. + optimize.set(false) + configurationFiles.from(project.file("compose-desktop.pro")) + } + + nativeDistributions { + targetFormats(*desktopPackageTargetFormats.toTypedArray()) + modules( + "java.datatransfer", + "java.desktop", + "java.logging", + "java.management", + "java.net.http", + "jdk.charsets", + "jdk.httpserver", + "jdk.unsupported" + ) + packageName = desktopPackageName + packageVersion = desktopPackageVersion.get() + description = desktopPackageDescription + vendor = desktopVendor.get() + appResourcesRootDir.set(generatedDesktopResourcesDir) + windows { + iconFile.set(desktopWindowsIconFile) + dirChooser = true + shortcut = true + menu = true + menuGroup = "Episteme" + perUserInstall = true + upgradeUuid = desktopWindowsUpgradeUuid + } + linux { + iconFile.set(desktopLinuxIconFile) + packageName = desktopLinuxPackageName + debMaintainer = "epistemereader@gmail.com" + menuGroup = "Office" + appCategory = "Office" + } + } + } +} + +tasks.withType().configureEach { + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") + jvmArgs("-Depisteme.desktop.flavor=$desktopFlavor") + jvmArgs("-Depisteme.desktop.diagnostics=${desktopDiagnostics.get()}") + jvmArgs("-Depisteme.desktop.diagnostics.tags=${desktopDiagnosticTags.get()}") + jvmArgs("-Depisteme.desktop.version=${desktopResolvedVersionName.get()}") + if (System.getProperty("os.name").contains("Mac")) { + jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") + } +} + +tasks.withType().configureEach { + manifest { + attributes( + "Implementation-Title" to desktopPackageName, + "Implementation-Version" to desktopResolvedVersionName.get(), + "Implementation-Vendor" to desktopVendor.get() + ) + } +} + +val stripReleaseProguardJarSignatures = if (desktopReleaseProguardEnabled) { + tasks.registering(StripInvalidJarSignaturesTask::class) { + dependsOn("proguardReleaseJars") + jarDirectoryPath.set(layout.buildDirectory.dir("compose/tmp/main-release/proguard").map { it.asFile.absolutePath }) + } +} else { + null +} + +tasks.matching { + it.name in setOf( + "createReleaseDistributable", + "packageReleaseDistributionForCurrentOS", + "packageReleaseExe", + "packageReleaseMsi", + "packageReleaseMsix", + "packageReleaseDeb", + "packageReleaseRpm", + "runReleaseDistributable" + ) +}.configureEach { + stripReleaseProguardJarSignatures?.let { dependsOn(it) } +} + +mapOf( + "packageMsi" to "main", + "packageReleaseMsi" to "main-release" +).forEach { (taskName, distributionName) -> + val renameTask = tasks.register("rename${taskName.replaceFirstChar(Char::titlecase)}Output") { + msiDirectoryPath.set(layout.buildDirectory.dir("compose/binaries/$distributionName/msi").get().asFile.absolutePath) + packageName.set(desktopPackageName) + packageVersion.set(desktopPackageVersion.get()) + architecture.set(desktopPackageArchitecture) + } + tasks.matching { it.name == taskName }.configureEach { + finalizedBy(renameTask) + } +} + +tasks.matching { + it.name in setOf( + "createDistributable", + "createReleaseDistributable", + "prepareAppResources", + "prepareReleaseAppResources", + "packageDistributionForCurrentOS", + "packageReleaseDistributionForCurrentOS", + "packageExe", + "packageReleaseExe", + "packageMsi", + "packageReleaseMsi", + "prepareReleaseMsixPackage", + "packageReleaseMsix", + "signReleaseMsix", + "packageDeb", + "packageReleaseDeb", + "packageRpm", + "packageReleaseRpm", + "packageLinuxTar", + "prepareAurPackage", + "packageAur", + "runDistributable", + "runReleaseDistributable" + ) +}.configureEach { + dependsOn(verifyDesktopNativePackaging) + dependsOn(prepareBundledDesktopResources) + inputs.dir(generatedDesktopResourcesDir) + .withPropertyName("bundledDesktopResources") + .withPathSensitivity(PathSensitivity.RELATIVE) +} diff --git a/desktopApp/compose-desktop.pro b/desktopApp/compose-desktop.pro new file mode 100644 index 0000000..d3805a8 --- /dev/null +++ b/desktopApp/compose-desktop.pro @@ -0,0 +1,26 @@ +-keep class io.ktor.serialization.kotlinx.** { *; } +-keep class io.ktor.serialization.kotlinx.json.** { *; } +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.Library { *; } +-keep class * extends com.sun.jna.Structure { *; } +-keep class kotlinx.coroutines.swing.SwingDispatcherFactory + +# Desktop release shrinking sees optional integrations from JOGL, Commons Compress +# Pack200, and OkHttp platform probes, so keep ProGuard from treating them as blockers. +-dontwarn com.jetbrains.JBR +-dontwarn com.jogamp.** +-dontwarn jogamp.** +-dontwarn org.apache.commons.compress.harmony.pack200.** +-dontwarn org.objectweb.asm.** +-dontwarn io.ktor.serialization.kotlinx.** +-dontwarn com.sun.jna.** +-dontwarn org.eclipse.swt.** +-dontwarn javafx.** +-dontwarn com.sun.javafx.** +-dontwarn okhttp3.internal.platform.** +-dontwarn org.bouncycastle.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** +-dontwarn android.** +-dontwarn com.github.luben.zstd.** +-dontwarn org.brotli.dec.** diff --git a/desktopApp/packaging/README.md b/desktopApp/packaging/README.md new file mode 100644 index 0000000..65cd664 --- /dev/null +++ b/desktopApp/packaging/README.md @@ -0,0 +1,229 @@ +# Desktop package builds + +Build Linux packages on the matching distro VM when testing manually: + +```bash +cd ~/Reader +./gradlew :desktopApp:packageDeb -x test +./gradlew :desktopApp:packageRpm -x test +./gradlew :desktopApp:packageAur -x test +``` + +Build a Windows MSIX locally on Windows with the Windows SDK installed: + +```powershell +cd C:\Users\aryan\Desktop\Reader +.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test +``` + +Copy the newest generated MSIX to your desktop: + +```powershell +$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\" +``` + +The MSIX task is separate from MSI packaging. It stages the release app image at +`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and +writes the MSIX to: + +```text +desktopApp/build/compose/binaries/main-release/msix +``` + +For Microsoft Store submission, set the package identity values from Partner +Center so `AppxManifest.xml` matches the reserved app identity: + +```powershell +.\gradlew.bat ` + -PdesktopOnly=true ` + -PdesktopMsixIdentityName= ` + -PdesktopMsixPublisher= ` + -PdesktopMsixPublisherDisplayName= ` + :desktopApp:packageReleaseMsix -x test +``` + +If Windows SDK tools are not on `PATH`, pass them explicitly: + +```powershell +.\gradlew.bat ` + -PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\\x64\makeappx.exe" ` + :desktopApp:packageReleaseMsix -x test +``` + +Local signing is optional and separate: + +```powershell +.\gradlew.bat ` + -PdesktopMsixCertificatePath=C:\path\to\certificate.pfx ` + -PdesktopMsixCertificatePassword= ` + :desktopApp:signReleaseMsix -x test +``` + +Recommended VM split: + +- Ubuntu: `./gradlew :desktopApp:packageDeb -x test` +- Fedora: `./gradlew :desktopApp:packageRpm -x test` +- Arch: `./gradlew :desktopApp:packageAur -x test` + +Desktop-only Gradle invocations automatically skip the Android app module and the +Android target in `:shared`, so desktop packaging does not require `sdk.dir`, +Android SDK installation, or Android release signing values. You can force that +mode for unusual command shapes with: + +```bash +./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test +``` + +Desktop release values are centralized in `gradle.properties`: + +```properties +desktopVersion=1.0.1 +desktopPackageVersion=1.0.1 +desktopAurPackageRelease=1 +``` + +The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`. +`packageAur` first creates a Linux app tarball, then generates an AUR worktree at: + +```text +desktopApp/build/aur/episteme-bin +``` + +On Arch, install/test the generated package with: + +```bash +sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst +episteme +``` + +For the OSS/offline flavor: + +```bash +./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test +sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst +episteme-oss +``` + +To inspect the AUR recipe manually instead: + +```bash +./gradlew :desktopApp:prepareAurPackage -x test +cd ~/Reader/desktopApp/build/aur/episteme-bin +makepkg -si +``` + +For publish-ready AUR metadata, pass the release tarball URL: + +```bash +./gradlew :desktopApp:prepareAurPackage \ + -PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \ + -x test +``` + +Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory. + +The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root +`LICENSE` file into `/usr/share/licenses/$pkgname/`. + +## AUR repository setup + +Create an account at: + +```text +https://aur.archlinux.org/register/ +``` + +Add your public SSH key in the account settings, then confirm SSH works: + +```bash +ssh aur@aur.archlinux.org +``` + +The command should authenticate and print AUR help text. It will not open a +normal shell. + +Create the package repos by cloning their not-yet-existing names: + +```bash +git clone ssh://aur@aur.archlinux.org/episteme-bin.git +git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git +``` + +If a name already exists, inspect it first. If it is abandoned, follow the AUR +orphan/adoption process instead of creating a duplicate package name. + +For each release, extract the matching `aur--.tar.gz` metadata +archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching +AUR clone, then commit and push: + +```bash +tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin +cd episteme-bin +git add PKGBUILD .SRCINFO +git commit -m "Update to 1.0.1" +git push +``` + +Repeat the same flow for `episteme-oss-bin`. + +## CI release workflow + +`Desktop release` in GitHub Actions builds desktop artifacts for standard and +OSS flavors: + +- Windows MSI +- Ubuntu/Debian DEB +- Fedora RPM +- Linux tarball used by AUR +- Direct Arch `.pkg.tar.zst` +- AUR metadata archives containing `PKGBUILD` and `.SRCINFO` +- `SHA256SUMS.txt` + +Before running it, publish Pdfium once from a machine that has the ignored +`third_party/pdfium` folders: + +```powershell +.\scripts\desktop\publish-pdfium-release.ps1 ` + -Repository Aryan-Raj3112/episteme ` + -Tag pdfium-desktop-v1 +``` + +That release must contain: + +```text +pdfium-linux-x64-v8.zip +pdfium-win-x64-v8.zip +``` + +The desktop release workflow downloads those assets with: + +```powershell +.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1 +``` + +Required GitHub Secrets for standard desktop packages: + +```text +DESKTOP_FIREBASE_PROJECT_ID +DESKTOP_FIREBASE_WEB_API_KEY +DESKTOP_GOOGLE_OAUTH_CLIENT_ID +DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET +``` + +`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped +for `:desktopApp:*` tasks. + +AUR publishing still needs the two AUR repos: + +```text +episteme-bin +episteme-oss-bin +``` + +Upload the generated `PKGBUILD` and `.SRCINFO` from: + +```text +aur-episteme-bin-.tar.gz +aur-episteme-oss-bin-.tar.gz +``` diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt new file mode 100644 index 0000000..03181c1 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAccountProfileRepository.kt @@ -0,0 +1,138 @@ +package org.dueattendant149.bookreader.desktop + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.util.Properties + +internal data class DesktopAccountProfile( + val isProUser: Boolean = false, + val credits: Int = 0, + val fetchedAtEpochMillis: Long = 0L +) + +// Credits and Pro status are server-owned, so startup only trusts a recent snapshot. +internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L + +internal fun DesktopAccountProfile.isFresh( + nowEpochMillis: Long = System.currentTimeMillis(), + ttlMillis: Long = DesktopAccountProfileCacheTtlMillis +): Boolean { + if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false + val ageMillis = nowEpochMillis - fetchedAtEpochMillis + return ageMillis in 0L..ttlMillis +} + +internal class DesktopAccountProfileRepository( + private val config: DesktopCloudConfig, + private val store: DesktopAccountProfileStore = DesktopAccountProfileStore() +) { + fun cachedProfile( + uid: String, + nowEpochMillis: Long = System.currentTimeMillis() + ): DesktopAccountProfile? { + return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) } + } + + fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) { + store.save(uid, profile) + } + + fun clearCachedProfiles() { + store.clear() + } + + suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) { + if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile() + val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}" + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Authorization", "Bearer $idToken") + setRequestProperty("Accept", "application/json") + connectTimeout = 12_000 + readTimeout = 20_000 + } + try { + if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis()) + } + val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + if (connection.responseCode !in 200..299) { + throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}") + } + val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull() + val profile = DesktopAccountProfile( + isProUser = fields?.booleanField("isPro") == true, + credits = fields?.numberField("credits")?.toInt() ?: 0, + fetchedAtEpochMillis = System.currentTimeMillis() + ) + profile + } finally { + connection.disconnect() + } + } +} + +internal class DesktopAccountProfileStore( + private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties") +) { + fun load(uid: String): DesktopAccountProfile? { + if (uid.isBlank() || !settingsFile.isFile) return null + val properties = Properties() + return runCatching { + settingsFile.inputStream().use(properties::load) + if (properties.getProperty("uid", "") != uid) return null + DesktopAccountProfile( + isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false, + credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0, + fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L + ) + }.getOrNull() + } + + fun save(uid: String, profile: DesktopAccountProfile) { + if (uid.isBlank()) return + val properties = Properties().apply { + setProperty("uid", uid) + setProperty("isProUser", profile.isProUser.toString()) + setProperty("credits", profile.credits.toString()) + setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString()) + } + settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile") + } + + fun clear() { + settingsFile.delete() + } +} + +private val DesktopAccountJson = Json { ignoreUnknownKeys = true } + +private fun JsonObject?.booleanField(key: String): Boolean? { + return this?.get(key) + ?.jsonObjectOrNull() + ?.get("booleanValue") + ?.jsonPrimitive + ?.contentOrNull + ?.toBooleanStrictOrNull() +} + +private fun JsonObject?.numberField(key: String): Double? { + val field = this?.get(key)?.jsonObjectOrNull() ?: return null + return field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() + ?: field["doubleValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() +} + +private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject + +private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt new file mode 100644 index 0000000..5a5dfdf --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStore.kt @@ -0,0 +1,1023 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import com.sun.jna.Library +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.WString +import com.sun.jna.ptr.PointerByReference +import com.sun.jna.win32.StdCallLibrary +import java.io.File +import java.util.Base64 +import java.util.Properties +import java.util.concurrent.TimeUnit + +private const val WINDOWS_CRED_TYPE_GENERIC = 1 +private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2 +private const val WINDOWS_ERROR_NOT_FOUND = 1168 +private const val LINUX_SECRET_SCHEMA_DONT_MATCH_NAME = 2 +private const val LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING = 0 +private const val LINUX_SECRET_SCHEMA_NAME = "org.dueattendant149.bookreader.Secret" +private const val LINUX_SECRET_COLLECTION_DEFAULT = "default" +private const val LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE = "application" +private const val LINUX_SECRET_SERVICE_APPLICATION_VALUE = "Episteme.Reader" +private const val LINUX_SECRET_SERVICE_KEY_ATTRIBUTE = "key" +private const val LINUX_LIBSECRET_PREFIX = "linux-libsecret:" +private const val LINUX_SECRET_TOOL_PREFIX = "secret-tool:" + +internal class DesktopAiByokStore( + private val settingsFile: File = defaultSettingsFile(), + private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform() +) { + val isSecureStorageAvailable: Boolean + get() = secretCodec.isAvailable.also { available -> + logDesktopTts("settings_secure_available codec=${secretCodec.name} available=$available") + } + + fun load(): ReaderAiByokSettings { + val settingsFileExists = settingsFile.exists() + logDesktopTts( + "settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " + + "exists=$settingsFileExists secureStorage=${if (settingsFileExists) "checking" else "skipped"}" + ) + if (!settingsFileExists) { + logDesktopTts("settings_load_empty reason=file_missing") + return ReaderAiByokSettings() + } + val secureStorageAvailable = secretCodec.isAvailable + logDesktopTts("settings_load_secure_storage codec=${secretCodec.name} available=$secureStorageAvailable") + val properties = Properties() + return runCatching { + settingsFile.inputStream().use(properties::load) + val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "") + val legacyGroqKey = properties.getProperty(LegacyGroqKey, "") + val loadedSettings = ReaderAiByokSettings( + geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey, secureStorageAvailable), + groqKey = loadSecret(properties, GroqKey, legacyGroqKey, secureStorageAvailable), + useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true, + modelForAll = properties.getProperty("modelForAll", ""), + defineModel = properties.getProperty("defineModel", ""), + summarizeModel = properties.getProperty("summarizeModel", ""), + recapModel = properties.getProperty("recapModel", ""), + ttsModel = properties.getProperty("ttsModel", ""), + hideReaderAiFeatures = properties.getProperty("hideReaderAiFeatures", "false").toBooleanStrictOrNull() ?: false, + ttsSpeakerId = properties.getProperty("ttsSpeakerId", DEFAULT_CLOUD_TTS_SPEAKER_ID) + ).sanitized() + val settings = if (loadedSettings.geminiKey.isNotBlank() && loadedSettings.ttsModel.isBlank()) { + loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID) + } else { + loadedSettings + }.toDesktopPersistableAiSettings() + if (secureStorageAvailable && + (legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings) + ) { + logDesktopTts( + "settings_load_migrate legacyGemini=${legacyGeminiKey.isNotBlank()} " + + "legacyGroq=${legacyGroqKey.isNotBlank()} autoTtsModel=${settings != loadedSettings}" + ) + runCatching { save(settings) } + } + logDesktopTts( + "settings_load_complete geminiKey=${settings.geminiKey.isNotBlank()} groqKey=${settings.groqKey.isNotBlank()} " + + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" cloudAvailable=${settings.isCloudTtsAvailable}" + ) + settings + }.getOrElse { error -> + logDesktopTts("settings_load_failed error=\"${error.desktopTtsSummary()}\"") + ReaderAiByokSettings() + } + } + + fun save(settings: ReaderAiByokSettings) { + val sanitized = settings.toDesktopPersistableAiSettings() + logDesktopTts( + "settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " + + "secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " + + "groqKey=${sanitized.groqKey.isNotBlank()} ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\"" + ) + val properties = Properties().apply { + setProtectedSecret(GeminiKey, sanitized.geminiKey) + setProtectedSecret(GroqKey, sanitized.groqKey) + setProperty("useOneModel", sanitized.useOneModel.toString()) + setProperty("modelForAll", sanitized.modelForAll) + setProperty("defineModel", sanitized.defineModel) + setProperty("summarizeModel", sanitized.summarizeModel) + setProperty("recapModel", sanitized.recapModel) + setProperty("ttsModel", sanitized.ttsModel) + setProperty("hideReaderAiFeatures", sanitized.hideReaderAiFeatures.toString()) + setProperty("ttsSpeakerId", sanitized.ttsSpeakerId) + } + settingsFile.parentFile?.mkdirs() + settingsFile.storePropertiesAtomically(properties, "Episteme desktop AI keys and models") + logDesktopTts( + "settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " + + "groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}" + ) + } + + private fun loadSecret( + properties: Properties, + key: String, + legacyPlaintext: String, + secureStorageAvailable: Boolean + ): String { + val protectedValue = properties.getProperty(key, "") + val decrypted = protectedValue + .takeIf { it.isNotBlank() } + ?.let { + runCatching { secretCodec.unprotect(key, it) } + .onFailure { error -> logDesktopTts("settings_secret_unprotect_failed key=$key error=\"${error.desktopTtsSummary()}\"") } + .getOrDefault("") + } + .orEmpty() + if (decrypted.isNotBlank()) return decrypted + return legacyPlaintext.takeIf { secureStorageAvailable }.orEmpty() + } + + private fun Properties.setProtectedSecret(key: String, value: String) { + val trimmed = value.trim() + if (trimmed.isBlank()) { + secretCodec.delete(key) + return + } + runCatching { secretCodec.protect(key, trimmed) } + .onSuccess { protectedValue -> + if (protectedValue.isBlank()) { + logDesktopTts("settings_secret_protect_empty key=$key codec=${secretCodec.name}") + } else { + setProperty(key, protectedValue) + logDesktopTts("settings_secret_protect_success key=$key codec=${secretCodec.name} prefix=\"${protectedValue.substringBefore(':', protectedValue)}\"") + } + } + .onFailure { error -> + logDesktopTts("settings_secret_protect_failed key=$key codec=${secretCodec.name} error=\"${error.desktopTtsSummary()}\"") + } + } + + companion object { + private const val GeminiKey = "geminiKeyProtected" + private const val GroqKey = "groqKeyProtected" + private const val LegacyGeminiKey = "geminiKey" + private const val LegacyGroqKey = "groqKey" + + fun defaultSettingsFile(): File { + return File(desktopUserConfigRoot(), "ai-byok.properties") + } + } +} + +internal interface DesktopSecretCodec { + val name: String get() = this::class.java.simpleName.ifBlank { "DesktopSecretCodec" } + val isAvailable: Boolean + fun protect(value: String): String + fun unprotect(value: String): String + fun protect(keyName: String, value: String): String = protect(value) + fun unprotect(keyName: String, value: String): String = unprotect(value) + fun delete(keyName: String) = Unit + + companion object { + fun platform(): DesktopSecretCodec { + val osName = System.getProperty("os.name").orEmpty() + val codec = when { + osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec + osName.contains("Linux", ignoreCase = true) -> LinuxSecretServiceCodec() + else -> UnavailableDesktopSecretCodec + } + logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}") + return codec + } + } +} + +private object UnavailableDesktopSecretCodec : DesktopSecretCodec { + override val name: String = "unavailable" + override val isAvailable: Boolean = false + override fun protect(value: String): String { + throw IllegalStateException("Secure key storage is unavailable on this operating system.") + } + override fun unprotect(value: String): String = "" +} + +internal data class DesktopSecretCommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String +) { + val isSuccess: Boolean get() = exitCode == 0 + val errorSummary: String + get() = stderr.ifBlank { stdout }.desktopTtsPreview(240).ifBlank { "exit code $exitCode" } +} + +internal interface DesktopSecretCommandRunner { + fun isExecutableAvailable(command: String): Boolean + fun run(command: List, input: String? = null, timeoutMillis: Long = 5_000L): DesktopSecretCommandResult +} + +private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner { + override fun isExecutableAvailable(command: String): Boolean { + val path = System.getenv("PATH").orEmpty() + return path.split(File.pathSeparator) + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .any { directory -> + File(directory, command).let { it.isFile && it.canExecute() } + } + } + + override fun run(command: List, input: String?, timeoutMillis: Long): DesktopSecretCommandResult { + require(command.isNotEmpty()) { "Secret command cannot be empty." } + val process = ProcessBuilder(command).start() + input?.let { value -> + process.outputStream.use { output -> + output.write(value.toByteArray(Charsets.UTF_8)) + } + } ?: process.outputStream.close() + + val completed = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS) + if (!completed) { + process.destroyForcibly() + throw IllegalStateException("Timed out waiting for ${command.first()} secure storage command.") + } + return DesktopSecretCommandResult( + exitCode = process.exitValue(), + stdout = process.inputStream.readBytes().toString(Charsets.UTF_8), + stderr = process.errorStream.readBytes().toString(Charsets.UTF_8) + ) + } +} + +internal class LinuxSecretServiceCodec( + private val libsecretCodec: DesktopSecretCodec = LinuxLibsecretCodec(), + private val secretToolCodec: DesktopSecretCodec = LinuxSecretToolCodec() +) : DesktopSecretCodec { + private val codecs: List = listOf(libsecretCodec, secretToolCodec) + + private val selectedCodec: DesktopSecretCodec? by lazy { + codecs.firstOrNull { codec -> + runCatching { codec.isAvailable } + .onFailure { error -> + logDesktopTts("settings_linux_secret_service_probe_failed codec=${codec.name} error=\"${error.desktopTtsSummary()}\"") + } + .getOrDefault(false) + } + } + + override val name: String = "linux-secret-service" + + override val isAvailable: Boolean + get() = selectedCodec != null + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + val codec = selectedCodec ?: throw IllegalStateException( + "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider." + ) + return codec.protect(keyName, value) + } + + override fun unprotect(keyName: String, value: String): String { + val orderedCodecs = when { + value.startsWith(LINUX_LIBSECRET_PREFIX) -> listOf(libsecretCodec, secretToolCodec) + value.startsWith(LINUX_SECRET_TOOL_PREFIX) -> listOf(libsecretCodec, secretToolCodec) + else -> codecs + } + for (codec in orderedCodecs) { + val secret = runCatching { + if (!codec.isAvailable) "" else codec.unprotect(keyName, value) + }.onFailure { error -> + logDesktopTts( + "settings_linux_secret_service_read_failed codec=${codec.name} key=$keyName " + + "error=\"${error.desktopTtsSummary()}\"" + ) + }.getOrDefault("") + if (secret.isNotBlank()) return secret + } + return "" + } + + override fun delete(keyName: String) { + codecs.forEach { codec -> + runCatching { codec.delete(keyName) } + .onFailure { error -> + logDesktopTts( + "settings_linux_secret_service_delete_failed codec=${codec.name} key=$keyName " + + "error=\"${error.desktopTtsSummary()}\"" + ) + } + } + } +} + +internal interface LinuxSecretServiceClient { + val isAvailable: Boolean + fun store(key: String, label: String, password: String) + fun lookup(key: String): String? + fun clear(key: String) +} + +internal class LinuxLibsecretCodec( + private val client: LinuxSecretServiceClient = JnaLinuxSecretServiceClient +) : DesktopSecretCodec { + override val name: String = "linux-libsecret" + + override val isAvailable: Boolean by lazy { + val available = if (!client.isAvailable) { + false + } else { + val probeKey = linuxSecretKey("probe") + val probeSecret = "episteme-linux-libsecret-probe" + runCatching { + client.store(probeKey, "Episteme secure storage probe", probeSecret) + client.lookup(probeKey) == probeSecret + }.onFailure { error -> + logDesktopTts("settings_linux_libsecret_unavailable error=\"${error.desktopTtsSummary()}\"") + }.also { + runCatching { client.clear(probeKey) } + }.getOrDefault(false) + } + logDesktopTts("settings_linux_libsecret_available available=$available") + available + } + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + if (!isAvailable) { + throw IllegalStateException( + "Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider." + ) + } + val key = linuxSecretKey(keyName) + logDesktopTts("settings_linux_libsecret_write_start key=$keyName valueChars=${value.length}") + client.store(key, "Episteme $keyName", value) + logDesktopTts("settings_linux_libsecret_write_result key=$keyName") + return LINUX_LIBSECRET_PREFIX + key + } + + override fun unprotect(keyName: String, value: String): String { + if (!isAvailable) return "" + val key = linuxSecretReferenceKey(keyName, value) + logDesktopTts("settings_linux_libsecret_read_start key=$keyName") + val secret = client.lookup(key).orEmpty() + logDesktopTts("settings_linux_libsecret_read_result key=$keyName chars=${secret.length}") + return secret + } + + override fun delete(keyName: String) { + if (!client.isAvailable) return + val key = linuxSecretKey(keyName) + runCatching { client.clear(key) } + .onFailure { error -> + logDesktopTts("settings_linux_libsecret_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") + } + } +} + +private object JnaLinuxSecretServiceClient : LinuxSecretServiceClient { + override val isAvailable: Boolean by lazy { + runCatching { + LinuxLibsecretNative.INSTANCE + LinuxGlibNative.INSTANCE + true + }.onFailure { error -> + logDesktopTts("settings_linux_libsecret_load_failed error=\"${error.desktopTtsSummary()}\"") + }.getOrDefault(false) + } + + private val schema: Pointer by lazy { + LinuxLibsecretNative.INSTANCE.secret_schema_new( + LINUX_SECRET_SCHEMA_NAME, + LINUX_SECRET_SCHEMA_DONT_MATCH_NAME, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING, + null + ) ?: throw IllegalStateException("Linux Secret Service schema creation failed.") + } + + override fun store(key: String, label: String, password: String) { + val error = PointerByReference() + val stored = LinuxLibsecretNative.INSTANCE.secret_password_store_sync( + schema, + LINUX_SECRET_COLLECTION_DEFAULT, + label, + password, + null, + error, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key, + null + ) + takeLibsecretError(error)?.let { message -> + throw IllegalStateException("Linux Secret Service write failed: $message") + } + if (!stored) { + throw IllegalStateException("Linux Secret Service write failed: libsecret returned false.") + } + } + + override fun lookup(key: String): String? { + val error = PointerByReference() + val passwordPointer = LinuxLibsecretNative.INSTANCE.secret_password_lookup_sync( + schema, + null, + error, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key, + null + ) + val errorMessage = takeLibsecretError(error) + if (errorMessage != null) { + passwordPointer?.let { pointer -> LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) } + throw IllegalStateException("Linux Secret Service read failed: $errorMessage") + } + return passwordPointer?.let { pointer -> + try { + pointer.getString(0, Charsets.UTF_8.name()) + } finally { + LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) + } + } + } + + override fun clear(key: String) { + val error = PointerByReference() + LinuxLibsecretNative.INSTANCE.secret_password_clear_sync( + schema, + null, + error, + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key, + null + ) + takeLibsecretError(error)?.let { message -> + throw IllegalStateException("Linux Secret Service delete failed: $message") + } + } + + private fun takeLibsecretError(error: PointerByReference): String? { + val errorPointer = error.value ?: return null + return try { + LinuxGError(errorPointer).message + ?.getString(0, Charsets.UTF_8.name()) + ?.ifBlank { null } + ?: "unknown libsecret error" + } finally { + LinuxGlibNative.INSTANCE.g_error_free(errorPointer) + } + } +} + +private interface LinuxLibsecretNative : Library { + fun secret_schema_new(name: String, flags: Int, vararg attributes: Any?): Pointer? + + fun secret_password_store_sync( + schema: Pointer, + collection: String, + label: String, + password: String, + cancellable: Pointer?, + error: PointerByReference, + vararg attributes: Any? + ): Boolean + + fun secret_password_lookup_sync( + schema: Pointer, + cancellable: Pointer?, + error: PointerByReference, + vararg attributes: Any? + ): Pointer? + + fun secret_password_clear_sync( + schema: Pointer, + cancellable: Pointer?, + error: PointerByReference, + vararg attributes: Any? + ): Boolean + + fun secret_password_free(password: Pointer?) + + companion object { + val INSTANCE: LinuxLibsecretNative by lazy { + loadLinuxNativeLibrary( + LinuxLibsecretNative::class.java, + "secret-1", + "libsecret-1.so.0" + ) + } + } +} + +private interface LinuxGlibNative : Library { + fun g_error_free(error: Pointer?) + + companion object { + val INSTANCE: LinuxGlibNative by lazy { + loadLinuxNativeLibrary( + LinuxGlibNative::class.java, + "glib-2.0", + "libglib-2.0.so.0" + ) + } + } +} + +@Structure.FieldOrder("domain", "code", "message") +internal class LinuxGError(pointer: Pointer) : Structure(pointer) { + @JvmField + var domain: Int = 0 + + @JvmField + var code: Int = 0 + + @JvmField + var message: Pointer? = null + + init { + read() + } +} + +private fun loadLinuxNativeLibrary(type: Class, vararg names: String): T { + var lastError: Throwable? = null + for (name in names) { + val loaded = runCatching { Native.load(name, type) as T } + .onFailure { error -> lastError = error } + .getOrNull() + if (loaded != null) return loaded + } + throw IllegalStateException("Could not load Linux native library ${names.joinToString(" or ")}.", lastError) +} + +internal class LinuxSecretToolCodec( + private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner +) : DesktopSecretCodec { + override val name: String = "linux-secret-tool" + + override val isAvailable: Boolean by lazy { + val available = commandRunner.isExecutableAvailable(SecretToolCommand) && + runCatching { + commandRunner.run(listOf(SecretToolCommand, "--help"), timeoutMillis = 3_000L).isSuccess + }.getOrDefault(false) + logDesktopTts("settings_linux_secret_tool_available available=$available") + available + } + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + if (!isAvailable) { + throw IllegalStateException( + "Linux Secret Service is unavailable. Install libsecret-tools and make sure a desktop keyring is running." + ) + } + val key = linuxSecretKey(keyName) + logDesktopTts("settings_linux_secret_tool_write_start key=$keyName valueChars=${value.length}") + val result = commandRunner.run( + command = listOf( + SecretToolCommand, + "store", + "--label", + "Episteme $keyName", + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key + ), + input = value, + timeoutMillis = 15_000L + ) + logDesktopTts("settings_linux_secret_tool_write_result key=$keyName exit=${result.exitCode}") + if (!result.isSuccess) { + throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}") + } + return LINUX_SECRET_TOOL_PREFIX + key + } + + override fun unprotect(keyName: String, value: String): String { + if (!isAvailable) return "" + val key = linuxSecretReferenceKey(keyName, value) + logDesktopTts("settings_linux_secret_tool_read_start key=$keyName") + val result = commandRunner.run( + command = listOf( + SecretToolCommand, + "lookup", + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key + ), + timeoutMillis = 8_000L + ) + logDesktopTts("settings_linux_secret_tool_read_result key=$keyName exit=${result.exitCode} chars=${result.stdout.length}") + if (!result.isSuccess) { + throw IllegalStateException("Linux Secret Service read failed: ${result.errorSummary}") + } + return result.stdout.trimEnd('\r', '\n') + } + + override fun delete(keyName: String) { + val key = linuxSecretKey(keyName) + runCatching { + commandRunner.run( + command = listOf( + SecretToolCommand, + "clear", + LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE, + LINUX_SECRET_SERVICE_APPLICATION_VALUE, + LINUX_SECRET_SERVICE_KEY_ATTRIBUTE, + key + ), + timeoutMillis = 8_000L + ) + }.onFailure { error -> + logDesktopTts("settings_linux_secret_tool_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") + } + } + + private companion object { + const val SecretToolCommand = "secret-tool" + } +} + +private fun linuxSecretKey(keyName: String): String { + return "Episteme.Reader.$keyName" +} + +private fun linuxSecretReferenceKey(keyName: String, reference: String): String { + return when { + reference.startsWith(LINUX_LIBSECRET_PREFIX) -> reference.removePrefix(LINUX_LIBSECRET_PREFIX) + reference.startsWith(LINUX_SECRET_TOOL_PREFIX) -> reference.removePrefix(LINUX_SECRET_TOOL_PREFIX) + else -> "" + }.ifBlank { linuxSecretKey(keyName) } +} + +private object WindowsSecretCodec : DesktopSecretCodec { + override val name: String = "windows" + + override val isAvailable: Boolean + get() { + val wincred = WindowsCredentialSecretCodec.isAvailable + val dpapi = WindowsDpapiSecretCodec.isAvailable + logDesktopTts("settings_windows_available wincred=$wincred dpapi=$dpapi") + return wincred || dpapi + } + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + val wincredFailure = runCatching { return WindowsCredentialSecretCodec.protect(keyName, value) } + .exceptionOrNull() + ?.also { error -> logDesktopTts("settings_wincred_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") } + val dpapiFailure = runCatching { return WindowsDpapiSecretCodec.protect(value) } + .exceptionOrNull() + ?.also { error -> logDesktopTts("settings_dpapi_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") } + throw IllegalStateException( + "No Windows secure key store write succeeded. " + + "Credential Manager: ${wincredFailure?.desktopTtsSummary() ?: "not attempted"}; " + + "DPAPI: ${dpapiFailure?.desktopTtsSummary() ?: "not attempted"}" + ) + } + + override fun unprotect(keyName: String, value: String): String { + return when { + value.startsWith(WindowsCredentialSecretCodec.Prefix) -> WindowsCredentialSecretCodec.unprotect(keyName, value) + value.startsWith(WindowsDpapiSecretCodec.Prefix) -> WindowsDpapiSecretCodec.unprotect(value) + else -> "" + } + } + + override fun delete(keyName: String) { + if (WindowsCredentialSecretCodec.isAvailable) WindowsCredentialSecretCodec.delete(keyName) + } +} + +private object WindowsCredentialSecretCodec : DesktopSecretCodec { + override val name: String = "wincred" + + const val Prefix = "wincred:" + + override val isAvailable: Boolean by lazy { + val probeKey = "probe" + val probe = "episteme-wincred-probe" + logDesktopTts("settings_wincred_probe_start") + runCatching { + val reference = protect(probeKey, probe) + val restored = unprotect(probeKey, reference) + val matches = restored == probe + logDesktopTts( + "settings_wincred_probe_result matches=$matches referencePrefix=\"${reference.substringBefore(':', reference)}\" " + + "restoredChars=${restored.length}" + ) + matches + }.onFailure { error -> + logDesktopTts("settings_wincred_unavailable error=\"${error.desktopTtsSummary()}\"") + }.also { + runCatching { delete(probeKey) } + }.getOrDefault(false) + } + + override fun protect(value: String): String { + return protect("secret", value) + } + + override fun unprotect(value: String): String { + return unprotect("secret", value) + } + + override fun protect(keyName: String, value: String): String { + val target = credentialTarget(keyName) + logDesktopTts("settings_wincred_write_start key=$keyName target=\"$target\" valueChars=${value.length}") + val credential = WindowsCredential(target, value) + credential.write() + val ok = Advapi32.INSTANCE.CredWriteW(credential, 0) + val errorCode = Native.getLastError() + logDesktopTts("settings_wincred_write_result key=$keyName ok=$ok error=$errorCode") + if (!ok) throw IllegalStateException("Windows Credential Manager write failed: $errorCode") + return Prefix + target + } + + override fun unprotect(keyName: String, value: String): String { + if (!value.startsWith(Prefix)) return "" + val target = value.removePrefix(Prefix).ifBlank { credentialTarget(keyName) } + logDesktopTts("settings_wincred_read_start key=$keyName target=\"$target\"") + val credentialPointer = PointerByReference() + val ok = Advapi32.INSTANCE.CredReadW(WString(target), WINDOWS_CRED_TYPE_GENERIC, 0, credentialPointer) + val errorCode = Native.getLastError() + logDesktopTts("settings_wincred_read_result key=$keyName ok=$ok error=$errorCode hasPointer=${credentialPointer.value != null}") + if (!ok) throw IllegalStateException("Windows Credential Manager read failed: $errorCode") + val pointer = credentialPointer.value ?: return "" + return try { + val credential = WindowsCredential(pointer) + val blobPointer = credential.CredentialBlob ?: return "" + logDesktopTts("settings_wincred_read_blob key=$keyName bytes=${credential.CredentialBlobSize}") + String(blobPointer.getByteArray(0, credential.CredentialBlobSize), Charsets.UTF_8) + } finally { + Advapi32.INSTANCE.CredFree(pointer) + } + } + + override fun delete(keyName: String) { + val ok = Advapi32.INSTANCE.CredDeleteW(WString(credentialTarget(keyName)), WINDOWS_CRED_TYPE_GENERIC, 0) + val errorCode = Native.getLastError() + logDesktopTts("settings_wincred_delete_result key=$keyName ok=$ok error=$errorCode") + if (!ok && errorCode != WINDOWS_ERROR_NOT_FOUND) { + logDesktopTts("settings_wincred_delete_failed key=$keyName error=$errorCode") + } + } + + private fun credentialTarget(keyName: String): String { + return "Episteme.Reader.AI.$keyName" + } + + private interface Advapi32 : StdCallLibrary { + fun CredWriteW(credential: WindowsCredential, flags: Int): Boolean + fun CredReadW(targetName: WString, type: Int, flags: Int, credential: PointerByReference): Boolean + fun CredDeleteW(targetName: WString, type: Int, flags: Int): Boolean + fun CredFree(buffer: Pointer?) + + companion object { + val INSTANCE: Advapi32 by lazy { + Native.load("Advapi32", Advapi32::class.java) as Advapi32 + } + } + } +} + +private object WindowsDpapiSecretCodec : DesktopSecretCodec { + override val name: String = "dpapi" + + const val Prefix = "dpapi:" + + override val isAvailable: Boolean by lazy { + logDesktopTts("settings_dpapi_probe_start") + runCatching { + Crypt32.INSTANCE + Kernel32.INSTANCE + val probe = "episteme-dpapi-probe" + val encrypted = protect(probe) + val restored = unprotect(encrypted) + val matches = restored == probe + logDesktopTts("settings_dpapi_probe_result matches=$matches encryptedChars=${encrypted.length} restoredChars=${restored.length}") + matches + }.onFailure { error -> + logDesktopTts("settings_dpapi_unavailable error=\"${error.desktopTtsSummary()}\"") + }.getOrDefault(false) + } + + override fun protect(value: String): String { + val input = DataBlob(value.toByteArray(Charsets.UTF_8)) + val output = DataBlob() + logDesktopTts("settings_dpapi_protect_start bytes=${value.toByteArray(Charsets.UTF_8).size}") + val ok = Crypt32.INSTANCE.CryptProtectData(input, null, null, null, null, 0, output) + val errorCode = Native.getLastError() + logDesktopTts("settings_dpapi_protect_result ok=$ok error=$errorCode") + if (!ok) throw IllegalStateException("Windows DPAPI protect failed: $errorCode") + return try { + Prefix + Base64.getEncoder().encodeToString(output.toByteArray()) + } finally { + output.free() + } + } + + override fun unprotect(value: String): String { + if (!value.startsWith(Prefix)) return "" + val encrypted = Base64.getDecoder().decode(value.removePrefix(Prefix)) + val input = DataBlob(encrypted) + val output = DataBlob() + logDesktopTts("settings_dpapi_unprotect_start bytes=${encrypted.size}") + val ok = Crypt32.INSTANCE.CryptUnprotectData(input, null, null, null, null, 0, output) + val errorCode = Native.getLastError() + logDesktopTts("settings_dpapi_unprotect_result ok=$ok error=$errorCode") + if (!ok) throw IllegalStateException("Windows DPAPI unprotect failed: $errorCode") + return try { + String(output.toByteArray(), Charsets.UTF_8) + } finally { + output.free() + } + } +} + +@Structure.FieldOrder("dwLowDateTime", "dwHighDateTime") +internal class WindowsFileTime : Structure() { + @JvmField + var dwLowDateTime: Int = 0 + + @JvmField + var dwHighDateTime: Int = 0 +} + +@Structure.FieldOrder( + "Flags", + "Type", + "TargetName", + "Comment", + "LastWritten", + "CredentialBlobSize", + "CredentialBlob", + "Persist", + "AttributeCount", + "Attributes", + "TargetAlias", + "UserName" +) +internal open class WindowsCredential : Structure { + @JvmField + var Flags: Int = 0 + + @JvmField + var Type: Int = WINDOWS_CRED_TYPE_GENERIC + + @JvmField + var TargetName: WString? = null + + @JvmField + var Comment: WString? = null + + @JvmField + var LastWritten: WindowsFileTime = WindowsFileTime() + + @JvmField + var CredentialBlobSize: Int = 0 + + @JvmField + var CredentialBlob: Pointer? = null + + @JvmField + var Persist: Int = WINDOWS_CRED_PERSIST_LOCAL_MACHINE + + @JvmField + var AttributeCount: Int = 0 + + @JvmField + var Attributes: Pointer? = null + + @JvmField + var TargetAlias: WString? = null + + @JvmField + var UserName: WString? = WString("Episteme") + + private var blobMemory: Memory? = null + + constructor() : super() + + constructor(pointer: Pointer) : super(pointer) { + read() + } + + constructor(target: String, secret: String) : super() { + val bytes = secret.toByteArray(Charsets.UTF_8) + TargetName = WString(target) + CredentialBlobSize = bytes.size + blobMemory = Memory(bytes.size.toLong()).also { memory -> + memory.write(0, bytes, 0, bytes.size) + CredentialBlob = memory + } + } +} + +@Structure.FieldOrder("cbData", "pbData") +internal open class DataBlob() : Structure() { + @JvmField + var cbData: Int = 0 + + @JvmField + var pbData: Pointer? = null + + private var memory: Memory? = null + + constructor(bytes: ByteArray) : this() { + cbData = bytes.size + memory = Memory(bytes.size.toLong()).also { allocated -> + allocated.write(0, bytes, 0, bytes.size) + pbData = allocated + } + } + + fun toByteArray(): ByteArray { + read() + return pbData?.getByteArray(0, cbData) ?: ByteArray(0) + } + + fun free() { + pbData?.let { Kernel32.INSTANCE.LocalFree(it) } + pbData = null + cbData = 0 + } +} + +private interface Crypt32 : StdCallLibrary { + fun CryptProtectData( + pDataIn: DataBlob, + szDataDescr: String?, + pOptionalEntropy: DataBlob?, + pvReserved: Pointer?, + pPromptStruct: Pointer?, + dwFlags: Int, + pDataOut: DataBlob + ): Boolean + + fun CryptUnprotectData( + pDataIn: DataBlob, + ppszDataDescr: Pointer?, + pOptionalEntropy: DataBlob?, + pvReserved: Pointer?, + pPromptStruct: Pointer?, + dwFlags: Int, + pDataOut: DataBlob + ): Boolean + + companion object { + val INSTANCE: Crypt32 by lazy { + Native.load("Crypt32", Crypt32::class.java) as Crypt32 + } + } +} + +private interface Kernel32 : StdCallLibrary { + fun LocalFree(hMem: Pointer?): Pointer? + + companion object { + val INSTANCE: Kernel32 by lazy { + Native.load("Kernel32", Kernel32::class.java) as Kernel32 + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt new file mode 100644 index 0000000..391979b --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiHub.kt @@ -0,0 +1,338 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText +import org.dueattendant149.bookreader.shared.ui.readerString + +@Composable +internal fun DesktopAiHubSheet( + bookKey: String, + bookTitle: String, + itemIndex: Int, + itemTitle: String, + summaryCacheStore: DesktopSummaryCacheStore, + summaryResult: SummarizationResult?, + isSummaryLoading: Boolean, + recapResult: RecapResult?, + isRecapLoading: Boolean, + recapProgressMessage: String?, + onGenerateSummary: (force: Boolean) -> Unit, + onClearSummary: () -> Unit, + onGenerateRecap: (() -> Unit)?, + onClearRecap: () -> Unit, + onDismiss: () -> Unit, + credits: Int, + showCredits: Boolean +) { + var selectedTab by remember { mutableIntStateOf(0) } + var cacheRefresh by remember { mutableIntStateOf(0) } + val cachedSummary = remember(bookKey, itemIndex, cacheRefresh) { + summaryCacheStore.getSummary(bookKey, itemIndex) + } + val effectiveSummary = summaryResult ?: cachedSummary?.let { + SummarizationResult(summary = it, isCacheHit = true) + } + val summaryTab = readerString("label_summary", "Summary") + val recapTab = readerString("ai_tab_recap", "Recap") + val cacheTab = readerString("ai_tab_cache", "Cache") + val tabs = buildList { + add(summaryTab) + if (onGenerateRecap != null) add(recapTab) + add(cacheTab) + } + val selectedTabIndex = selectedTab.coerceIn(0, tabs.lastIndex) + val activeTab = tabs.getOrElse(selectedTabIndex) { summaryTab } + + DesktopReaderBottomSheet( + title = readerString("desktop_ai_hub", "AI hub"), + onDismiss = onDismiss + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(bookTitle, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(itemTitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (showCredits) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(10.dp) + ) { + Text( + readerString("credits_count", "%1\$d credits", credits), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + + ScrollableTabRow(selectedTabIndex = selectedTabIndex, edgePadding = 0.dp) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTabIndex == index, + onClick = { selectedTab = index }, + text = { Text(title) } + ) + } + } + + when (activeTab) { + summaryTab -> { + DesktopAiHubResultView( + title = itemTitle, + resultText = effectiveSummary?.summary, + errorText = effectiveSummary?.error, + cost = effectiveSummary?.cost, + freeRemaining = effectiveSummary?.freeRemaining, + isCacheHit = effectiveSummary?.isCacheHit == true, + isLoading = isSummaryLoading, + loadingLabel = readerString("generating_summary", "Generating summary..."), + emptyTitle = readerString("desktop_no_summary_cached_section", "No summary cached for this section."), + primaryActionLabel = readerString("desktop_generate_summary", "Generate summary"), + onPrimaryAction = { onGenerateSummary(false) }, + onRegenerate = { onGenerateSummary(true) }, + onClear = { + summaryCacheStore.deleteSummary(bookKey, itemIndex) + cacheRefresh++ + onClearSummary() + } + ) + } + recapTab -> { + DesktopAiHubResultView( + title = readerString("ai_story_recap", "Story recap"), + resultText = recapResult?.recap, + errorText = recapResult?.error, + cost = recapResult?.cost, + freeRemaining = recapResult?.freeRemaining, + isCacheHit = false, + isLoading = isRecapLoading, + loadingLabel = recapProgressMessage?.takeIf { it.isNotBlank() } ?: readerString("ai_generating_recap", "Generating recap..."), + emptyTitle = readerString("desktop_create_recap_current_position", "Create a recap up to your current position."), + primaryActionLabel = readerString("desktop_generate_recap", "Generate recap"), + onPrimaryAction = { onGenerateRecap?.invoke() }, + onRegenerate = { onGenerateRecap?.invoke() }, + onClear = onClearRecap + ) + } + cacheTab -> { + DesktopSummaryCachePanel( + bookKey = bookKey, + summaryCacheStore = summaryCacheStore, + onCacheChanged = { + cacheRefresh++ + onClearSummary() + } + ) + } + } + } +} + +@Composable +private fun DesktopAiHubResultView( + title: String, + resultText: String?, + errorText: String?, + cost: Double?, + freeRemaining: Int?, + isCacheHit: Boolean, + isLoading: Boolean, + loadingLabel: String, + emptyTitle: String, + primaryActionLabel: String, + onPrimaryAction: () -> Unit, + onRegenerate: () -> Unit, + onClear: () -> Unit +) { + val clipboard = LocalClipboardManager.current + val hasText = !resultText.isNullOrBlank() + val hasError = !errorText.isNullOrBlank() + Column( + modifier = Modifier.fillMaxWidth().heightIn(min = 260.dp, max = 430.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + DesktopAiUsageBadge( + isCacheHit = isCacheHit, + cost = cost, + freeRemaining = freeRemaining, + isLoading = isLoading + ) + } + if (isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + when { + hasText -> { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + TextButton(enabled = !isLoading, onClick = onRegenerate) { + Text(readerString("ai_regenerate", "Regenerate")) + } + TextButton(enabled = !isLoading, onClick = onClear) { + Text(readerString("action_clear", "Clear")) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = { clipboard.setText(AnnotatedString(resultText.orEmpty())) }) { + Icon(Icons.Default.ContentCopy, contentDescription = readerString("action_copy", "Copy")) + } + } + HorizontalDivider() + Column(modifier = Modifier.weight(1f).heightIn(min = 120.dp).padding(end = 2.dp).verticalScroll(rememberScrollState())) { + SharedMarkdownText(resultText.orEmpty()) + } + } + hasError -> { + Text(errorText.orEmpty(), color = MaterialTheme.colorScheme.error) + Button(onClick = onPrimaryAction, enabled = !isLoading) { + Text(primaryActionLabel) + } + } + !isLoading -> { + Text(emptyTitle, color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = onPrimaryAction) { + Text(primaryActionLabel) + } + } + } + } +} + +@Composable +private fun DesktopAiUsageBadge( + isCacheHit: Boolean, + cost: Double?, + freeRemaining: Int?, + isLoading: Boolean +) { + val text = when { + isCacheHit -> readerString("desktop_cached", "Cached") + cost == 0.0 && freeRemaining != null -> readerString("desktop_free_remaining_format", "Free, %1\$d left", freeRemaining) + cost != null -> readerString("desktop_credits_decimal_format", "%1\$s credits", cost) + isLoading -> readerString("desktop_cost_calculating", "Cost calculating") + else -> null + } ?: return + Surface( + color = if (isCacheHit || cost == 0.0) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.primaryContainer + }, + shape = RoundedCornerShape(10.dp) + ) { + Text( + text, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = if (isCacheHit || cost == 0.0) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + ) + } +} + +@Composable +private fun DesktopSummaryCachePanel( + bookKey: String, + summaryCacheStore: DesktopSummaryCacheStore, + onCacheChanged: () -> Unit +) { + var cachedItems by remember(bookKey) { mutableStateOf(summaryCacheStore.getAllSummaries(bookKey)) } + if (cachedItems.isEmpty()) { + Text(readerString("desktop_no_cached_summaries_book", "No cached summaries for this book yet."), color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + cachedItems.forEach { item -> + var expanded by remember(item.index, item.summary) { mutableStateOf(false) } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(8.dp), + tonalElevation = 1.dp + ) { + Column(modifier = Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(item.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(readerString("desktop_cached_summary", "Cached summary"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + TextButton(onClick = { expanded = !expanded }) { + Text(if (expanded) readerString("desktop_hide", "Hide") else readerString("desktop_view", "View")) + } + IconButton( + onClick = { + summaryCacheStore.deleteSummary(bookKey, item.index) + cachedItems = summaryCacheStore.getAllSummaries(bookKey) + onCacheChanged() + } + ) { + Icon(Icons.Default.Delete, contentDescription = readerString("desktop_delete_summary", "Delete summary"), tint = MaterialTheme.colorScheme.error) + } + } + if (expanded) { + SharedMarkdownText(item.summary) + } + } + } + } + TextButton( + onClick = { + summaryCacheStore.clearBookCache(bookKey) + cachedItems = emptyList() + onCacheChanged() + }, + modifier = Modifier.align(Alignment.End) + ) { + Text(readerString("clear_all", "Clear all"), color = MaterialTheme.colorScheme.error) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt new file mode 100644 index 0000000..ac63f0b --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppHost.kt @@ -0,0 +1,703 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import org.dueattendant149.bookreader.shared.AppContrastOption +import org.dueattendant149.bookreader.shared.AppThemeMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode +import org.dueattendant149.bookreader.shared.ui.SharedAppTheme +import org.dueattendant149.bookreader.shared.ui.readerString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.withContext +import java.awt.Component +import java.awt.EventQueue +import java.awt.Frame +import java.awt.GraphicsDevice +import java.awt.KeyboardFocusManager +import java.awt.Rectangle +import java.awt.Toolkit +import java.awt.event.KeyEvent as AwtKeyEvent +import java.util.concurrent.atomic.AtomicReference + +internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300) + +private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L) + +internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) { + configureComposeSwingInterop() + application { + val windowDefaults = remember { epistemeDesktopWindowDefaults() } + val windowStateStore = remember { DesktopWindowStateStore() } + val restoredWindowState = remember { windowStateStore.load() } + val windowState = rememberWindowState( + placement = restoredWindowState?.toWindowPlacement() + ?: DesktopWindowStateSnapshot.default().toWindowPlacement(), + position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center), + size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize + ) + var readerFullscreen by remember { mutableStateOf(false) } + DesktopWindowStatePersistenceEffect( + windowState = windowState, + store = windowStateStore, + enabled = !readerFullscreen + ) + Window( + onCloseRequest = ::exitApplication, + title = windowDefaults.title, + state = windowState, + icon = painterResource(windowDefaults.iconResourcePath) + ) { + DisposableEffect(window, windowDefaults.minimumSize) { + window.minimumSize = windowDefaults.minimumSize + onDispose { + startupSplash?.close() + } + } + EpistemeDesktopStartupGate( + window = window, + startupSplash = startupSplash, + appWindowPlacement = windowState.placement, + readerFullscreen = readerFullscreen, + onReaderFullscreenChange = { readerFullscreen = it } + ) + } + } +} + +@Composable +private fun EpistemeDesktopStartupGate( + window: Component?, + startupSplash: DesktopStartupSplash?, + appWindowPlacement: WindowPlacement, + readerFullscreen: Boolean, + onReaderFullscreenChange: (Boolean) -> Unit +) { + var showApp by remember { mutableStateOf(false) } + + DisposableEffect(startupSplash) { + onDispose { + startupSplash?.close() + } + } + + if (showApp) { + EpistemeDesktopApp( + window = window, + appWindowPlacement = appWindowPlacement, + readerFullscreen = readerFullscreen, + onReaderFullscreenChange = onReaderFullscreenChange + ) + } else { + EpistemeDesktopStartupScreen(window = window) + } + + LaunchedEffect(Unit) { + withFrameNanos { } + startupSplash?.close() + delay(80L) + showApp = true + } +} + +@Composable +private fun EpistemeDesktopStartupScreen(window: Component?) { + val appTitle = remember { epistemeDesktopWindowDefaults().title } + SharedAppTheme( + appThemeMode = AppThemeMode.SYSTEM, + appContrastOption = AppContrastOption.STANDARD, + appTextDimFactorLight = 1.0f, + appTextDimFactorDark = 1.0f, + appSeedColor = DesktopDefaultAppSeedColor + ) { + EpistemeDesktopWindowChromeEffect( + window = window, + captionColor = MaterialTheme.colorScheme.surface, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(32.dp) + ) { + Image( + painter = painterResource(EpistemeDesktopWindowIconResource), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + Text( + text = appTitle, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = readerString("desktop_opening_your_library", "Opening your library"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 3.dp + ) + } + } + } +} + +internal const val ComposeInteropBlendingProperty = "compose.interop.blending" +internal const val ComposeInteropBlendingEnabled = "true" +private const val DesktopWindowStatePersistDebounceMillis = 450L + +internal fun composeInteropBlendingDefault( + platform: DesktopPlatform = currentDesktopPlatform() +): String? { + return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { + null + } else { + ComposeInteropBlendingEnabled + } +} + +internal fun configureComposeSwingInterop( + platform: DesktopPlatform = currentDesktopPlatform() +) { + // Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT + // browser surfaces; the blending path can prevent those native children from painting. + if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) { + composeInteropBlendingDefault(platform)?.let { defaultValue -> + System.setProperty(ComposeInteropBlendingProperty, defaultValue) + } + } + logDesktopWebView2( + "compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}" + ) +} + +@Composable +internal fun DesktopWindowStatePersistenceEffect( + windowState: WindowState, + store: DesktopWindowStateStore, + enabled: Boolean, + transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it }, + onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {} +) { + val persistenceEnabled by rememberUpdatedState(enabled) + val latestTransformSnapshot by rememberUpdatedState(transformSnapshot) + val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved) + LaunchedEffect(windowState, store) { + snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) } + .distinctUntilChanged() + .collectLatest { snapshot -> + if (!persistenceEnabled || snapshot == null) return@collectLatest + val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest + delay(DesktopWindowStatePersistDebounceMillis) + if (persistenceEnabled) { + withContext(Dispatchers.IO) { + store.save(persistableSnapshot) + } + latestOnSnapshotSaved(persistableSnapshot) + } + } + } + DisposableEffect(windowState, store) { + onDispose { + if (persistenceEnabled) { + DesktopWindowStateSnapshot.fromWindowState(windowState) + ?.let(latestTransformSnapshot) + ?.let { snapshot -> + runCatching { store.save(snapshot) } + latestOnSnapshotSaved(snapshot) + } + } + } + } +} + +@Composable +internal fun DesktopReaderFullscreenEffect( + window: Component?, + enabled: Boolean +) { + val awtWindow = window as? java.awt.Window ?: return + val fullscreenSnapshot = remember(awtWindow) { + AtomicReference() + } + val pendingExitSnapshot = remember(awtWindow) { + AtomicReference() + } + + LaunchedEffect(awtWindow, enabled) { + if (!enabled && fullscreenSnapshot.get() == null) { + return@LaunchedEffect + } + if (enabled) { + EventQueue.invokeLater { + awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot) + } + } + delay(if (enabled) 180L else 80L) + EventQueue.invokeLater { + if (enabled) { + pendingExitSnapshot.set(null) + awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot) + } else { + awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot) + } + } + delay(120L) + EventQueue.invokeLater { + if (!enabled) { + awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null)) + } + awtWindow.refreshDesktopReaderWindowFocus() + } + if (!enabled) { + for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) { + delay(delayMillis) + EventQueue.invokeLater { + awtWindow.refreshDesktopReaderWindowFocus() + } + } + } + } + + DisposableEffect(awtWindow) { + onDispose { + EventQueue.invokeLater { + awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot) + } + } + } +} + +private data class DesktopReaderFullscreenSnapshot( + val device: GraphicsDevice?, + val frameState: Int?, + val frameBounds: Rectangle?, + val alwaysOnTop: Boolean +) + +private fun java.awt.Window.enterDesktopReaderFullscreen( + snapshotRef: AtomicReference +) { + if (!isDisplayable) return + focusableWindowState = true + captureDesktopReaderFullscreenSnapshot(snapshotRef) + applyDesktopReaderBorderlessFullscreen(snapshotRef.get()) + refreshDesktopReaderWindowFocus() +} + +private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot( + snapshotRef: AtomicReference +) { + snapshotRef.compareAndSet( + null, + DesktopReaderFullscreenSnapshot( + device = graphicsConfiguration?.device, + frameState = (this as? Frame)?.extendedState, + frameBounds = bounds.desktopReaderCopy(), + alwaysOnTop = isAlwaysOnTop + ) + ) +} + +private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) { + if (!isDisplayable) return + val device = snapshot?.device ?: graphicsConfiguration?.device + val frame = this as? Frame + focusableWindowState = true + if (frame != null) { + frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv() + frame.state = Frame.NORMAL + } + device?.let { fullscreenDevice -> + runCatching { + if (fullscreenDevice.fullScreenWindow == this) { + fullscreenDevice.fullScreenWindow = null + } + } + } + runCatching { + bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds + } + runCatching { + isAlwaysOnTop = true + } +} + +private fun java.awt.Window.beginDesktopReaderFullscreenExit( + snapshotRef: AtomicReference, + pendingExitSnapshotRef: AtomicReference? = null +) { + val snapshot = snapshotRef.getAndSet(null) + if (snapshot == null) return + pendingExitSnapshotRef?.set(snapshot) + val device = snapshot.device ?: graphicsConfiguration?.device + device?.let { fullscreenDevice -> + runCatching { + if (fullscreenDevice.fullScreenWindow == this) { + fullscreenDevice.fullScreenWindow = null + } + } + } + runCatching { + isAlwaysOnTop = snapshot.alwaysOnTop + } + if (!isVisible) { + isVisible = true + } + (this as? Frame)?.let { frame -> + frame.state = Frame.NORMAL + frame.extendedState = Frame.NORMAL + } +} + +private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) { + if (snapshot == null) return + runCatching { + isAlwaysOnTop = snapshot.alwaysOnTop + } + if (!isVisible) { + isVisible = true + } + val frame = this as? Frame + if (frame == null) { + bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) + return + } + val restoreMaximized = snapshot.frameState?.let { state -> + state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH + } == true + frame.extendedState = Frame.NORMAL + frame.state = Frame.NORMAL + frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device) + if (restoreMaximized) { + frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds() + EventQueue.invokeLater { + if (frame.isDisplayable && frame.isShowing) { + frame.extendedState = Frame.MAXIMIZED_BOTH + } + } + } + frame.toFront() + frame.requestFocus() + frame.validate() +} + +private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle { + return defaultConfiguration.bounds.desktopReaderCopy() +} + +private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? { + val configuration = defaultConfiguration ?: return null + return runCatching { + val bounds = configuration.bounds + val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration) + Rectangle( + bounds.x + insets.left, + bounds.y + insets.top, + (bounds.width - insets.left - insets.right).coerceAtLeast(1), + (bounds.height - insets.top - insets.bottom).coerceAtLeast(1) + ) + }.getOrNull() +} + +private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle { + val usableBounds = device?.desktopReaderUsableBounds() + ?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820) + val source = this ?: usableBounds + val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640)) + val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480)) + val looksFullscreen = source.x <= usableBounds.x && + source.y <= usableBounds.y && + source.width >= usableBounds.width && + source.height >= usableBounds.height + if (looksFullscreen) { + return usableBounds.desktopReaderCopy() + } + val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x) + val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y) + return Rectangle( + source.x.coerceIn(usableBounds.x, maxX), + source.y.coerceIn(usableBounds.y, maxY), + width, + height + ) +} + +private fun Rectangle.desktopReaderCopy(): Rectangle { + return Rectangle(x, y, width, height) +} + +private fun java.awt.Window.refreshDesktopReaderWindowFocus() { + if (!isDisplayable) return + if (this is Frame && extendedState and Frame.ICONIFIED != 0) { + extendedState = extendedState and Frame.ICONIFIED.inv() + } + toFront() + requestFocus() + requestFocusInWindow() + focusOwner?.requestFocus() +} + +@Composable +internal fun DesktopReaderFullscreenKeyEffect( + enabled: Boolean, + onKeyPressed: (AwtKeyEvent) -> Boolean +) { + DesktopReaderKeyDispatcherEffect( + enabled = enabled, + allowChromeModalWindows = false, + onKeyPressed = onKeyPressed + ) +} + +@Composable +internal fun DesktopReaderKeyDispatcherEffect( + enabled: Boolean, + allowChromeModalWindows: Boolean = false, + allowPanelModalWindows: Boolean = false, + dispatchWhenOwnerWindowActive: Boolean = true, + onKeyPressed: (AwtKeyEvent) -> Boolean +) { + val currentOnKeyPressed by rememberUpdatedState(onKeyPressed) + DisposableEffect( + enabled, + allowChromeModalWindows, + allowPanelModalWindows, + dispatchWhenOwnerWindowActive + ) { + if (!enabled) { + onDispose {} + } else { + val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() + val dispatcher = java.awt.KeyEventDispatcher { event -> + val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow + val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind() + val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = activeReaderModalKind, + allowChromeModalWindows = allowChromeModalWindows, + allowPanelModalWindows = allowPanelModalWindows, + dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive + ) + activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event) + } + focusManager.addKeyEventDispatcher(dispatcher) + onDispose { + focusManager.removeKeyEventDispatcher(dispatcher) + } + } + } +} + +internal enum class DesktopReaderModalWindowKind { + CHROME, + PANEL, + POPUP +} + +internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind: DesktopReaderModalWindowKind?, + allowChromeModalWindows: Boolean, + allowPanelModalWindows: Boolean, + dispatchWhenOwnerWindowActive: Boolean +): Boolean { + return when (activeReaderModalKind) { + null -> dispatchWhenOwnerWindowActive + DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows + DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows + DesktopReaderModalWindowKind.POPUP -> false + } +} + +private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? { + val windowTitle = when (this) { + is java.awt.Dialog -> title + is Frame -> title + else -> "" + } + return desktopReaderModalWindowKind( + windowName = name.orEmpty(), + windowTitle = windowTitle + ) +} + +internal fun desktopReaderModalWindowKind( + windowName: String, + windowTitle: String +): DesktopReaderModalWindowKind? { + return when { + windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" || + windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" || + windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME + + windowName == "${DesktopReaderModalWindowNamePrefix}Panel" || + windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" || + windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" || + windowTitle.startsWith("Reader Panel") || + windowTitle.startsWith("Reader Navigation") || + windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL + + windowName.startsWith(DesktopReaderModalWindowNamePrefix) || + windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP + + else -> null + } +} + +internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:" + +internal data class DesktopWebViewRuntimeState( + val initialized: Boolean = false, + val restartRequired: Boolean = false, + val downloadProgress: Float = -1f, + val errorMessage: String? = null +) + +internal enum class DesktopEpubWebViewBackend( + val logName: String, + val displayName: String +) { + WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"), + WEBKIT("webkit", "WebKit"), + UNSUPPORTED("unsupported", "native webview") +} + +internal fun desktopEpubWebViewBackend( + platform: DesktopPlatform = currentDesktopPlatform() +): DesktopEpubWebViewBackend { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 + DesktopOperatingSystem.LINUX, + DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT + DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED + } +} + +internal fun desktopEpubWebViewUsesNativeSwtBrowser( + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED +} + +internal fun desktopEpubWebViewUsesWebView2( + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 +} + +internal fun desktopShouldUseNativeVerticalEpubReader( + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return platform.os == DesktopOperatingSystem.LINUX +} + +internal fun desktopEpubBookLoadSemanticMode( + settings: ReaderSettings, + platform: DesktopPlatform = currentDesktopPlatform() +): SharedJvmBookLoadSemanticMode { + return if ( + settings.readingMode == ReaderReadingMode.VERTICAL && + !desktopShouldUseNativeVerticalEpubReader(platform) + ) { + SharedJvmBookLoadSemanticMode.SKIP + } else { + SharedJvmBookLoadSemanticMode.FULL + } +} + +internal fun desktopEpubWebViewCanRender( + state: DesktopWebViewRuntimeState, + platform: DesktopPlatform = currentDesktopPlatform() +): Boolean { + return desktopEpubWebViewUsesNativeSwtBrowser(platform) +} + +@Composable +internal fun DesktopWebViewRuntimeIndicator( + state: DesktopWebViewRuntimeState, + modifier: Modifier = Modifier +) { + val platform = currentDesktopPlatform() + val message = when { + !desktopEpubWebViewUsesNativeSwtBrowser(platform) -> + readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.") + state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage) + state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.") + state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt()) + else -> readerString("desktop_webview_preparing", "Preparing embedded webview...") + } + + Box( + modifier = modifier.padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.errorMessage == null && !state.restartRequired) { + if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) { + CircularProgressIndicator() + } + } + Text( + text = message, + color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + if (state.downloadProgress in 0f..100f) { + LinearProgressIndicator( + progress = { state.downloadProgress / 100f }, + modifier = Modifier.width(260.dp) + ) + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt new file mode 100644 index 0000000..686e4ae --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAppState.kt @@ -0,0 +1,159 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookShelfRef +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.SharedLibraryProjectionInput +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot +import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.ShelfRecord +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedEpubBook +import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter +import org.dueattendant149.bookreader.shared.ui.SharedAppTab + +internal val DesktopInitialAppTab = SharedAppTab.LIBRARY + +internal fun desktopEmptyReaderBook(): SharedEpubBook { + val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open") + return SharedEpubBook( + id = "desktop_empty_reader", + fileName = "", + title = noBookOpen, + chapters = listOf( + SharedEpubChapter( + id = "empty", + title = noBookOpen, + plainText = "" + ) + ) + ) +} + +internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot { + val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion + val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) { + DesktopDefaultTextReaderSettings + } else { + readerDefaultSettings + } + val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) { + DesktopDefaultPdfReaderSettings + } else { + pdfReaderDefaultSettings + } + val migratedBooks = if (shouldMigrateReaderDefaults) { + books.map { book -> + when { + book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) && + book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults) + book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) && + book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults) + else -> book + } + } + } else { + books + } + return copy( + books = migratedBooks, + appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor, + readerDefaultSettings = migratedTextDefaults, + pdfReaderDefaultSettings = migratedPdfDefaults, + desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion + ) +} + +internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState { + val readableBooks = books.filter { it.type in DesktopReadableFileTypes } + return SharedReaderScreenState( + rawLibraryBooks = readableBooks, + recentFilesLimit = recentFilesLimit, + allTags = tags.ifEmpty { readableBooks.collectTags() }, + syncedFolders = syncedFolders, + isTabsEnabled = isTabsEnabled, + openTabIds = openTabIds, + activeTabBookId = activeTabBookId, + pinnedHomeBookIds = pinnedHomeBookIds, + pinnedLibraryBookIds = pinnedLibraryBookIds, + useStrictFileFilter = useStrictFileFilter, + appThemeMode = appThemeMode, + appContrastOption = appContrastOption, + appTextDimFactorLight = appTextDimFactorLight, + appTextDimFactorDark = appTextDimFactorDark, + appSeedColor = appSeedColor, + appFontPreference = appFontPreference, + customAppThemes = customAppThemes, + customReaderThemes = customReaderThemes, + readerDefaultSettings = readerDefaultSettings, + pdfReaderDefaultSettings = pdfReaderDefaultSettings, + readerToolbarPreferences = readerToolbarPreferences, + readerHighlightPalette = readerHighlightPalette, + pdfHighlighterPalette = pdfHighlighterPalette, + readerTtsReplacementPreferences = readerTtsReplacementPreferences + ) +} + +internal fun SharedLibraryStateProjector.projectDesktopLibraryState( + state: SharedReaderScreenState, + shelfRecords: List, + shelfRefs: List +): SharedReaderScreenState { + val allBooks = state.rawLibraryBooks + val visibleBooks = allBooks.filterNot { isDesktopPdfReflowBookId(it.id) } + val projected = project( + SharedLibraryProjectionInput( + state = state, + booksFromStore = visibleBooks, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + tags = state.allTags.ifEmpty { visibleBooks.collectTags() } + ) + ) + val booksById = allBooks.associateBy { it.id } + val openTabs = state.openTabIds.mapNotNull { booksById[it] } + val openTabIds = openTabs.map { it.id } + return projected.copy( + rawLibraryBooks = allBooks, + openTabs = openTabs, + openTabIds = openTabIds, + activeTabBookId = state.activeTabBookId?.takeIf { it in openTabIds } + ) +} + +internal fun SharedReaderScreenState.toDesktopLibrarySnapshot( + shelfRecords: List, + shelfRefs: List, + customFonts: List +): SharedLibrarySnapshot { + return SharedLibrarySnapshot( + books = rawLibraryBooks, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + tags = allTags, + customFonts = customFonts, + syncedFolders = syncedFolders, + recentFilesLimit = recentFilesLimit, + isTabsEnabled = isTabsEnabled, + openTabIds = openTabIds, + activeTabBookId = activeTabBookId, + pinnedHomeBookIds = pinnedHomeBookIds, + pinnedLibraryBookIds = pinnedLibraryBookIds, + useStrictFileFilter = useStrictFileFilter, + appThemeMode = appThemeMode, + appContrastOption = appContrastOption, + appTextDimFactorLight = appTextDimFactorLight, + appTextDimFactorDark = appTextDimFactorDark, + appSeedColor = appSeedColor, + appFontPreference = appFontPreference, + customAppThemes = customAppThemes, + customReaderThemes = customReaderThemes, + readerDefaultSettings = readerDefaultSettings, + pdfReaderDefaultSettings = pdfReaderDefaultSettings, + desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion, + readerToolbarPreferences = readerToolbarPreferences, + readerHighlightPalette = readerHighlightPalette, + pdfHighlighterPalette = pdfHighlighterPalette, + readerTtsReplacementPreferences = readerTtsReplacementPreferences + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt new file mode 100644 index 0000000..c58f3df --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAtomicFile.kt @@ -0,0 +1,56 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.Properties + +internal fun File.writeTextAtomically(text: String) { + parentFile?.mkdirs() + val temp = createSiblingTempFile() + try { + temp.writeText(text) + moveReplacing(temp, this) + } finally { + runCatching { if (temp.exists()) temp.delete() } + } +} + +internal fun File.storePropertiesAtomically(properties: Properties, comments: String) { + parentFile?.mkdirs() + val temp = createSiblingTempFile() + try { + temp.outputStream().use { output -> + properties.store(output, comments) + } + moveReplacing(temp, this) + } finally { + runCatching { if (temp.exists()) temp.delete() } + } +} + +private fun File.createSiblingTempFile(): File { + val directory = parentFile ?: File(".") + directory.mkdirs() + val prefix = ".$name." + return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile() +} + +private fun moveReplacing(source: File, target: File) { + target.parentFile?.mkdirs() + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt new file mode 100644 index 0000000..55268cf --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporter.kt @@ -0,0 +1,109 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ImportedBookFile +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +internal data class DesktopPreparedImport( + val files: List, + val failedCount: Int +) + +internal class DesktopBookImporter( + private val booksDirectory: File = File(desktopUserDataRoot(), "books") +) { + fun createBookFile(fileName: String): File { + booksDirectory.mkdirs() + return File(booksDirectory, fileName) + } + + fun prepareImports(files: List): DesktopPreparedImport { + val preparedFiles = mutableListOf() + var failedCount = 0 + booksDirectory.mkdirs() + + files.forEach { file -> + val type = SharedFileCapabilities.fileTypeForName(file.name) + if (!SharedFileCapabilities.canOpen(type, ReaderPlatform.DESKTOP)) { + preparedFiles += file.copy(sourceFolder = null) + return@forEach + } + + val source = file.localPath + ?.let(::File) + ?.takeIf { it.isFile } + + if (source == null) { + failedCount += 1 + return@forEach + } + + val hashResult = runCatching { source.sha256() } + if (hashResult.isFailure) { + failedCount += 1 + return@forEach + } + val hash = hashResult.getOrThrow() + val destination = File(booksDirectory, "$hash${file.storageSuffix(source)}") + + val copyResult = runCatching { + copyIfNeeded(source, destination) + destination + } + if (copyResult.isFailure) { + failedCount += 1 + return@forEach + } + val copied = copyResult.getOrThrow() + + preparedFiles += ImportedBookFile( + name = file.name, + uriString = null, + localPath = copied.absolutePath, + size = copied.length(), + sourceFolder = null, + id = hash + ) + } + + return DesktopPreparedImport( + files = preparedFiles, + failedCount = failedCount + ) + } + + private fun copyIfNeeded(source: File, destination: File) { + val sourceFile = source.canonicalFile + val destinationFile = destination.canonicalFile + if (sourceFile == destinationFile) return + destination.parentFile?.mkdirs() + Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + private fun ImportedBookFile.storageSuffix(source: File): String { + return SharedFileCapabilities.fileExtensionSuffixForName(name) + ?: source.extension.takeIf { it.isNotBlank() }?.let { ".$it" } + ?: ".book" + } +} + +private fun File.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(8 * 1024) + while (true) { + val read = input.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + } + return digest.digest().toHexString() +} + +private fun ByteArray.toHexString(): String { + return joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt new file mode 100644 index 0000000..4055007 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfile.kt @@ -0,0 +1,85 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.SharedFeaturePolicy +import org.dueattendant149.bookreader.shared.SharedLegalLinks +import org.dueattendant149.bookreader.shared.SharedLegalProfile +import org.dueattendant149.bookreader.shared.sharedLegalLinksForProfile + +internal const val DesktopFlavorProperty = "episteme.desktop.flavor" +internal const val DesktopVersionProperty = "episteme.desktop.version" +internal const val DesktopFlavorStandard = "standard" +internal const val DesktopFlavorOssOffline = "oss-offline" +internal const val EpistemeDesktopStandardAppName = "Episteme" +internal const val EpistemeDesktopOssAppName = "Episteme oss" +internal const val ComposeApplicationResourcesDirProperty = "compose.application.resources.dir" + +internal data class DesktopBuildProfile( + val flavor: String, + val appName: String, + val buildLabel: String, + val featurePolicy: SharedFeaturePolicy, + val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) { + SharedLegalProfile.OSS + } else { + SharedLegalProfile.STANDARD + } +) { + val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline + val aiKeySettingsAvailable: Boolean + get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS + val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess + val creditBackedCloudTtsControlsAvailable: Boolean + get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable + val legalLinks: SharedLegalLinks + get() = sharedLegalLinksForProfile(legalProfile) +} + +internal fun currentDesktopBuildProfile(): DesktopBuildProfile { + return desktopBuildProfileForFlavor( + System.getProperty(DesktopFlavorProperty, DesktopFlavorStandard) + ) +} + +internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfile { + val flavor = normalizedDesktopFlavor(rawFlavor) + return when (flavor) { + DesktopFlavorOssOffline -> DesktopBuildProfile( + flavor = DesktopFlavorOssOffline, + appName = EpistemeDesktopOssAppName, + buildLabel = "Offline OSS edition", + featurePolicy = SharedFeaturePolicy.OssOffline, + legalProfile = SharedLegalProfile.OSS + ) + else -> DesktopBuildProfile( + flavor = DesktopFlavorStandard, + appName = EpistemeDesktopStandardAppName, + buildLabel = "Standard edition", + featurePolicy = SharedFeaturePolicy.Standard, + legalProfile = SharedLegalProfile.STANDARD + ) + } +} + +private fun normalizedDesktopFlavor(rawFlavor: String?): String { + return when (rawFlavor?.trim()?.lowercase()) { + DesktopFlavorOssOffline, + "oss", + "episteme-oss" -> DesktopFlavorOssOffline + else -> DesktopFlavorStandard + } +} + +internal fun ReaderAiByokSettings.withDesktopFeaturePolicy( + featurePolicy: SharedFeaturePolicy +): ReaderAiByokSettings { + return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) { + toDesktopPersistableAiSettings() + } else { + ReaderAiByokSettings() + } +} + +internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings { + return sanitized().copy(hideReaderAiFeatures = false) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt new file mode 100644 index 0000000..e51f5dc --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopByokAiAdapter.kt @@ -0,0 +1,342 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.AiAdapter +import org.dueattendant149.bookreader.shared.AiDefinitionResult +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderByokTextRequest +import org.dueattendant149.bookreader.shared.ReaderByokTextRequestResult +import org.dueattendant149.bookreader.shared.ReaderByokTextRequests +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.SummarizationResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.net.HttpURLConnection +import java.net.URL + +class DesktopByokAiAdapter( + private val settingsProvider: () -> ReaderAiByokSettings, + private val networkAccess: () -> Boolean = { true } +) : AiAdapter { + override val isAvailable: Boolean + get() = networkAccess() && settingsProvider().sanitized().areReaderAiFeaturesAvailable + + override suspend fun define(text: String, context: String?): AiDefinitionResult { + val result = callTextAi(ReaderAiFeature.DEFINE, text, context) + return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message) + } + + override suspend fun defineStreaming( + text: String, + context: String?, + onUpdate: (String) -> Unit + ): AiDefinitionResult { + val result = callTextAi(ReaderAiFeature.DEFINE, text, context, onUpdate) + return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message) + } + + override suspend fun summarize(text: String): SummarizationResult { + val result = callTextAi(ReaderAiFeature.SUMMARIZE, text) + return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message) + } + + override suspend fun summarizeStreaming( + text: String, + onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, + onUpdate: (String) -> Unit + ): SummarizationResult { + val result = callTextAi(ReaderAiFeature.SUMMARIZE, text, onUpdate = onUpdate) + return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message) + } + + override suspend fun recap(textBeforeCurrentLocation: String): RecapResult { + val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation) + return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message) + } + + suspend fun callTextAi( + feature: ReaderAiFeature, + text: String, + context: String? = null, + onUpdate: (String) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + if (!networkAccess()) return@withContext Result.failure(IllegalStateException("AI features are unavailable in this desktop build.")) + if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send.")) + when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) { + ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden.")) + is ReaderByokTextRequestResult.MissingKey -> { + Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models.")) + } + is ReaderByokTextRequestResult.MissingModel -> { + Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models.")) + } + is ReaderByokTextRequestResult.Ready -> runCatching { + requestResult.request.execute(onUpdate) + } + } + } + + private fun ReaderByokTextRequest.execute(onUpdate: (String) -> Unit): String { + var connection: HttpURLConnection? = null + try { + val url = if (model.provider == "groq") { + URL("https://api.groq.com/openai/v1/chat/completions") + } else { + URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey") + } + connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json; charset=UTF-8") + setRequestProperty("Accept", "application/json") + if (model.provider == "groq") { + setRequestProperty("Authorization", "Bearer $apiKey") + } + connectTimeout = 15_000 + readTimeout = 120_000 + doOutput = true + doInput = true + } + val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this) + connection.outputStream.use { output -> + output.write(payload.toByteArray(Charsets.UTF_8)) + } + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + val errorBody = runCatching { + connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } + }.getOrNull() + throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}") + } + val text = if (model.provider == "groq") { + streamGroqResponse(connection, onUpdate) + } else { + streamGeminiResponse(connection, onUpdate) + }.trim() + if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.") + return text + } finally { + connection?.disconnect() + } + } + + private fun buildGroqPayload(request: ReaderByokTextRequest): String { + return buildJsonObject { + put("model", JsonPrimitive(request.model.name)) + put( + "messages", + buildJsonArray { + add(buildJsonObject { + put("role", JsonPrimitive("system")) + put("content", JsonPrimitive(request.systemInstruction)) + }) + add(buildJsonObject { + put("role", JsonPrimitive("user")) + put("content", JsonPrimitive(request.userPrompt)) + }) + } + ) + put("temperature", JsonPrimitive(request.temperature)) + put("top_p", JsonPrimitive(0.95)) + put("max_tokens", JsonPrimitive(request.maxTokens)) + put("stream", JsonPrimitive(true)) + if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none")) + }.toString() + } + + private fun buildGeminiPayload(request: ReaderByokTextRequest): String { + return buildJsonObject { + put( + "contents", + buildJsonArray { + add(buildJsonObject { + put("parts", buildJsonArray { + add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) }) + }) + }) + } + ) + put( + "systemInstruction", + buildJsonObject { + put("parts", buildJsonArray { + add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) }) + }) + } + ) + put( + "generationConfig", + buildJsonObject { + put("temperature", JsonPrimitive(request.temperature)) + put("topP", JsonPrimitive(0.95)) + put("topK", JsonPrimitive(40)) + put("maxOutputTokens", JsonPrimitive(request.maxTokens)) + put("response_mime_type", JsonPrimitive("text/plain")) + if (request.model.name.startsWith("gemini")) { + put( + "thinkingConfig", + buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) } + ) + } + } + ) + }.toString() + } + + private fun streamGeminiResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String { + val output = StringBuilder() + connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + var buffer = "" + var line: String? + while (reader.readLine().also { line = it } != null) { + buffer += line + while (true) { + val start = buffer.indexOf('{') + if (start == -1) { + buffer = "" + break + } + var depth = 0 + var end = -1 + scan@ for (index in start until buffer.length) { + when (buffer[index]) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) { + end = index + break@scan + } + } + } + } + if (end == -1) break + val jsonObject = buffer.substring(start, end + 1) + buffer = buffer.substring(end + 1) + val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull() + val chunk = parsed.geminiTextChunk() + if (chunk.isNotEmpty()) { + output.append(chunk) + onUpdate(chunk) + } + if (parsed.geminiFinishReason() == "SAFETY") { + throw IllegalStateException("Blocked for safety reasons.") + } + } + } + } + return output.toString() + } + + private fun streamGroqResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String { + val output = StringBuilder() + var inThink = false + var thinkBuffer = "" + + fun cleanChunk(text: String): String { + thinkBuffer += text + val cleaned = StringBuilder() + while (true) { + if (inThink) { + val end = thinkBuffer.indexOf("") + if (end == -1) { + if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7) + break + } + inThink = false + thinkBuffer = thinkBuffer.substring(end + 8) + } else { + val start = thinkBuffer.indexOf("") + if (start == -1) { + if (thinkBuffer.length > 6) { + cleaned.append(thinkBuffer.dropLast(6)) + thinkBuffer = thinkBuffer.takeLast(6) + } + break + } + cleaned.append(thinkBuffer.substring(0, start)) + inThink = true + thinkBuffer = thinkBuffer.substring(start + 7) + } + } + return cleaned.toString() + } + + connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + val trimmed = line!!.trim() + if (!trimmed.startsWith("data: ")) continue + val data = trimmed.removePrefix("data: ").trim() + if (data == "[DONE]") continue + val chunk = runCatching { + DesktopAiJson.parseToJsonElement(data).jsonObject["choices"] + ?.jsonArray + ?.firstOrNull() + ?.jsonObject + ?.get("delta") + ?.jsonObject + ?.get("content") + ?.jsonPrimitive + ?.contentOrNull + }.getOrNull().orEmpty() + val cleaned = cleanChunk(chunk) + if (cleaned.isNotEmpty()) { + output.append(cleaned) + onUpdate(cleaned) + } + } + } + if (!inThink && thinkBuffer.isNotBlank()) { + output.append(thinkBuffer) + onUpdate(thinkBuffer) + } + return output.toString() + } +} + +private val DesktopAiJson = Json { ignoreUnknownKeys = true } + +private fun JsonObject?.geminiTextChunk(): String { + if (this == null) return "" + return this["candidates"] + ?.jsonArrayOrNull() + ?.firstOrNull() + ?.jsonObjectOrNull() + ?.get("content") + ?.jsonObjectOrNull() + ?.get("parts") + ?.jsonArrayOrNull() + ?.firstOrNull() + ?.jsonObjectOrNull() + ?.get("text") + ?.jsonPrimitiveOrNull() + ?.contentOrNull + .orEmpty() +} + +private fun JsonObject?.geminiFinishReason(): String? { + if (this == null) return null + return this["candidates"] + ?.jsonArrayOrNull() + ?.firstOrNull() + ?.jsonObjectOrNull() + ?.get("finishReason") + ?.jsonPrimitiveOrNull() + ?.contentOrNull +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject +private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray +private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt new file mode 100644 index 0000000..2ca06c6 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfig.kt @@ -0,0 +1,85 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.util.Properties + +internal data class DesktopCloudConfig( + val aiWorkerUrl: String, + val ttsWorkerUrl: String, + val firebaseWebApiKey: String, + val firebaseProjectId: String, + val googleOAuthClientId: String, + val googleOAuthClientSecret: String +) { + val isAuthConfigured: Boolean + get() = firebaseWebApiKey.isNotBlank() && + firebaseProjectId.isNotBlank() && + googleOAuthClientId.isNotBlank() + + val isAiWorkerConfigured: Boolean get() = aiWorkerUrl.isNotBlank() + val isTtsWorkerConfigured: Boolean get() = ttsWorkerUrl.isNotBlank() +} + +internal fun loadDesktopCloudConfig(): DesktopCloudConfig { + return desktopCloudConfigFromProperties( + resourceProperties = loadDesktopCloudResourceProperties(), + localProperties = loadDesktopLocalProperties() + ) +} + +private fun loadDesktopCloudResourceProperties(): Properties { + return Properties().apply { + val classLoader = DesktopCloudConfig::class.java.classLoader + val stream = classLoader.getResourceAsStream("desktop-cloud.properties") + ?: classLoader.getResourceAsStream("common/desktop-cloud.properties") + ?: System.getProperty(ComposeApplicationResourcesDirProperty) + ?.let(::File) + ?.let { resourcesDir -> + listOf( + resourcesDir.resolve("desktop-cloud.properties"), + resourcesDir.resolve("common/desktop-cloud.properties") + ).firstOrNull { it.isFile }?.inputStream() + } + stream?.use { input -> load(input) } + } +} + +private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties { + return Properties().apply { + file.takeIf { it.isFile } + ?.inputStream() + ?.use { input -> load(input) } + } +} + +internal fun desktopCloudConfigFromProperties( + resourceProperties: Properties, + localProperties: Properties = Properties(), + systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") }, + environment: (String) -> String? = { key -> System.getenv(key) } +): DesktopCloudConfig { + fun value(vararg keys: String): String { + return keys.firstNotNullOfOrNull { key -> + systemProperty(key) + ?: environment("EPISTEME_DESKTOP_${key.uppercase()}") + ?: environment(key) + ?: localProperties.getProperty("DESKTOP_$key") + ?: localProperties.getProperty(key) + ?: resourceProperties.getProperty(key) + }?.trim().orEmpty() + } + + val aiWorkerUrl = value("AI_WORKER_URL").ifBlank { + "https://reader-ai.aryanrajttps.workers.dev" + } + val ttsWorkerUrl = value("TTS_WORKER_URL") + + return DesktopCloudConfig( + aiWorkerUrl = aiWorkerUrl, + ttsWorkerUrl = ttsWorkerUrl, + firebaseWebApiKey = value("FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"), + firebaseProjectId = value("FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" }, + googleOAuthClientId = value("GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"), + googleOAuthClientSecret = value("GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET") + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt new file mode 100644 index 0000000..5b3ea79 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudRepositories.kt @@ -0,0 +1,683 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import java.io.SequenceInputStream +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.time.Duration +import java.time.Instant +import java.util.Collections +import java.util.UUID + +internal data class DesktopCloudBookMetadata( + val bookId: String = "", + val title: String? = null, + val author: String? = null, + val displayName: String = "", + val type: String = "", + val lastPositionCfi: String? = null, + val lastChapterIndex: Int? = null, + val locatorBlockIndex: Int? = null, + val locatorCharOffset: Int? = null, + val lastPage: Int? = null, + val progressPercentage: Float? = null, + val isRecent: Boolean = true, + val isDeleted: Boolean = false, + val lastModifiedTimestamp: Long = 0L, + val readingPositionModifiedTimestamp: Long = 0L, + val annotationModifiedTimestamp: Long = 0L, + val bookmarksJson: String? = null, + val hasAnnotations: Boolean = false, + val fileContentModifiedTimestamp: Long = 0L, + val customName: String? = null, + val highlightsJson: String? = null, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val description: String? = null, + val originalTitle: String? = null, + val originalAuthor: String? = null, + val originalSeriesName: String? = null, + val originalSeriesIndex: Double? = null, + val originalDescription: String? = null +) + +internal data class DesktopCloudShelfMetadata( + val name: String = "", + val bookIds: List = emptyList(), + val lastModifiedTimestamp: Long = 0L, + val isDeleted: Boolean = false +) + +internal data class DesktopCloudFontMetadata( + val id: String = "", + val displayName: String = "", + val fileName: String = "", + val fileExtension: String = "", + val timestamp: Long = 0L, + val isDeleted: Boolean = false +) + +internal data class DesktopDriveFile( + val id: String, + val name: String, + val modifiedTimeMillis: Long = 0L +) + +internal class DesktopFirestoreRepository( + private val config: DesktopCloudConfig, + private val client: HttpClient = defaultDesktopCloudHttpClient() +) { + suspend fun getAllBooks(userId: String, idToken: String): List = + firestoreCollection(userId, "books", idToken).mapNotNull { document -> + document.fields?.toBookMetadata(document.id) + } + + suspend fun getBookMetadata(userId: String, bookId: String, idToken: String): DesktopCloudBookMetadata? = + firestoreDocument(userId, "books", bookId, idToken)?.let { document -> + document.fields?.toBookMetadata(document.id) + } + + suspend fun syncBookMetadata( + userId: String, + book: DesktopCloudBookMetadata, + originDeviceId: String, + idToken: String + ) { + val fields = book.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId)) + writeFirestoreDocument(userId, "books", book.bookId, fields, idToken) + } + + suspend fun getAllShelves(userId: String, idToken: String): List = + firestoreCollection(userId, "shelves", idToken).mapNotNull { document -> + document.fields?.toShelfMetadata(document.id) + } + + suspend fun syncShelf( + userId: String, + shelf: DesktopCloudShelfMetadata, + originDeviceId: String, + idToken: String + ) { + val fields = shelf.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId)) + writeFirestoreDocument(userId, "shelves", shelf.name, fields, idToken) + } + + suspend fun getAllFonts(userId: String, idToken: String): List = + firestoreCollection(userId, "fonts", idToken).mapNotNull { document -> + document.fields?.toFontMetadata(document.id) + } + + suspend fun syncFontMetadata(userId: String, font: DesktopCloudFontMetadata, idToken: String) { + writeFirestoreDocument(userId, "fonts", font.id, font.toFirestoreFields(), idToken) + } + + suspend fun deleteFontMetadata(userId: String, fontId: String, idToken: String) { + deleteFirestoreDocument(userId, "fonts", fontId, idToken) + } + + suspend fun deleteAllUserFirestoreData(userId: String, idToken: String) { + listOf("books", "shelves", "fonts").forEach { collection -> + firestoreCollection(userId, collection, idToken).forEach { document -> + deleteFirestoreDocument(userId, collection, document.id, idToken) + } + } + } + + private suspend fun firestoreCollection( + userId: String, + collection: String, + idToken: String + ): List = withContext(Dispatchers.IO) { + val response = sendFirestore( + request = HttpRequest.newBuilder(firestoreCollectionUri(userId, collection)) + .GET() + .build(), + idToken = idToken, + allowNotFound = true + ) ?: return@withContext emptyList() + val root = DesktopCloudJson.parseToJsonElement(response).jsonObject + root["documents"]?.jsonArrayOrNull().orEmpty().mapNotNull { element -> + val document = element.jsonObjectOrNull() ?: return@mapNotNull null + DesktopFirestoreDocument( + id = document.string("name")?.substringAfterLast('/').orEmpty(), + fields = document["fields"]?.jsonObjectOrNull() + ) + } + } + + private suspend fun firestoreDocument( + userId: String, + collection: String, + documentId: String, + idToken: String + ): DesktopFirestoreDocument? = withContext(Dispatchers.IO) { + val response = sendFirestore( + request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId)) + .GET() + .build(), + idToken = idToken, + allowNotFound = true + ) ?: return@withContext null + val document = DesktopCloudJson.parseToJsonElement(response).jsonObject + DesktopFirestoreDocument( + id = document.string("name")?.substringAfterLast('/').orEmpty(), + fields = document["fields"]?.jsonObjectOrNull() + ) + } + + private suspend fun writeFirestoreDocument( + userId: String, + collection: String, + documentId: String, + fields: Map, + idToken: String + ) = withContext(Dispatchers.IO) { + val body = JsonObject(mapOf("fields" to JsonObject(fields))) + sendFirestore( + request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId)) + .header("Content-Type", "application/json; charset=UTF-8") + .method("PATCH", HttpRequest.BodyPublishers.ofString(DesktopCloudJson.encodeToString(JsonElement.serializer(), body))) + .build(), + idToken = idToken + ) + Unit + } + + private suspend fun deleteFirestoreDocument( + userId: String, + collection: String, + documentId: String, + idToken: String + ) = withContext(Dispatchers.IO) { + sendFirestore( + request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId)) + .DELETE() + .build(), + idToken = idToken, + allowNotFound = true + ) + Unit + } + + private fun firestoreCollectionUri(userId: String, collection: String): URI { + return URI.create("${firestoreBaseUrl()}/users/${pathEncode(userId)}/$collection") + } + + private fun firestoreDocumentUri(userId: String, collection: String, documentId: String): URI { + return URI.create("${firestoreCollectionUri(userId, collection)}/${pathEncode(documentId)}") + } + + private fun firestoreBaseUrl(): String { + return "https://firestore.googleapis.com/v1/projects/${pathEncode(config.firebaseProjectId)}/databases/(default)/documents" + } + + private fun sendFirestore( + request: HttpRequest, + idToken: String, + allowNotFound: Boolean = false + ): String? { + val authed = HttpRequest.newBuilder(request.uri()) + .timeout(Duration.ofSeconds(30)) + .copyMethodAndBodyFrom(request) + .header("Authorization", "Bearer $idToken") + .header("Accept", "application/json") + .apply { + request.headers().map().forEach { (name, values) -> + values.forEach { value -> header(name, value) } + } + } + .build() + val response = client.send(authed, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) + if (allowNotFound && response.statusCode() == 404) return null + if (response.statusCode() !in 200..299) { + throw IllegalStateException("Firestore HTTP ${response.statusCode()}: ${response.body().take(240)}") + } + return response.body() + } +} + +internal class DesktopGoogleDriveRepository( + private val client: HttpClient = defaultDesktopCloudHttpClient() +) { + suspend fun getFiles(accessToken: String): List = withContext(Dispatchers.IO) { + listFiles(accessToken = accessToken, query = null) + } + + suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) { + listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull() + } + + suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? = + uploadNamedFile( + accessToken = accessToken, + fileName = fileName, + file = file, + contentType = when (extension.lowercase()) { + "ttf" -> "font/ttf" + "otf" -> "font/otf" + "woff2" -> "font/woff2" + else -> "application/octet-stream" + } + ) + + suspend fun uploadFile(accessToken: String, bookId: String, file: File, type: FileType): DesktopDriveFile? { + val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null + val mimeType = SharedFileCapabilities.mimeTypeFor(type) ?: return null + return uploadNamedFile( + accessToken = accessToken, + fileName = "$bookId.$extension", + file = file, + contentType = mimeType + ) + } + + suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? { + return uploadNamedFile( + accessToken = accessToken, + fileName = desktopCloudAnnotationDriveFileName(bookId), + file = file, + contentType = "application/json" + ) + } + + suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean { + val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId)) + ?: return false + return downloadFile(accessToken, driveFile.id, destination).also { downloaded -> + if (downloaded && driveFile.modifiedTimeMillis > 0L) { + destination.setLastModified(driveFile.modifiedTimeMillis) + } + } + } + + suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) { + destination.parentFile?.mkdirs() + val temp = File(destination.parentFile ?: File("."), "${destination.name}.${System.nanoTime()}.tmp") + val request = HttpRequest.newBuilder( + URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}?alt=media") + ) + .timeout(Duration.ofMinutes(3)) + .header("Authorization", "Bearer $accessToken") + .GET() + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofFile(temp.toPath())) + if (response.statusCode() !in 200..299) { + temp.delete() + return@withContext false + } + Files.move(temp.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + true + } + + suspend fun deleteAllFiles(accessToken: String): Boolean = withContext(Dispatchers.IO) { + getFiles(accessToken).forEach { file -> + deleteDriveFile(accessToken, file.id) + } + true + } + + suspend fun deleteDriveFile(accessToken: String, fileId: String): Boolean = withContext(Dispatchers.IO) { + val request = HttpRequest.newBuilder( + URI.create("https://www.googleapis.com/drive/v3/files/${pathEncode(fileId)}") + ) + .timeout(Duration.ofSeconds(30)) + .header("Authorization", "Bearer $accessToken") + .DELETE() + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) + response.statusCode() in 200..299 || response.statusCode() == 404 + } + + private suspend fun uploadNamedFile( + accessToken: String, + fileName: String, + file: File, + contentType: String + ): DesktopDriveFile? = withContext(Dispatchers.IO) { + if (!file.isFile) return@withContext null + val existingFiles = listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false") + existingFiles.drop(1).forEach { duplicate -> deleteDriveFile(accessToken, duplicate.id) } + val existingFileId = existingFiles.firstOrNull()?.id + val boundary = "episteme_${UUID.randomUUID().toString().replace("-", "")}" + val metadata = buildJsonObject { + put("name", JsonPrimitive(fileName)) + if (existingFileId == null) { + put("parents", JsonArray(listOf(JsonPrimitive("appDataFolder")))) + } + } + val prefix = buildString { + append("--") + append(boundary) + append("\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n") + append(DesktopCloudJson.encodeToString(JsonElement.serializer(), metadata)) + append("\r\n--") + append(boundary) + append("\r\nContent-Type: ") + append(contentType) + append("\r\n\r\n") + }.toByteArray(Charsets.UTF_8) + val suffix = "\r\n--$boundary--\r\n".toByteArray(Charsets.UTF_8) + val uploadUri = if (existingFileId == null) { + URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") + } else { + URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}") + } + val request = HttpRequest.newBuilder(uploadUri) + .timeout(Duration.ofMinutes(5)) + .header("Authorization", "Bearer $accessToken") + .header("Content-Type", "multipart/related; boundary=$boundary") + .method( + if (existingFileId == null) "POST" else "PATCH", + HttpRequest.BodyPublishers.ofInputStream { + sequenceInputStream( + ByteArrayInputStream(prefix), + file.inputStream(), + ByteArrayInputStream(suffix) + ) + } + ) + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) + if (response.statusCode() !in 200..299) return@withContext null + val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject + DesktopDriveFile( + id = root.string("id").orEmpty(), + name = root.string("name").orEmpty(), + modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime")) + ) + } + + private fun listFiles(accessToken: String, query: String?): List { + val params = buildList { + add("spaces" to "appDataFolder") + add("fields" to "files(id,name,modifiedTime)") + if (!query.isNullOrBlank()) add("q" to query) + } + val request = HttpRequest.newBuilder( + URI.create("https://www.googleapis.com/drive/v3/files?${query(params)}") + ) + .timeout(Duration.ofSeconds(30)) + .header("Authorization", "Bearer $accessToken") + .header("Accept", "application/json") + .GET() + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) + if (response.statusCode() !in 200..299) { + throw IllegalStateException("Google Drive HTTP ${response.statusCode()}: ${response.body().take(240)}") + } + val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject + return root["files"]?.jsonArrayOrNull().orEmpty().mapNotNull { element -> + val obj = element.jsonObjectOrNull() ?: return@mapNotNull null + val id = obj.string("id") ?: return@mapNotNull null + val name = obj.string("name") ?: return@mapNotNull null + DesktopDriveFile( + id = id, + name = name, + modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime")) + ) + } + } +} + +private fun parseDriveModifiedTimeMillis(value: String?): Long { + if (value.isNullOrBlank()) return 0L + return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L) +} + +private data class DesktopFirestoreDocument( + val id: String, + val fields: JsonObject? +) + +private val DesktopCloudJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = true +} + +private fun defaultDesktopCloudHttpClient(): HttpClient { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() +} + +private fun DesktopCloudBookMetadata.toFirestoreFields(): Map = mapOf( + "bookId" to firestoreString(bookId), + "title" to firestoreNullableString(title), + "author" to firestoreNullableString(author), + "displayName" to firestoreString(displayName), + "type" to firestoreString(type), + "lastPositionCfi" to firestoreNullableString(lastPositionCfi), + "lastChapterIndex" to firestoreNullableInt(lastChapterIndex), + "locatorBlockIndex" to firestoreNullableInt(locatorBlockIndex), + "locatorCharOffset" to firestoreNullableInt(locatorCharOffset), + "lastPage" to firestoreNullableInt(lastPage), + "progressPercentage" to firestoreNullableFloat(progressPercentage), + "isRecent" to firestoreBoolean(isRecent), + "isDeleted" to firestoreBoolean(isDeleted), + "lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp), + "readingPositionModifiedTimestamp" to firestoreLong(readingPositionModifiedTimestamp), + "annotationModifiedTimestamp" to firestoreLong(annotationModifiedTimestamp), + "bookmarksJson" to firestoreNullableString(bookmarksJson), + "hasAnnotations" to firestoreBoolean(hasAnnotations), + "fileContentModifiedTimestamp" to firestoreLong(fileContentModifiedTimestamp), + "customName" to firestoreNullableString(customName), + "highlightsJson" to firestoreNullableString(highlightsJson), + "seriesName" to firestoreNullableString(seriesName), + "seriesIndex" to firestoreNullableDouble(seriesIndex), + "description" to firestoreNullableString(description), + "originalTitle" to firestoreNullableString(originalTitle), + "originalAuthor" to firestoreNullableString(originalAuthor), + "originalSeriesName" to firestoreNullableString(originalSeriesName), + "originalSeriesIndex" to firestoreNullableDouble(originalSeriesIndex), + "originalDescription" to firestoreNullableString(originalDescription) +) + +private fun DesktopCloudShelfMetadata.toFirestoreFields(): Map = mapOf( + "name" to firestoreString(name), + "bookIds" to firestoreStringArray(bookIds), + "lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp), + "isDeleted" to firestoreBoolean(isDeleted) +) + +private fun DesktopCloudFontMetadata.toFirestoreFields(): Map = mapOf( + "id" to firestoreString(id), + "displayName" to firestoreString(displayName), + "fileName" to firestoreString(fileName), + "fileExtension" to firestoreString(fileExtension), + "timestamp" to firestoreLong(timestamp), + "isDeleted" to firestoreBoolean(isDeleted) +) + +private fun JsonObject.toBookMetadata(documentId: String): DesktopCloudBookMetadata? { + val bookId = stringField("bookId") ?: documentId.takeIf { it.isNotBlank() } ?: return null + return DesktopCloudBookMetadata( + bookId = bookId, + title = stringField("title"), + author = stringField("author"), + displayName = stringField("displayName").orEmpty(), + type = stringField("type").orEmpty(), + lastPositionCfi = stringField("lastPositionCfi"), + lastChapterIndex = intField("lastChapterIndex"), + locatorBlockIndex = intField("locatorBlockIndex"), + locatorCharOffset = intField("locatorCharOffset"), + lastPage = intField("lastPage"), + progressPercentage = doubleField("progressPercentage")?.toFloat(), + isRecent = booleanField("isRecent") ?: true, + isDeleted = booleanField("isDeleted") ?: false, + lastModifiedTimestamp = longField("lastModifiedTimestamp"), + readingPositionModifiedTimestamp = longField("readingPositionModifiedTimestamp"), + annotationModifiedTimestamp = longField("annotationModifiedTimestamp"), + bookmarksJson = stringField("bookmarksJson"), + hasAnnotations = booleanField("hasAnnotations") ?: false, + fileContentModifiedTimestamp = longField("fileContentModifiedTimestamp"), + customName = stringField("customName"), + highlightsJson = stringField("highlightsJson"), + seriesName = stringField("seriesName"), + seriesIndex = doubleField("seriesIndex"), + description = stringField("description"), + originalTitle = stringField("originalTitle"), + originalAuthor = stringField("originalAuthor"), + originalSeriesName = stringField("originalSeriesName"), + originalSeriesIndex = doubleField("originalSeriesIndex"), + originalDescription = stringField("originalDescription") + ) +} + +private fun JsonObject.toShelfMetadata(documentId: String): DesktopCloudShelfMetadata? { + val name = stringField("name") ?: documentId.takeIf { it.isNotBlank() } ?: return null + return DesktopCloudShelfMetadata( + name = name, + bookIds = stringArrayField("bookIds"), + lastModifiedTimestamp = longField("lastModifiedTimestamp"), + isDeleted = booleanField("isDeleted") ?: false + ) +} + +private fun JsonObject.toFontMetadata(documentId: String): DesktopCloudFontMetadata? { + val id = stringField("id") ?: documentId.takeIf { it.isNotBlank() } ?: return null + return DesktopCloudFontMetadata( + id = id, + displayName = stringField("displayName").orEmpty(), + fileName = stringField("fileName").orEmpty(), + fileExtension = stringField("fileExtension").orEmpty(), + timestamp = longField("timestamp"), + isDeleted = booleanField("isDeleted") ?: false + ) +} + +private fun firestoreString(value: String): JsonElement = JsonObject(mapOf("stringValue" to JsonPrimitive(value))) + +private fun firestoreNullableString(value: String?): JsonElement { + return value?.let(::firestoreString) ?: firestoreNull() +} + +private fun firestoreNullableInt(value: Int?): JsonElement { + return value?.let { JsonObject(mapOf("integerValue" to JsonPrimitive(it.toString()))) } ?: firestoreNull() +} + +private fun firestoreLong(value: Long): JsonElement = JsonObject(mapOf("integerValue" to JsonPrimitive(value.toString()))) + +private fun firestoreNullableFloat(value: Float?): JsonElement { + return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it.toDouble()))) } ?: firestoreNull() +} + +private fun firestoreNullableDouble(value: Double?): JsonElement { + return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it))) } ?: firestoreNull() +} + +private fun firestoreBoolean(value: Boolean): JsonElement = JsonObject(mapOf("booleanValue" to JsonPrimitive(value))) + +private fun firestoreStringArray(values: List): JsonElement { + return JsonObject( + mapOf( + "arrayValue" to JsonObject( + mapOf("values" to JsonArray(values.map(::firestoreString))) + ) + ) + ) +} + +private fun firestoreNull(): JsonElement = JsonObject(mapOf("nullValue" to JsonPrimitive("NULL_VALUE"))) + +private fun JsonObject.stringField(key: String): String? { + return field(key)?.get("stringValue")?.jsonPrimitive?.contentOrNull +} + +private fun JsonObject.booleanField(key: String): Boolean? { + return field(key)?.get("booleanValue")?.jsonPrimitive?.booleanOrNull +} + +private fun JsonObject.longField(key: String): Long { + val field = field(key) ?: return 0L + return field["integerValue"]?.jsonPrimitive?.longOrNull + ?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toLong() + ?: 0L +} + +private fun JsonObject.intField(key: String): Int? { + val field = field(key) ?: return null + return field["integerValue"]?.jsonPrimitive?.intOrNull + ?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toInt() +} + +private fun JsonObject.doubleField(key: String): Double? { + val field = field(key) ?: return null + return field["doubleValue"]?.jsonPrimitive?.doubleOrNull + ?: field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() +} + +private fun JsonObject.stringArrayField(key: String): List { + val values = field(key) + ?.get("arrayValue") + ?.jsonObjectOrNull() + ?.get("values") + ?.jsonArrayOrNull() + .orEmpty() + return values.mapNotNull { it.jsonObjectOrNull()?.get("stringValue")?.jsonPrimitive?.contentOrNull } +} + +private fun JsonObject.field(key: String): JsonObject? = this[key]?.jsonObjectOrNull() + +private fun JsonElement?.jsonObjectOrNull(): JsonObject? { + if (this == null || this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() +} + +private fun JsonElement?.jsonArrayOrNull(): JsonArray? { + if (this == null || this is JsonNull) return null + return runCatching { jsonArray }.getOrNull() +} + +private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull + +private fun query(vararg pairs: Pair): String = query(pairs.asIterable()) + +private fun query(pairs: Iterable>): String { + return pairs.joinToString("&") { (key, value) -> "${formEncode(key)}=${formEncode(value)}" } +} + +private fun formEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) + +private fun pathEncode(value: String): String = formEncode(value).replace("+", "%20") + +private fun driveQueryStringValue(value: String): String { + return value.replace("\\", "\\\\").replace("'", "\\'") +} + +private fun sequenceInputStream(vararg streams: InputStream): SequenceInputStream { + return SequenceInputStream(Collections.enumeration(streams.toList())) +} + +private fun HttpRequest.Builder.copyMethodAndBodyFrom(source: HttpRequest): HttpRequest.Builder { + val publisher = source.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody()) + return method(source.method(), publisher) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt new file mode 100644 index 0000000..31f86bb --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSidecarSync.kt @@ -0,0 +1,311 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import java.io.File + +internal object DesktopCloudSidecarSync { + fun localAnnotationDebugSummary(book: BookItem): String { + val path = book.path?.takeIf { it.isNotBlank() } ?: return "path=null type=${book.type}" + if (book.type != FileType.PDF) return "path=${path.logPreview(140)} type=${book.type}" + val annotationFile = desktopPdfAnnotationFile(path) + val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) + val bookmarkFile = desktopPdfBookmarkFile(path) + val richTextFile = desktopPdfRichTextFile(path) + return "path=${path.logPreview(140)} " + + "annotations{exists=${annotationFile.isFile} syncable=${annotationFile.hasSyncablePdfAnnotations()} " + + "bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFile()}} " + + "deletedAnnotations{exists=${deletedAnnotationFile.isFile} count=${deletedAnnotationFile.annotationDeletionCount()} " + + "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModifiedIfFile()}} " + + "bookmarks{exists=${bookmarkFile.isFile} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModifiedIfFile()}} " + + "text{exists=${richTextFile.isFile} syncable=${richTextFile.hasSyncablePdfRichText()} " + + "bytes=${richTextFile.length()} ts=${richTextFile.lastModifiedIfFile()}} " + + "payloadTs=${localAnnotationPayloadTimestamp(book)} totalTs=${localAnnotationTimestamp(book)}" + } + + fun hasLocalAnnotationData(book: BookItem): Boolean { + val path = book.path?.takeIf { it.isNotBlank() } ?: return false + if (book.type != FileType.PDF) return false + return desktopPdfAnnotationFile(path).hasSyncablePdfAnnotations() || + desktopPdfAnnotationDeletionFile(path).hasSyncablePdfAnnotationDeletions() || + desktopPdfBookmarkFile(path).isFile || + desktopPdfRichTextFile(path).hasSyncablePdfRichText() + } + + fun localAnnotationTimestamp(book: BookItem): Long { + val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L + if (book.type != FileType.PDF) return 0L + return maxOf( + localAnnotationPayloadTimestamp(path), + desktopPdfBookmarkFile(path).lastModifiedIfFile() + ) + } + + fun localAnnotationPayloadTimestamp(book: BookItem): Long { + val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L + if (book.type != FileType.PDF) return 0L + return localAnnotationPayloadTimestamp(path) + } + + fun markAnnotationPayloadSynced(book: BookItem, timestamp: Long) { + val path = book.path?.takeIf { it.isNotBlank() } ?: return + if (book.type != FileType.PDF || timestamp <= 0L) return + listOf(desktopPdfAnnotationFile(path), desktopPdfAnnotationDeletionFile(path), desktopPdfRichTextFile(path)) + .filter { it.isFile } + .forEach { it.setLastModified(timestamp) } + } + + fun recordAnnotationDeletions(book: BookItem, annotationIds: Collection) { + val path = book.path?.takeIf { it.isNotBlank() } ?: return + if (book.type != FileType.PDF) return + recordAnnotationDeletions(path, book.id, annotationIds) + } + + fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection) { + val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet() + if (ids.isEmpty()) return + val file = desktopPdfAnnotationDeletionFile(documentPath) + val existing = if (file.isFile) { + SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText()) + } else { + emptyMap() + } + val now = System.currentTimeMillis() + val next = existing.toMutableMap() + ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) } + val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next) + if (file.isFile && file.readText() == nextJson) return + file.parentFile?.mkdirs() + file.writeText(nextJson) + logDesktopCloudAnnotations { + "desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " + + "bytes=${file.length()} ts=${file.lastModified()}" + } + } + + fun exportAnnotationBundle(book: BookItem): File? { + val path = book.path?.takeIf { it.isNotBlank() } ?: return null + if (book.type != FileType.PDF) return null + val annotationFile = desktopPdfAnnotationFile(path) + val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) + val richTextFile = desktopPdfRichTextFile(path) + logDesktopCloudAnnotations { + "desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}" + } + val data = buildMap { + if (annotationFile.isFile) { + desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations -> + put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations) + } + } + if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) { + val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText()) + put( + SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, + SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions) + ) + } + if (richTextFile.isFile) { + desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) } + } + } + if (data.isEmpty()) { + logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" } + return null + } + val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data) + val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson( + cloudSidecarJson.encodeToString(JsonElement.serializer(), payload) + ) + val tempFile = File( + desktopUserCacheRoot(), + "sync_bundle_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json" + ) + tempFile.parentFile?.mkdirs() + tempFile.writeText(canonical) + logDesktopCloudAnnotations { + "desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " + + "canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}" + } + return tempFile + } + + fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean { + val path = book.path?.takeIf { it.isNotBlank() } ?: run { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" } + return false + } + if (book.type != FileType.PDF) { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" } + return false + } + val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run { + logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" } + return false + } + val data = root["data"]?.jsonObjectOrNull() ?: root + val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data) + val annotationFile = desktopPdfAnnotationFile(path) + val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path) + val bookmarkFile = desktopPdfBookmarkFile(path) + val richTextFile = desktopPdfRichTextFile(path) + logDesktopCloudAnnotations { + "desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " + + "rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " + + localAnnotationDebugSummary(book) + } + + if (canonicalData.hasPdfAnnotationPayload()) { + val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData) + if (annotations.isEmpty()) { + if (annotationFile.isFile) { + val deleted = annotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" } + } + } else { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations)) + annotationFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_annotations book=${book.id} count=${annotations.size} " + + "bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}" + } + } + } else if (annotationFile.isFile) { + val deleted = annotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" } + } + + val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData) + if (deletions.isEmpty()) { + if (deletedAnnotationFile.isFile) { + val deleted = deletedAnnotationFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" } + } + } else { + deletedAnnotationFile.parentFile?.mkdirs() + deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions)) + deletedAnnotationFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " + + "bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}" + } + } + + canonicalData["bookmarks"]?.let { bookmarks -> + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks)) + bookmarkFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}" + } + } + + canonicalData["text"]?.let { richText -> + val richDocument = SharedPdfRichTextSerializer.decodeElement(richText) + if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) { + if (richTextFile.isFile) { + val deleted = richTextFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" } + } + } else { + richTextFile.parentFile?.mkdirs() + richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) + richTextFile.setLastModified(timestamp) + logDesktopCloudAnnotations { + "desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " + + "spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}" + } + } + } ?: run { + if (richTextFile.isFile) { + val deleted = richTextFile.delete() + logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" } + } else { + logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" } + } + } + logDesktopCloudAnnotations { + "desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}" + } + return true + } +} + +private fun localAnnotationPayloadTimestamp(path: String): Long { + return maxOf( + desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(), + desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(), + desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText() + ) +} + +private val cloudSidecarJson = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true +} + +private fun Json.parseElementOrNull(raw: String): JsonElement? { + return runCatching { parseToJsonElement(raw) }.getOrNull() +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() +} + +private fun JsonObject.hasPdfAnnotationPayload(): Boolean { + return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) || + containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS) +} + +private fun File.lastModifiedIfFile(): Long { + return if (isFile) lastModified() else 0L +} + +private fun File.hasSyncablePdfAnnotations(): Boolean { + return isFile && desktopPdfAnnotationElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableAnnotations(): Long { + return if (hasSyncablePdfAnnotations()) lastModified() else 0L +} + +private fun File.annotationDeletionCount(): Int { + return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0 +} + +private fun File.hasSyncablePdfAnnotationDeletions(): Boolean { + return annotationDeletionCount() > 0 +} + +private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long { + return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L +} + +private fun File.hasSyncablePdfRichText(): Boolean { + return isFile && desktopPdfRichTextElementForSync(readText()) != null +} + +private fun File.lastModifiedIfSyncableRichText(): Long { + return if (hasSyncablePdfRichText()) lastModified() else 0L +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt new file mode 100644 index 0000000..382ac97 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSync.kt @@ -0,0 +1,1204 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.BookShelfRef +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer +import org.dueattendant149.bookreader.shared.EpubBookmark +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.SharedCloudBookMetadataWinner +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.ShelfRecord +import org.dueattendant149.bookreader.shared.sharedCloudBookMetadataWinner +import org.dueattendant149.bookreader.shared.shouldDownloadRemoteCloudBookContent +import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent +import org.dueattendant149.bookreader.shared.sharedCloudBookContentFileName +import org.dueattendant149.bookreader.shared.toStablePositionCfi +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSidecarCodec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark +import java.io.File + +internal data class DesktopCloudSyncInput( + val userId: String, + val idToken: String, + val driveAccessToken: String, + val deviceId: String, + val state: SharedReaderScreenState, + val shelfRecords: List, + val shelfRefs: List, + val customFonts: List, + val includeFolderBooks: Boolean +) + +internal data class DesktopCloudSyncResult( + val state: SharedReaderScreenState, + val shelfRecords: List, + val shelfRefs: List, + val customFonts: List, + val uploadedBooks: Int = 0, + val downloadedBooks: Int = 0, + val pendingContentDownloads: Int = 0 +) + +internal class DesktopCloudSync( + private val firestoreRepository: DesktopFirestoreRepository, + private val driveRepository: DesktopGoogleDriveRepository, + private val bookImporter: DesktopBookImporter, + private val customFontStore: DesktopCustomFontStore +) { + suspend fun sync(input: DesktopCloudSyncInput): DesktopCloudSyncResult { + var state = input.state + var shelfRecords = input.shelfRecords + var shelfRefs = input.shelfRefs + var customFonts = input.customFonts + var uploadedBooks = 0 + var downloadedBooks = 0 + var pendingContentDownloads = 0 + + logDesktopCloudSync { + "desktop.engine.full_sync.start user=${input.userId} device=${input.deviceId} " + + "localBooks=${input.state.rawLibraryBooks.size} includeFolderBooks=${input.includeFolderBooks}" + } + val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken) + .filterNot { isDesktopPdfReflowBookId(it.bookId) } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken) + val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken) + var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } + logDesktopCloudSync { + "desktop.engine.full_sync.loaded user=${input.userId} remoteBooks=${remoteBooks.size} " + + "remoteShelves=${remoteShelves.size} remoteFonts=${remoteFonts.size} driveFiles=${driveFiles.size}" + } + + val localBooks = state.rawLibraryBooks + .filterNot { isDesktopPdfReflowBookId(it.id) } + .filter { input.includeFolderBooks || it.sourceFolder == null } + .filterNot { it.path?.startsWith("opds-pse") == true } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + val localBooksMap = localBooks.associateBy { it.id } + val remoteBooksMap = remoteBooks.associateBy { it.bookId } + val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct() + + allBookIds.forEach { bookId -> + val local = localBooksMap[bookId] + val remote = remoteBooksMap[bookId] + if (local?.sourceFolder != null) return@forEach + + when { + local != null && remote == null -> { + logDesktopCloudSync { "desktop.engine.book_decision action=upload_new ${local.desktopCloudSyncSummary()}" } + uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } + + local == null && remote != null -> { + if (remote.isDeleted) { + logDesktopCloudSync { "desktop.engine.book_decision action=skip_deleted_remote_only ${remote.desktopCloudSyncSummary()}" } + return@forEach + } + logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_new ${remote.desktopCloudSyncSummary()}" } + val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles) + if (downloaded == null) { + pendingContentDownloads += 1 + logDesktopCloudSync { + "desktop.engine.book_decision action=defer_remote_new_pending_content " + + remote.desktopCloudSyncSummary() + } + return@forEach + } + val remoteBook = downloaded + state = state.upsertCloudBook(remoteBook) + downloadedBooks += 1 + importDesktopPdfBookmarksMetadata(remoteBook, remote.bookmarksJson, remote.lastModifiedTimestamp) + if (remote.hasAnnotations) { + val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveFileTimestamp(remote.bookId, driveFiles) + ) + downloadAnnotations(input.driveAccessToken, remoteBook, remoteAnnotationTimestamp) + } + } + + local != null && remote != null -> { + val remoteBook = remote.toDesktopBookItem(existing = local) + val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote) + val downloaded = if (shouldDownloadContent) { + downloadRemoteBook(input.driveAccessToken, remote, local, driveFiles) + } else { + null + } + if (shouldDownloadContent && downloaded == null) { + pendingContentDownloads += 1 + } + val localContentAvailable = local.path?.let(::File)?.isFile == true + if (shouldDownloadContent && downloaded == null && !localContentAvailable) { + logDesktopCloudSync { + "desktop.engine.book_decision action=defer_existing_pending_content book=$bookId " + + local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() + } + state = state.removeCloudBook(bookId) + return@forEach + } + val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local) + val metadataWinner = sharedCloudBookMetadataWinner( + localModifiedTimestamp = local.timestamp, + remoteModifiedTimestamp = remote.lastModifiedTimestamp + ) + val localMetadataWins = metadataWinner == SharedCloudBookMetadataWinner.LOCAL + val localReadingTimestamp = local.effectiveCloudReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() + val remoteAnnotationDriveTimestamp = remoteAnnotationDriveFileTimestamp(bookId, driveFiles) + val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp) + val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp + val localAnnotationsShouldUpload = shouldUploadLocalAnnotations( + local = local, + remote = remote, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + localSidecarTimestamp = localSidecarTimestampBeforeMerge + ) + logDesktopCloudAnnotations { + "desktop.sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " + + "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " + + "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp localTs=${local.timestamp} " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "localShouldUpload=$localAnnotationsShouldUpload " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(local) + } + logDesktopCloudSync { + "desktop.engine.book_compare book=$bookId winner=$metadataWinner shouldDownloadContent=$shouldDownloadContent " + + "downloadedContent=${downloaded != null} sidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary() + } + + if (remote.isDeleted) { + if (localMetadataWins) { + logDesktopCloudSync { "desktop.engine.book_decision action=resurrect_upload_local book=$bookId" } + uploadBookAndMetadata( + input = input, + book = local, + uploadContent = shouldUploadLocalBookContent(local, null), + uploadAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(local) + )?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE) { + logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_delete book=$bookId" } + state = state.removeCloudBook(bookId) + } else { + logDesktopCloudSync { "desktop.engine.book_decision action=skip_equal_delete book=$bookId" } + } + return@forEach + } + + if (localMetadataWins) { + logDesktopCloudSync { + "desktop.engine.book_decision action=upload_local book=$bookId " + + "uploadContent=${shouldUploadLocalBookContent(local, remote)} " + + "uploadAnnotations=$localAnnotationsShouldUpload " + + "preserveRemoteReading=${remoteReadingTimestamp > localReadingTimestamp}" + } + val localForMetadata = if (remoteReadingTimestamp > localReadingTimestamp) { + local.withCloudReadingPosition(remote) + } else { + local + } + val bookForMetadata = localForMetadata.withDownloadedCloudContent(downloaded, replacePath = false) + uploadBookAndMetadata( + input = input, + book = bookForMetadata, + uploadContent = shouldUploadLocalBookContent(local, remote), + uploadAnnotations = localAnnotationsShouldUpload, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) + uploadedBooks += 1 + } + } else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE || downloaded != null) { + logDesktopCloudSync { + "desktop.engine.book_decision action=apply_remote book=$bookId " + + "metadataWinner=$metadataWinner downloadedContent=${downloaded != null}" + } + val mergedBook = downloaded ?: remoteBook + state = state.upsertCloudBook(mergedBook) + importDesktopPdfBookmarksMetadata(mergedBook, remote.bookmarksJson, remote.lastModifiedTimestamp) + } + + if (!localMetadataWins && (localAnnotationsShouldUpload || localReadingPositionShouldUpload)) { + val metadataBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } + ?: remoteBook + logDesktopCloudAnnotations { + "desktop.sync.upload_local_supplement book=$bookId winner=$metadataWinner " + + "remoteHas=${remote.hasAnnotations} remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp " + + "localSidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp" + } + logDesktopCloudSync { + "desktop.engine.book_decision action=upload_local_supplement book=$bookId " + + "metadataWinner=$metadataWinner sidecarTs=$localSidecarTimestampBeforeMerge " + + "uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " + + metadataBook.desktopCloudSyncSummary() + } + uploadBookAndMetadata( + input = input, + book = metadataBook, + uploadContent = false, + uploadAnnotations = localAnnotationsShouldUpload, + remoteHasAnnotations = remote.hasAnnotations, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded)) + uploadedBooks += 1 + } + } + + val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(downloaded ?: local) + val needsAnnotationDownload = !localMetadataWins && + !localAnnotationsShouldUpload && + remote.hasAnnotations && + (remoteAnnotationTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L) + if (needsAnnotationDownload) { + logDesktopCloudAnnotations { + "desktop.sync.download_remote_annotations book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp " + + "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + + "localShouldUpload=$localAnnotationsShouldUpload" + } + logDesktopCloudSync { + "desktop.engine.sidecar_download_start book=$bookId remoteTs=${remote.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" + } + val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local + downloadAnnotations(input.driveAccessToken, targetBook, remoteAnnotationTimestamp) + } else { + logDesktopCloudAnnotations { + "desktop.sync.skip_remote_annotations book=$bookId remoteHas=${remote.hasAnnotations} " + + "remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " + + "localShouldUpload=$localAnnotationsShouldUpload" + } + logDesktopCloudSync { + "desktop.engine.sidecar_download_skip book=$bookId remoteHasAnnotations=${remote.hasAnnotations} " + + "localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins" + } + } + } + } + } + + driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name } + state.rawLibraryBooks + .filterNot { isDesktopPdfReflowBookId(it.id) } + .filter { it.sourceFolder == null } + .filterNot { it.path?.startsWith("opds-pse") == true } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + .forEach { book -> + val driveName = desktopCloudBookDriveFileName(book.id, book.type) ?: return@forEach + val localFile = book.path?.let(::File) + when { + localFile?.isFile == true && driveFiles[driveName] == null -> { + val remote = remoteBooksMap[book.id] + if (remote == null || shouldUploadLocalBookContent(book, remote)) { + logDesktopCloudSync { "desktop.engine.content_upload_missing_remote book=${book.id} driveName=$driveName" } + uploadBookAndMetadata( + input = input, + book = book, + uploadContent = true, + uploadAnnotations = false, + remoteHasAnnotations = remote?.hasAnnotations == true, + remoteAnnotationModifiedTimestamp = remote?.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveFileTimestamp(book.id, driveFiles) + ) ?: 0L, + remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp + )?.let { synced -> + state = state.upsertCloudBook(synced) + uploadedBooks += 1 + } + } else { + pendingContentDownloads += 1 + logDesktopCloudSync { + "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName " + + "localContentTs=${book.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}" + } + } + } + + (localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> { + val remote = remoteBooksMap[book.id] ?: return@forEach + logDesktopCloudSync { "desktop.engine.content_download_missing_local book=${book.id} driveName=$driveName" } + val downloaded = downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles) + if (downloaded != null) { + state = state.upsertCloudBook(downloaded) + downloadedBooks += 1 + } else { + pendingContentDownloads += 1 + } + } + + (localFile == null || !localFile.isFile) && driveFiles[driveName] == null -> { + pendingContentDownloads += 1 + logDesktopCloudSync { "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName" } + state = state.removeCloudBook(book.id) + } + } + } + + val shelfSync = syncShelves( + userId = input.userId, + idToken = input.idToken, + deviceId = input.deviceId, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + syncableBookIds = state.rawLibraryBooks + .filterNot { isDesktopPdfReflowBookId(it.id) } + .mapTo(mutableSetOf()) { it.id }, + remoteShelves = remoteShelves + ) + shelfRecords = shelfSync.records + shelfRefs = shelfSync.refs + + customFonts = syncFonts( + userId = input.userId, + idToken = input.idToken, + accessToken = input.driveAccessToken, + localFonts = customFonts, + remoteFonts = remoteFonts + ) + + logDesktopCloudSync { + "desktop.engine.full_sync.complete user=${input.userId} uploaded=$uploadedBooks downloaded=$downloadedBooks " + + "pendingContent=$pendingContentDownloads books=${state.rawLibraryBooks.size}" + } + return DesktopCloudSyncResult( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }, + uploadedBooks = uploadedBooks, + downloadedBooks = downloadedBooks, + pendingContentDownloads = pendingContentDownloads + ) + } + + suspend fun uploadBookAndMetadata( + input: DesktopCloudSyncInput, + book: BookItem, + uploadContent: Boolean, + uploadAnnotations: Boolean = true, + remoteHasAnnotations: Boolean = false, + remoteAnnotationModifiedTimestamp: Long = 0L, + remoteContentModifiedTimestamp: Long? = null + ): BookItem? { + if (isDesktopPdfReflowBookId(book.id)) { + logDesktopCloudSync { "desktop.upload.skip reason=reflow ${book.desktopCloudSyncSummary()}" } + return null + } + if (book.sourceFolder != null) { + logDesktopCloudSync { "desktop.upload.skip reason=folder_book ${book.desktopCloudSyncSummary()}" } + return null + } + if (book.path?.startsWith("opds-pse") == true) { + logDesktopCloudSync { "desktop.upload.skip reason=opds_stream ${book.desktopCloudSyncSummary()}" } + return null + } + if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) { + logDesktopCloudSync { "desktop.upload.skip reason=manual_only ${book.desktopCloudSyncSummary()}" } + return null + } + logDesktopCloudSync { + "desktop.upload.start uploadContent=$uploadContent uploadAnnotations=$uploadAnnotations " + + "remoteHasAnnotations=$remoteHasAnnotations ${book.desktopCloudSyncSummary()}" + } + if (uploadContent) { + val source = book.path?.let(::File)?.takeIf { it.isFile } + if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) { + logDesktopCloudSync { "desktop.upload.content_failed book=${book.id} path=${source.absolutePath}" } + return null + } + logDesktopCloudSync { "desktop.upload.content_success book=${book.id} path=${source?.absolutePath ?: "none"}" } + } + + val hasLocalAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(book) + val shouldUploadAnnotations = uploadAnnotations || (!remoteHasAnnotations && hasLocalAnnotations) + val bundle = if (shouldUploadAnnotations) DesktopCloudSidecarSync.exportAnnotationBundle(book) else null + var uploadedAnnotationTimestamp = 0L + logDesktopCloudAnnotations { + "desktop.upload.annotation_decision book=${book.id} uploadAnnotations=$uploadAnnotations " + + "remoteHas=$remoteHasAnnotations hasLocal=$hasLocalAnnotations shouldUpload=$shouldUploadAnnotations " + + "bundleBytes=${bundle?.length() ?: 0L} " + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + try { + if (bundle != null) { + val mergedRemoteIntoUpload = mergeRemoteAnnotationsIntoUploadBundle( + accessToken = input.driveAccessToken, + book = book, + bundle = bundle, + remoteHasAnnotations = remoteHasAnnotations + ) + val uploadedAnnotationFile = driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) + if (uploadedAnnotationFile == null) { + logDesktopCloudAnnotations { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } + logDesktopCloudSync { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" } + return null + } + uploadedAnnotationTimestamp = uploadedAnnotationFile.modifiedTimeMillis + if (mergedRemoteIntoUpload) { + val appliedMergedLocal = DesktopCloudSidecarSync.importAnnotationBundle( + book = book, + rawJson = bundle.readText(), + timestamp = uploadedAnnotationTimestamp + ) + logDesktopCloudAnnotations { + "desktop.upload.local_apply_merged book=${book.id} applied=$appliedMergedLocal " + + "driveTs=$uploadedAnnotationTimestamp bytes=${bundle.length()}" + } + } + DesktopCloudSidecarSync.markAnnotationPayloadSynced(book, uploadedAnnotationTimestamp) + } + if (bundle != null) { + logDesktopCloudAnnotations { + "desktop.upload.sidecar_success book=${book.id} bytes=${bundle.length()} driveTs=$uploadedAnnotationTimestamp" + } + } else { + logDesktopCloudAnnotations { + "desktop.upload.sidecar_skipped book=${book.id} shouldUpload=$shouldUploadAnnotations hasLocal=$hasLocalAnnotations" + } + } + logDesktopCloudSync { + "desktop.upload.sidecar_decision book=${book.id} hasLocal=$hasLocalAnnotations " + + "shouldUpload=$shouldUploadAnnotations uploaded=${bundle != null} bytes=${bundle?.length() ?: 0L}" + } + } finally { + bundle?.delete() + } + + val now = System.currentTimeMillis() + val syncedBook = book.copy( + timestamp = now, + readingPositionModifiedTimestamp = book.effectiveCloudReadingPositionModifiedTimestamp() + ) + val localAnnotationTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(book) + val syncedAnnotationTimestamp = if (bundle != null) { + uploadedAnnotationTimestamp.takeIf { it > 0L } ?: maxOf(localAnnotationTimestamp, now) + } else if (remoteHasAnnotations) { + remoteAnnotationModifiedTimestamp + } else { + 0L + } + val syncedHasAnnotations = if (uploadAnnotations) { + syncedAnnotationTimestamp > 0L || (bundle != null && hasLocalAnnotations) + } else { + remoteHasAnnotations || syncedAnnotationTimestamp > 0L || bundle != null || hasLocalAnnotations + } + firestoreRepository.syncBookMetadata( + userId = input.userId, + book = syncedBook.toDesktopCloudBookMetadata( + hasAnnotations = syncedHasAnnotations, + timestamp = now, + annotationModifiedTimestamp = syncedAnnotationTimestamp, + contentTimestampOverride = if (uploadContent) null else remoteContentModifiedTimestamp + ), + originDeviceId = input.deviceId, + idToken = input.idToken + ) + logDesktopCloudSync { + "desktop.upload.metadata_success user=${input.userId} device=${input.deviceId} " + + "oldTs=${book.timestamp} newTs=$now hasAnnotations=$syncedHasAnnotations " + + syncedBook.desktopCloudSyncSummary("synced") + } + logDesktopCloudAnnotations { + "desktop.upload.metadata_success book=${book.id} oldTs=${book.timestamp} newTs=$now " + + "readTs=${syncedBook.effectiveCloudReadingPositionModifiedTimestamp()} " + + "annTs=$syncedAnnotationTimestamp hasAnnotations=$syncedHasAnnotations" + } + return syncedBook + } + + private suspend fun mergeRemoteAnnotationsIntoUploadBundle( + accessToken: String, + book: BookItem, + bundle: File, + remoteHasAnnotations: Boolean + ): Boolean { + if (!remoteHasAnnotations || !bundle.isFile) return false + val remoteTemp = File(desktopUserCacheRoot(), "remote_annotation_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") + try { + val didDownload = driveRepository.downloadAnnotationFile(accessToken, book.id, remoteTemp) + if (!didDownload || !remoteTemp.isFile) { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_missing book=${book.id} didDownload=$didDownload " + + "tempExists=${remoteTemp.exists()} localBytes=${bundle.length()}" + } + return false + } + val localRaw = bundle.readText() + val remoteRaw = remoteTemp.readText() + val mergedRaw = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson( + localDataJson = localRaw, + remoteDataJson = remoteRaw, + preferRemoteOnConflict = false + ) + val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(localRaw) + val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteRaw) + val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedRaw) + if (mergedRaw != localRaw) { + bundle.writeText(mergedRaw) + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_applied book=${book.id} localCount=$localCount " + + "remoteCount=$remoteCount mergedCount=$mergedCount mergedBytes=${bundle.length()}" + } + return true + } else { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_noop book=${book.id} localCount=$localCount " + + "remoteCount=$remoteCount mergedCount=$mergedCount" + } + } + } catch (error: Exception) { + logDesktopCloudAnnotations { + "desktop.upload.merge_remote_failed book=${book.id} error=${error.message.orEmpty().logPreview(240)}" + } + } finally { + remoteTemp.delete() + } + return false + } + + suspend fun deleteBooksFromCloud( + userId: String, + idToken: String, + accessToken: String, + deviceId: String, + books: List + ) { + val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } + books + .filterNot { isDesktopPdfReflowBookId(it.id) } + .filter { it.sourceFolder == null } + .filterNot { it.path?.startsWith("opds-pse") == true } + .filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) } + .forEach { book -> + firestoreRepository.syncBookMetadata( + userId = userId, + book = book.toDesktopCloudBookMetadata( + hasAnnotations = false, + timestamp = System.currentTimeMillis() + ).copy(isDeleted = true), + originDeviceId = deviceId, + idToken = idToken + ) + desktopCloudBookDriveFileName(book.id, book.type) + ?.let { driveFiles[it]?.id } + ?.let { driveRepository.deleteDriveFile(accessToken, it) } + driveFiles[desktopCloudAnnotationDriveFileName(book.id)]?.id + ?.let { driveRepository.deleteDriveFile(accessToken, it) } + } + } + + suspend fun syncShelfChange( + userId: String, + idToken: String, + deviceId: String, + record: ShelfRecord, + refs: List, + isDeleted: Boolean = false + ) { + if (record.isSmart) return + firestoreRepository.syncShelf( + userId = userId, + shelf = DesktopCloudShelfMetadata( + name = record.name, + bookIds = refs.filter { it.shelfId == record.id }.map { it.bookId }.distinct(), + lastModifiedTimestamp = System.currentTimeMillis(), + isDeleted = isDeleted + ), + originDeviceId = deviceId, + idToken = idToken + ) + } + + suspend fun clearCloudData(userId: String, idToken: String, accessToken: String) { + driveRepository.deleteAllFiles(accessToken) + firestoreRepository.deleteAllUserFirestoreData(userId, idToken) + } + + suspend fun deleteFontFromCloud( + userId: String, + idToken: String, + accessToken: String, + font: CustomFontItem + ) { + val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } + driveFiles[font.fileName]?.id?.let { driveRepository.deleteDriveFile(accessToken, it) } + firestoreRepository.deleteFontMetadata(userId, font.id, idToken) + } + + private suspend fun downloadAnnotations(accessToken: String, book: BookItem, timestamp: Long): Boolean { + val temp = File(desktopUserCacheRoot(), "temp_download_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json") + return try { + logDesktopCloudAnnotations { + "desktop.download.start book=${book.id} remoteTs=$timestamp temp=${temp.name} " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + logDesktopCloudSync { "desktop.sidecar_download.start book=${book.id} remoteTs=$timestamp temp=${temp.name}" } + if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) { + logDesktopCloudAnnotations { + "desktop.download.missing book=${book.id} remoteTs=$timestamp tempExists=${temp.exists()} tempBytes=${temp.length()}" + } + logDesktopCloudSync { "desktop.sidecar_download.missing book=${book.id} remoteTs=$timestamp" } + return false + } + val raw = temp.readText() + logDesktopCloudAnnotations { + "desktop.download.success book=${book.id} remoteTs=$timestamp bytes=${raw.length}" + } + val appliedTimestamp = timestamp.takeIf { it > 0L } ?: temp.lastModified().takeIf { it > 0L } ?: 0L + val applied = DesktopCloudSidecarSync.importAnnotationBundle(book, raw, appliedTimestamp) + logDesktopCloudAnnotations { + "desktop.download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp applied=$applied " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(book) + } + logDesktopCloudSync { + "desktop.sidecar_download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp bytes=${temp.length()} applied=$applied" + } + applied + } finally { + temp.delete() + } + } + + private suspend fun downloadRemoteBook( + accessToken: String, + remote: DesktopCloudBookMetadata, + existing: BookItem?, + driveFiles: Map + ): BookItem? { + val type = remote.fileType() + val driveName = desktopCloudBookDriveFileName(remote.bookId, type) ?: return null + val driveFile = driveFiles[driveName] ?: return null + val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null + val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension") + logDesktopCloudSync { "desktop.content_download.start book=${remote.bookId} driveName=$driveName remoteContentTs=${remote.fileContentModifiedTimestamp}" } + if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) { + destination.delete() + logDesktopCloudSync { "desktop.content_download.failed book=${remote.bookId} driveName=$driveName" } + return null + } + val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified() + if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp) + val downloaded = remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy( + fileSize = destination.length(), + fileContentModifiedTimestamp = contentTimestamp + ) + logDesktopCloudSync { + "desktop.content_download.success book=${remote.bookId} bytes=${destination.length()} contentTs=$contentTimestamp " + + downloaded.desktopCloudSyncSummary("downloaded") + } + return downloaded + } + + private suspend fun syncFonts( + userId: String, + idToken: String, + accessToken: String, + localFonts: List, + remoteFonts: List + ): List { + val localFontsMap = localFonts.associateBy { it.id } + val remoteFontsMap = remoteFonts.associateBy { it.id } + val driveFiles = driveRepository.getFiles(accessToken).associateBy { it.name } + val nextFonts = localFonts.toMutableList() + + (localFontsMap.keys + remoteFontsMap.keys).forEach { fontId -> + val local = localFontsMap[fontId] + val remote = remoteFontsMap[fontId] + when { + local != null && remote == null -> { + firestoreRepository.syncFontMetadata(userId, local.toDesktopCloudFontMetadata(), idToken) + } + + local == null && remote != null && !remote.isDeleted -> { + val target = customFontStore.getFontFile(remote.fileName) + driveFiles[remote.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, target) } + nextFonts += customFontStore.syncedFontItem(remote) + } + + local != null && remote != null -> { + when { + local.isDeleted && !remote.isDeleted -> { + firestoreRepository.syncFontMetadata(userId, remote.copy(isDeleted = true), idToken) + } + + !local.isDeleted && remote.isDeleted -> { + customFontStore.deleteFont(local) + nextFonts.removeAll { it.id == local.id } + } + } + } + } + } + + nextFonts.toList().forEach { font -> + val localFile = File(font.path) + if (!font.isDeleted && localFile.isFile && driveFiles[font.fileName] == null) { + driveRepository.uploadFont(accessToken, font.fileName, localFile, font.fileExtension) + } else if (!font.isDeleted && !localFile.isFile) { + driveFiles[font.fileName]?.id?.let { driveRepository.downloadFile(accessToken, it, localFile) } + } + } + return nextFonts.distinctBy { it.id } + } + + private suspend fun syncShelves( + userId: String, + idToken: String, + deviceId: String, + shelfRecords: List, + shelfRefs: List, + syncableBookIds: Set, + remoteShelves: List + ): ShelfSyncResult { + val localShelves = shelfRecords + .filterNot { it.isSmart } + .map { record -> + DesktopCloudShelfRecord( + record = record, + metadata = DesktopCloudShelfMetadata( + name = record.name, + bookIds = shelfRefs.filter { it.shelfId == record.id } + .map { it.bookId } + .filter { it in syncableBookIds } + .distinct(), + lastModifiedTimestamp = desktopShelfTimestamp(record, shelfRefs), + isDeleted = false + ) + ) + } + val localShelvesByName = localShelves.associateBy { it.metadata.name } + val remoteShelvesByName = remoteShelves.associateBy { it.name } + var records = shelfRecords + var refs = shelfRefs + + (localShelvesByName.keys + remoteShelvesByName.keys).forEach { shelfName -> + val local = localShelvesByName[shelfName] + val remote = remoteShelvesByName[shelfName] + when { + local != null && remote == null -> { + firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) + } + + local == null && remote != null -> { + if (!remote.isDeleted) { + val record = ShelfRecord(id = "shelf_${remote.lastModifiedTimestamp}_${shelfName.hashCode()}", name = remote.name) + records += record + refs = refs.filterNot { it.shelfId == record.id } + + remote.bookIds.filter { it in syncableBookIds }.map { bookId -> + BookShelfRef(bookId, record.id, remote.lastModifiedTimestamp) + } + } + } + + local != null && remote != null -> { + if (local.metadata.lastModifiedTimestamp > remote.lastModifiedTimestamp) { + firestoreRepository.syncShelf(userId, local.metadata, deviceId, idToken) + } else if (remote.lastModifiedTimestamp > local.metadata.lastModifiedTimestamp) { + if (remote.isDeleted) { + records = records.filterNot { it.id == local.record.id } + refs = refs.filterNot { it.shelfId == local.record.id } + } else { + refs = refs.filterNot { it.shelfId == local.record.id } + + remote.bookIds.filter { it in syncableBookIds }.map { bookId -> + BookShelfRef(bookId, local.record.id, remote.lastModifiedTimestamp) + } + } + } + } + } + } + return ShelfSyncResult(records, refs) + } +} + +internal fun BookItem.toDesktopCloudBookMetadata( + hasAnnotations: Boolean, + timestamp: Long = this.timestamp, + annotationModifiedTimestamp: Long = 0L, + contentTimestampOverride: Long? = null +): DesktopCloudBookMetadata { + val position = readerPosition.takeIf { type.usesCloudLocatorMetadata() } + val supportsReaderAnnotations = type.usesCloudLocatorMetadata() + val bookmarksJson = desktopPdfBookmarksMetadataJson(this) + ?: if (supportsReaderAnnotations) { + readerBookmarks + .mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() } + .let(EpubAnnotationSerializer::bookmarksToJson) + } else { + null + } + val highlightsJson = if (supportsReaderAnnotations) { + EpubAnnotationSerializer.highlightsToJson(readerHighlights) + } else { + null + } + val localFile = path?.let(::File) + val contentTimestamp = contentTimestampOverride + ?: fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L + return DesktopCloudBookMetadata( + bookId = id, + title = title, + author = author, + displayName = displayName, + type = type.name, + lastPositionCfi = position?.cloudPositionCfi(), + lastChapterIndex = position?.chapterIndex, + locatorBlockIndex = position?.blockIndex, + locatorCharOffset = position?.charOffset, + lastPage = if (type.usesCloudLocatorMetadata()) position?.pageIndex ?: lastPageIndex else lastPageIndex, + progressPercentage = progressPercentage, + isRecent = isRecent, + isDeleted = false, + lastModifiedTimestamp = timestamp, + readingPositionModifiedTimestamp = effectiveCloudReadingPositionModifiedTimestamp(), + annotationModifiedTimestamp = annotationModifiedTimestamp, + bookmarksJson = bookmarksJson, + hasAnnotations = hasAnnotations, + fileContentModifiedTimestamp = contentTimestamp, + customName = null, + highlightsJson = highlightsJson, + seriesName = seriesName, + seriesIndex = seriesIndex, + description = description, + originalTitle = originalTitle ?: title, + originalAuthor = originalAuthor ?: author, + originalSeriesName = originalSeriesName ?: seriesName, + originalSeriesIndex = originalSeriesIndex ?: seriesIndex, + originalDescription = originalDescription ?: description + ) +} + +internal fun DesktopCloudBookMetadata.toDesktopBookItem( + existing: BookItem? = null, + downloadedPath: String? = null +): BookItem { + val type = fileType() + val pageIndex = lastPage + val locator = if (type.usesCloudLocatorMetadata()) { + ReaderLocator.fromLegacy( + chapterIndex = lastChapterIndex, + cfi = lastPositionCfi, + pageIndex = pageIndex + ).withFallbacks( + blockIndex = locatorBlockIndex, + charOffset = locatorCharOffset + ) + } else { + null + } + val remoteReadingTimestamp = effectiveCloudReadingPositionModifiedTimestamp() + val localReadingTimestamp = existing?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L + val useRemoteReadingPosition = existing == null || + remoteReadingTimestamp > localReadingTimestamp || + (localReadingTimestamp == 0L && hasCloudReadingPosition()) + val restoredPageIndex = if (useRemoteReadingPosition) pageIndex ?: existing?.lastPageIndex else existing?.lastPageIndex + val restoredReaderPosition = if (type.usesCloudLocatorMetadata()) { + if (useRemoteReadingPosition) { + locator?.takeIf { + it.chapterIndex != null || + it.pageIndex != null || + it.cfi != null || + it.startOffset != null || + it.blockIndex != null + } ?: existing?.readerPosition + } else { + existing?.readerPosition + } + } else { + null + } + return BookItem( + id = bookId, + path = downloadedPath ?: existing?.path, + type = type, + displayName = displayName.ifBlank { existing?.displayName ?: bookId }, + timestamp = lastModifiedTimestamp, + coverImagePath = existing?.coverImagePath, + title = title ?: existing?.title, + author = author ?: existing?.author, + description = description ?: existing?.description, + originalTitle = originalTitle ?: existing?.originalTitle, + originalAuthor = originalAuthor ?: existing?.originalAuthor, + originalSeriesName = originalSeriesName ?: existing?.originalSeriesName, + originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex, + originalDescription = originalDescription ?: existing?.originalDescription, + progressPercentage = if (useRemoteReadingPosition) progressPercentage ?: existing?.progressPercentage else existing?.progressPercentage, + isRecent = isRecent, + fileSize = existing?.fileSize ?: 0L, + fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L } + ?: existing?.fileContentModifiedTimestamp + ?: 0L, + sourceFolder = null, + folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false, + seriesName = seriesName ?: existing?.seriesName, + seriesIndex = seriesIndex ?: existing?.seriesIndex, + tags = existing?.tags.orEmpty(), + lastPageIndex = restoredPageIndex, + readerPosition = restoredReaderPosition, + readerSettings = existing?.readerSettings, + readerBookmarks = if (type == FileType.PDF || bookmarksJson.isNullOrBlank()) { + existing?.readerBookmarks.orEmpty() + } else { + EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark -> + ReaderBookmark( + id = "${bookmark.chapterIndex}:${bookmark.cfi}", + pageIndex = bookmark.pageInChapter?.minus(1) ?: bookmark.locator.pageIndex ?: 0, + chapterTitle = bookmark.chapterTitle, + preview = bookmark.snippet, + locator = bookmark.locator + ) + } + }, + readerHighlights = if (highlightsJson.isNullOrBlank()) { + existing?.readerHighlights.orEmpty() + } else { + EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) + }, + pdfReaderViewport = if (useRemoteReadingPosition) remotePdfViewport(existing, pageIndex) else existing?.pdfReaderViewport, + readingPositionModifiedTimestamp = if (useRemoteReadingPosition) remoteReadingTimestamp else localReadingTimestamp + ) +} + +internal fun BookItem.withCloudReadingPosition(remote: DesktopCloudBookMetadata): BookItem { + val remoteType = remote.fileType() + val pageIndex = remote.lastPage + val locator = if (remoteType.usesCloudLocatorMetadata()) { + ReaderLocator.fromLegacy( + chapterIndex = remote.lastChapterIndex, + cfi = remote.lastPositionCfi, + pageIndex = pageIndex + ).withFallbacks( + blockIndex = remote.locatorBlockIndex, + charOffset = remote.locatorCharOffset + ).takeIf { + it.chapterIndex != null || + it.pageIndex != null || + it.cfi != null || + it.startOffset != null || + it.blockIndex != null + } + } else { + null + } + return copy( + lastPageIndex = pageIndex ?: lastPageIndex, + readerPosition = if (remoteType.usesCloudLocatorMetadata()) locator ?: readerPosition else null, + progressPercentage = remote.progressPercentage ?: progressPercentage, + pdfReaderViewport = if (remoteType.usesCloudLocatorMetadata()) { + pdfReaderViewport + } else { + remote.remotePdfViewport(this, pageIndex) + }, + readingPositionModifiedTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp() + ) +} + +private fun DesktopCloudBookMetadata.remotePdfViewport( + existing: BookItem?, + pageIndex: Int? +): SharedPdfReaderViewport? { + if (fileType().usesCloudLocatorMetadata() || pageIndex == null) return existing?.pdfReaderViewport + val base = existing?.pdfReaderViewport ?: SharedPdfReaderViewport() + return base.copy( + pageIndex = pageIndex, + horizontalScrollOffset = 0, + paginatedVerticalScrollOffset = 0, + verticalFirstPageIndex = pageIndex, + verticalFirstPageScrollOffset = 0 + ) +} + +private fun FileType.usesCloudLocatorMetadata(): Boolean { + return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) +} + +internal fun BookItem.hasCloudReadingPosition(): Boolean { + return lastPageIndex != null || + readerPosition != null || + (progressPercentage ?: 0f) > 0f +} + +internal fun BookItem.effectiveCloudReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: timestamp.takeIf { hasCloudReadingPosition() } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.hasCloudReadingPosition(): Boolean { + return lastChapterIndex != null || + lastPage != null || + !lastPositionCfi.isNullOrBlank() || + locatorBlockIndex != null || + locatorCharOffset != null || + (progressPercentage ?: 0f) > 0f +} + +internal fun DesktopCloudBookMetadata.effectiveCloudReadingPositionModifiedTimestamp(): Long { + return readingPositionModifiedTimestamp.takeIf { it > 0L } + ?: lastModifiedTimestamp.takeIf { hasCloudReadingPosition() } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(): Long { + return annotationModifiedTimestamp.takeIf { it > 0L } + ?: 0L +} + +internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long): Long { + return sidecarModifiedTimestamp.takeIf { it > 0L } + ?: effectiveCloudAnnotationModifiedTimestamp() +} + +internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata { + return DesktopCloudFontMetadata( + id = id, + displayName = displayName, + fileName = fileName, + fileExtension = fileExtension, + timestamp = timestamp, + isDeleted = isDeleted + ) +} + +internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? { + return sharedCloudBookContentFileName(bookId, type) +} + +private data class DesktopCloudShelfRecord( + val record: ShelfRecord, + val metadata: DesktopCloudShelfMetadata +) + +private data class ShelfSyncResult( + val records: List, + val refs: List +) + +private fun DesktopCloudBookMetadata.fileType(): FileType { + return runCatching { FileType.valueOf(type) }.getOrDefault(FileType.EPUB) +} + +private fun SharedReaderScreenState.upsertCloudBook(book: BookItem): SharedReaderScreenState { + val existing = rawLibraryBooks.any { it.id == book.id } + val nextBooks = if (existing) { + rawLibraryBooks.map { if (it.id == book.id) book else it } + } else { + listOf(book) + rawLibraryBooks + } + return copy(rawLibraryBooks = nextBooks) +} + +private fun SharedReaderScreenState.removeCloudBook(bookId: String): SharedReaderScreenState { + return copy( + rawLibraryBooks = rawLibraryBooks.filterNot { it.id == bookId }, + selectedBookIds = selectedBookIds - bookId, + pinnedHomeBookIds = pinnedHomeBookIds - bookId, + pinnedLibraryBookIds = pinnedLibraryBookIds - bookId, + openTabIds = openTabIds.filterNot { it == bookId }, + activeTabBookId = activeTabBookId?.takeUnless { it == bookId } + ) +} + +private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopCloudBookMetadata): Boolean { + val localFile = local.path?.let(::File) + val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L + return local.sourceFolder == null && + remote.fileType() == local.type && + shouldDownloadRemoteCloudBookContent( + localFileAvailable = localFile?.isFile == true, + localContentModifiedTimestamp = localTimestamp, + remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp, + remoteDeleted = remote.isDeleted + ) +} + +private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean { + val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false + val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified() + return local.sourceFolder == null && + shouldUploadLocalCloudBookContent( + localFileAvailable = true, + localContentModifiedTimestamp = localTimestamp, + remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp + ) +} + +private fun shouldUploadLocalAnnotations( + local: BookItem, + remote: DesktopCloudBookMetadata?, + remoteAnnotationModifiedTimestamp: Long = remote?.effectiveCloudAnnotationModifiedTimestamp() ?: 0L, + localSidecarTimestamp: Long = DesktopCloudSidecarSync.localAnnotationTimestamp(local) +): Boolean { + return DesktopCloudSidecarSync.hasLocalAnnotationData(local) && + (remote == null || !remote.hasAnnotations || localSidecarTimestamp > remoteAnnotationModifiedTimestamp) +} + +private fun remoteAnnotationDriveFileTimestamp( + bookId: String, + driveFiles: Map +): Long { + return driveFiles[desktopCloudAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L +} + +internal fun desktopCloudAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json" + +private fun BookItem.withDownloadedCloudContent(downloaded: BookItem?, replacePath: Boolean = true): BookItem { + if (downloaded == null) return this + return copy( + path = if (replacePath) downloaded.path ?: path else path, + fileSize = downloaded.fileSize.takeIf { it > 0L } ?: fileSize, + fileContentModifiedTimestamp = downloaded.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: fileContentModifiedTimestamp + ) +} + +private fun desktopShelfTimestamp(record: ShelfRecord, refs: List): Long { + val idTimestamp = record.id.split('_').firstNotNullOfOrNull { it.toLongOrNull() } + val refsTimestamp = refs.filter { it.shelfId == record.id }.maxOfOrNull { it.addedAt } + return maxOf(idTimestamp ?: 0L, refsTimestamp ?: 0L) +} + +private fun ReaderLocator.cloudPositionCfi(): String? { + return toStablePositionCfi() +} + +private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? { + val chapterIndex = locator.chapterIndex ?: 0 + val cfi = locator.cloudPositionCfi() ?: "desktop:$chapterIndex:$pageIndex" + return EpubBookmark( + cfi = cfi, + chapterTitle = chapterTitle, + label = null, + snippet = preview, + pageInChapter = pageIndex + 1, + totalPagesInChapter = null, + chapterIndex = chapterIndex, + locator = locator.withFallbacks( + chapterIndex = chapterIndex, + cfi = cfi, + pageIndex = pageIndex, + textQuote = preview + ) + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt new file mode 100644 index 0000000..4ac30f5 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncDiagnostics.kt @@ -0,0 +1,82 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedFileCapabilities + +internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync" +internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations" + +internal fun logDesktopCloudSync(message: () -> String) { + logDesktopDiagnostic(DesktopCloudSyncLogTag, message) +} + +internal fun logDesktopCloudAnnotations(message: () -> String) { + logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message) +} + +internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String { + val position = readerPosition + val page = if (type.usesCloudLocatorForDiagnostics()) { + position?.pageIndex ?: lastPageIndex + } else { + lastPageIndex + } + return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + + "contentTs=$fileContentModifiedTimestamp " + + "page=$page chapter=${position?.chapterIndex} " + + "block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " + + "cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " + + "bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}" +} + +internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String { + return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " + + "annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " + + "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " + + "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " + + "recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " + + "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}" +} + +internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean { + val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex + val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) { + other.readerPosition?.pageIndex ?: other.lastPageIndex + } else { + other.lastPageIndex + } + val thisProgress = progressPercentage + val otherProgress = other.progressPercentage + val progressMatches = when { + thisProgress == null && otherProgress == null -> true + thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f + else -> false + } + val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) { + readerPosition == other.readerPosition + } else { + true + } + return thisPage == otherPage && + locatorMatches && + progressMatches +} + +private fun org.dueattendant149.bookreader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean { + return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this) +} + +private fun String?.cloudSyncPreview(maxLength: Int = 80): String { + val value = this ?: return "null" + return if (value.length <= maxLength) value else value.take(maxLength) + "..." +} + +private fun String?.cloudSyncAnnotationSummary(): String { + val value = this?.trim() ?: return "null" + return when { + value.isEmpty() -> "blank" + value == "[]" -> "empty" + else -> "present(${value.length})" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt new file mode 100644 index 0000000..0b2b0e9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncSettingsStore.kt @@ -0,0 +1,60 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.util.Properties +import java.util.UUID + +internal data class DesktopCloudSyncSettings( + val isSyncEnabled: Boolean = false, + val isFolderSyncEnabled: Boolean = false +) + +internal class DesktopCloudSyncSettingsStore( + private val settingsFile: File = File(desktopUserConfigRoot(), "cloud-sync.properties") +) { + fun load(): DesktopCloudSyncSettings { + if (!settingsFile.isFile) return DesktopCloudSyncSettings() + val properties = Properties() + return runCatching { + settingsFile.inputStream().use(properties::load) + DesktopCloudSyncSettings( + isSyncEnabled = properties.getProperty("syncEnabled", "false").toBooleanStrictOrNull() == true, + isFolderSyncEnabled = properties.getProperty("folderSyncEnabled", "false").toBooleanStrictOrNull() == true + ) + }.getOrDefault(DesktopCloudSyncSettings()) + } + + fun save(settings: DesktopCloudSyncSettings) { + settingsFile.parentFile?.mkdirs() + val properties = Properties().apply { + setProperty("syncEnabled", settings.isSyncEnabled.toString()) + setProperty("folderSyncEnabled", settings.isFolderSyncEnabled.toString()) + } + settingsFile.outputStream().use { output -> + properties.store(output, "Episteme desktop cloud sync") + } + } +} + +internal class DesktopInstallationIdStore( + private val settingsFile: File = File(desktopUserConfigRoot(), "installation.properties") +) { + fun getOrCreateId(): String { + val properties = Properties() + val existing = runCatching { + if (!settingsFile.isFile) return@runCatching null + settingsFile.inputStream().use(properties::load) + properties.getProperty("installationId")?.takeIf { it.isNotBlank() } + }.getOrNull() + if (existing != null) return existing + + val generated = UUID.randomUUID().toString() + settingsFile.parentFile?.mkdirs() + settingsFile.outputStream().use { output -> + Properties().apply { + setProperty("installationId", generated) + }.store(output, "Episteme desktop installation") + } + return generated + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt new file mode 100644 index 0000000..ab7ad33 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchive.kt @@ -0,0 +1,614 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.ptr.PointerByReference +import org.apache.commons.compress.archivers.sevenz.SevenZFile +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import java.awt.Color +import java.awt.Font +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.OutputStream +import java.net.URL +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile +import javax.imageio.ImageIO +import kotlin.math.roundToInt + +internal object DesktopComicArchive { + private val comicTypes = SharedFileCapabilities.comicArchiveTypes + private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") + + fun canLoad(type: FileType): Boolean = type in comicTypes + + fun load(file: File, type: FileType): DesktopComicDocument { + require(file.isFile) { "Missing comic archive: ${file.absolutePath}" } + require(canLoad(type)) { "${type.name} is not a comic archive type." } + return when (type) { + FileType.CBZ -> loadZip(file) + FileType.CBR -> loadRar(file) + FileType.CB7 -> loadSevenZ(file) + FileType.CBT -> loadTar(file) + else -> error("${type.name} is not a comic archive type.") + } + } + + fun loadOpdsStream( + path: String, + title: String, + reference: OpdsStreamReference, + catalog: OpdsCatalog? + ): DesktopComicDocument { + val cacheDir = File( + DesktopLibraryDatabase.defaultDatabaseFile().parentFile, + "opds_stream_cache/${reference.id.hashCode()}" + ).apply { mkdirs() } + val pages = (0 until reference.count).map { pageIndex -> + DesktopComicPage( + name = "opds_${pageIndex + 1}.jpg", + width = 800, + height = 1200, + source = OpdsStreamComicPageSource( + pageIndex = pageIndex, + urlTemplate = reference.urlTemplate, + catalog = catalog, + cacheDir = cacheDir + ) + ) + } + return DesktopComicDocument( + path = path, + title = title, + pages = pages, + closeAction = {} + ) + } + + private fun loadZip(file: File): DesktopComicDocument { + val zip = ZipFile(file) + return try { + val pages = zip.entries() + .asSequence() + .filter { entry -> !entry.isDirectory && entry.name.isComicImageName() } + .sortedBy { entry -> entry.name.comicSortKey() } + .mapNotNull { entry -> + val bytes = zip.getInputStream(entry).use { it.readBytes() } + val size = decodeImageSize(bytes) ?: return@mapNotNull null + DesktopComicPage( + name = entry.name, + width = size.first, + height = size.second, + source = ZipComicPageSource(zip, entry.name) + ) + } + .toList() + require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." } + DesktopComicDocument( + path = file.absolutePath, + title = file.nameWithoutExtension, + pages = pages, + closeAction = { zip.close() } + ) + } catch (throwable: Throwable) { + runCatching { zip.close() } + throw throwable + } + } + + private fun loadRar(file: File): DesktopComicDocument { + val nativeResult = runCatching { loadRarWithNativeLibarchive(file) } + if (nativeResult.isSuccess) return nativeResult.getOrThrow() + + return runCatching { loadWithArchiveCommand(file) } + .getOrElse { commandError -> + error( + "Could not open CBR with libarchive. " + + "Bundle libarchive for this desktop platform, or keep tar/bsdtar available on PATH. " + + "Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " + + "Command: ${commandError.shortMessage()}" + ) + } + } + + private fun loadRarWithNativeLibarchive(file: File): DesktopComicDocument { + val tempDir = Files.createTempDirectory("reader-comic-").toFile() + return try { + val extracted = DesktopLibarchive.extractImagePages(file, tempDir, imageExtensions) + documentFromExtracted(file, extracted, tempDir) + } catch (throwable: Throwable) { + runCatching { tempDir.deleteRecursively() } + throw throwable + } + } + + private fun loadSevenZ(file: File): DesktopComicDocument { + val tempDir = Files.createTempDirectory("reader-comic-").toFile() + val commonsResult = runCatching { + val extracted = mutableListOf() + @Suppress("DEPRECATION") + SevenZFile(file).use { archive -> + var entry = archive.nextEntry + while (entry != null) { + val name = entry.name.orEmpty() + if (!entry.isDirectory && name.isComicImageName()) { + val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}") + target.outputStream().use { output -> + archive.copyCurrentEntryTo(output) + } + extracted += ExtractedComicPage(name = name, file = target) + } + entry = archive.nextEntry + } + } + documentFromExtracted(file, extracted, tempDir) + } + if (commonsResult.isSuccess) return commonsResult.getOrThrow() + + runCatching { tempDir.deleteRecursively() } + return runCatching { loadWithArchiveCommand(file) } + .getOrElse { commandError -> + error( + "Could not open CB7 with Commons Compress or system tar/bsdtar. " + + "Commons: ${commonsResult.exceptionOrNull()?.shortMessage().orEmpty()} " + + "Command: ${commandError.shortMessage()}" + ) + } + } + + private fun loadTar(file: File): DesktopComicDocument { + val tempDir = Files.createTempDirectory("reader-comic-").toFile() + return try { + val extracted = mutableListOf() + TarArchiveInputStream(file.inputStream().buffered()).use { archive -> + var entry = archive.nextEntry + while (entry != null) { + val name = entry.name.orEmpty() + if (!entry.isDirectory && name.isComicImageName()) { + val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}") + target.outputStream().use { output -> + archive.copyTo(output) + } + extracted += ExtractedComicPage(name = name, file = target) + } + entry = archive.nextEntry + } + } + documentFromExtracted(file, extracted, tempDir) + } catch (throwable: Throwable) { + runCatching { tempDir.deleteRecursively() } + throw throwable + } + } + + private fun loadWithArchiveCommand(file: File): DesktopComicDocument { + val tempDir = Files.createTempDirectory("reader-comic-").toFile() + return try { + val extracted = DesktopArchiveCommand.extractImagePages(file, tempDir, imageExtensions) + documentFromExtracted(file, extracted, tempDir) + } catch (throwable: Throwable) { + runCatching { tempDir.deleteRecursively() } + throw throwable + } + } + + private fun documentFromExtracted( + file: File, + extracted: List, + tempDir: File + ): DesktopComicDocument { + val pages = extracted + .sortedBy { page -> page.name.comicSortKey() } + .mapNotNull { page -> + val bytes = page.file.readBytes() + val size = decodeImageSize(bytes) ?: return@mapNotNull null + DesktopComicPage( + name = page.name, + width = size.first, + height = size.second, + source = FileComicPageSource(page.file) + ) + } + require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." } + return DesktopComicDocument( + path = file.absolutePath, + title = file.nameWithoutExtension, + pages = pages, + closeAction = { tempDir.deleteRecursively() } + ) + } + + private fun SevenZFile.copyCurrentEntryTo(output: OutputStream) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = read(buffer, 0, buffer.size) + if (read < 0) break + if (read > 0) output.write(buffer, 0, read) + } + } + + private fun decodeImageSize(bytes: ByteArray): Pair? { + val image = ByteArrayInputStream(bytes).use { input -> + ImageIO.read(input) + } ?: return null + return image.width.coerceAtLeast(1) to image.height.coerceAtLeast(1) + } + + internal fun String.isComicImageName(): Boolean { + return imageExtension() in imageExtensions + } + + internal fun String.imageExtension(): String { + return substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "img") + .lowercase() + .takeIf { it in imageExtensions } + ?: "img" + } + + internal fun String.comicSortKey(): String { + return replace('\\', '/').lowercase() + } + + internal fun Throwable.shortMessage(): String { + return message + ?.replace(Regex("\\s+"), " ") + ?.take(240) + ?.ifBlank { null } + ?: javaClass.simpleName + } + + internal data class ExtractedComicPage( + val name: String, + val file: File + ) +} + +internal class DesktopComicDocument( + val path: String, + val title: String, + pages: List, + private val closeAction: () -> Unit +) { + private val pages = pages.toList() + + val pageCount: Int = pages.size + val pageSizes: List = pages.map { page -> + DesktopPdfPageSize(page.width.toFloat(), page.height.toFloat()) + } + + fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage { + val page = pages.getOrNull(pageIndex) ?: error("Invalid comic page index $pageIndex.") + val sourceImage = ByteArrayInputStream(page.source.readBytes()).use { input -> + ImageIO.read(input) + } ?: error("Could not decode comic page ${pageIndex + 1}.") + val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f + val sourceWidth = sourceImage.width.coerceAtLeast(1) + val sourceHeight = sourceImage.height.coerceAtLeast(1) + val width = (sourceWidth * safeScale).roundToInt().coerceAtLeast(1) + val height = (sourceHeight * safeScale).roundToInt().coerceAtLeast(1) + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + graphics.color = Color.WHITE + graphics.fillRect(0, 0, width, height) + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + graphics.drawImage(sourceImage, 0, 0, width, height, null) + } finally { + graphics.dispose() + sourceImage.flush() + } + return image + } + + fun close() { + closeAction() + } +} + +internal data class DesktopComicPage( + val name: String, + val width: Int, + val height: Int, + val source: ComicPageSource +) + +internal interface ComicPageSource { + fun readBytes(): ByteArray +} + +private class ZipComicPageSource( + private val zip: ZipFile, + private val entryName: String +) : ComicPageSource { + override fun readBytes(): ByteArray { + val entry = zip.getEntry(entryName) ?: error("Missing comic page entry: $entryName") + return zip.getInputStream(entry).use { it.readBytes() } + } +} + +private class FileComicPageSource( + private val file: File +) : ComicPageSource { + override fun readBytes(): ByteArray = file.readBytes() +} + +private class OpdsStreamComicPageSource( + private val pageIndex: Int, + private val urlTemplate: String, + private val catalog: OpdsCatalog?, + private val cacheDir: File +) : ComicPageSource { + override fun readBytes(): ByteArray { + val cachedFile = File(cacheDir, "page_$pageIndex.jpg") + if (cachedFile.isFile && cachedFile.length() > 0L) { + return cachedFile.readBytes() + } + + return runCatching { + val bytes = DesktopOpdsHttp.fetchBytes(streamPageUrl(), catalog) + if (bytes.isNotEmpty()) { + cachedFile.writeBytes(bytes) + bytes + } else { + error("Empty OPDS stream page response.") + } + }.getOrElse { + errorPageBytes() + } + } + + private fun streamPageUrl(): String { + return effectiveTemplate() + .replace("{pageNumber}", pageIndex.toString()) + .replace("{page}", pageIndex.toString()) + .replace("{maxWidth}", "1600") + .replace("{maxHeight}", "2400") + } + + private fun effectiveTemplate(): String { + val catalogUrl = catalog?.url ?: return urlTemplate + if (!urlTemplate.startsWith("http", ignoreCase = true)) return urlTemplate + return runCatching { + val oldUrl = URL(urlTemplate) + val newUrl = URL(catalogUrl) + val oldBase = "${oldUrl.protocol}://${oldUrl.authority}" + val newBase = "${newUrl.protocol}://${newUrl.authority}" + urlTemplate.replace(oldBase, newBase) + }.getOrDefault(urlTemplate) + } + + private fun errorPageBytes(): ByteArray { + val image = BufferedImage(800, 1200, BufferedImage.TYPE_INT_RGB) + val graphics = image.createGraphics() + try { + graphics.color = Color.DARK_GRAY + graphics.fillRect(0, 0, image.width, image.height) + graphics.color = Color.WHITE + graphics.font = Font(Font.SANS_SERIF, Font.BOLD, 36) + val text = "Page unavailable" + val metrics = graphics.fontMetrics + graphics.drawString(text, (image.width - metrics.stringWidth(text)) / 2, image.height / 2) + } finally { + graphics.dispose() + } + return ByteArrayOutputStream().use { output -> + ImageIO.write(image, "jpg", output) + output.toByteArray() + } + } +} + +private object DesktopArchiveCommand { + private const val EXTRACT_TIMEOUT_SECONDS = 60L + + fun extractImagePages( + file: File, + tempDir: File, + imageExtensions: Set + ): List { + val command = resolveCommand() + ?: error("No tar/bsdtar command was found on PATH.") + val names = listArchiveEntries(command, file) + .filter { name -> + val extension = name.substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + extension in imageExtensions + } + .sortedBy { name -> with(DesktopComicArchive) { name.comicSortKey() } } + require(names.isNotEmpty()) { "No readable image pages were found in ${file.name}." } + return names.mapIndexed { index, name -> + val extension = name.substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "img") + .lowercase() + .ifBlank { "img" } + val target = File(tempDir, "page_$index.$extension") + extractEntry(command, file, name, target) + DesktopComicArchive.ExtractedComicPage(name = name, file = target) + } + } + + private fun resolveCommand(): String? { + val override = System.getProperty("reader.archive.command") + ?: System.getenv("READER_ARCHIVE_COMMAND") + return listOfNotNull(override?.takeIf { it.isNotBlank() }, "bsdtar", "tar") + .firstOrNull(::isCommandAvailable) + } + + private fun isCommandAvailable(command: String): Boolean { + return runCatching { + val process = ProcessBuilder(command, "--version") + .redirectErrorStream(true) + .start() + process.inputStream.use { it.readBytes() } + process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0 + }.getOrDefault(false) + } + + private fun listArchiveEntries(command: String, file: File): List { + val process = ProcessBuilder(command, "-tf", file.absolutePath) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + error("$command timed out while listing ${file.name}.") + } + if (process.exitValue() != 0) { + error(output.ifBlank { "$command could not list ${file.name}." }) + } + return output + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .toList() + } + + private fun extractEntry(command: String, file: File, entryName: String, target: File) { + val process = ProcessBuilder(command, "-xOf", file.absolutePath, entryName) + .start() + val errorText = StringBuilder() + val errorThread = Thread { + process.errorStream.bufferedReader(Charsets.UTF_8).use { reader -> + errorText.append(reader.readText()) + } + }.apply { + isDaemon = true + start() + } + process.inputStream.use { input -> + target.outputStream().use { output -> + input.copyTo(output) + } + } + val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + error("$command timed out while extracting $entryName.") + } + errorThread.join(1000) + if (process.exitValue() != 0) { + error(errorText.toString().ifBlank { "$command could not extract $entryName." }) + } + } +} + +private object DesktopLibarchive { + private const val ARCHIVE_OK = 0 + private const val ARCHIVE_EOF = 1 + private const val ARCHIVE_ENTRY_DIRECTORY = 0x4000 + private const val BUFFER_SIZE = 128 * 1024 + + fun extractImagePages( + file: File, + tempDir: File, + imageExtensions: Set + ): List { + val archive = api.archive_read_new() + ?: error("libarchive could not allocate a reader.") + try { + checkArchive(api.archive_read_support_filter_all(archive), archive, "enable archive filters") + checkArchive(api.archive_read_support_format_all(archive), archive, "enable archive formats") + checkArchive( + api.archive_read_open_filename(archive, file.absolutePath, BUFFER_SIZE.toLong()), + archive, + "open ${file.name}" + ) + + val pages = mutableListOf() + val entryRef = PointerByReference() + while (true) { + when (val status = api.archive_read_next_header(archive, entryRef)) { + ARCHIVE_OK -> Unit + ARCHIVE_EOF -> break + else -> checkArchive(status, archive, "read archive header") + } + + val entry = entryRef.value ?: continue + val name = api.archive_entry_pathname_utf8(entry) + ?: api.archive_entry_pathname(entry) + ?: continue + val extension = name.substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + if (api.archive_entry_filetype(entry) == ARCHIVE_ENTRY_DIRECTORY || extension !in imageExtensions) { + api.archive_read_data_skip(archive) + continue + } + + val target = File(tempDir, "page_${pages.size}.${extension.ifBlank { "img" }}") + target.outputStream().use { output -> + val buffer = ByteArray(BUFFER_SIZE) + while (true) { + val read = api.archive_read_data(archive, buffer, buffer.size.toLong()) + when { + read > 0 -> output.write(buffer, 0, read.toInt()) + read == 0L -> break + else -> checkArchive(read.toInt(), archive, "extract $name") + } + } + } + pages += DesktopComicArchive.ExtractedComicPage(name = name, file = target) + } + return pages.sortedBy { page -> with(DesktopComicArchive) { page.name.comicSortKey() } } + } finally { + api.archive_read_free(archive) + } + } + + private val api: LibarchiveLibrary by lazy { + val overridePath = System.getProperty("reader.libarchive.path") + ?: System.getenv("READER_LIBARCHIVE_PATH") + val candidates = if (overridePath.isNullOrBlank()) { + listOf("archive", "libarchive", "libarchive-13", "libarchive-14") + } else { + listOf(File(overridePath).absolutePath, overridePath) + } + + candidates.firstNotNullOfOrNull { candidate -> + runCatching { Native.load(candidate, LibarchiveLibrary::class.java) } + .getOrNull() + } ?: error( + "Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " + + "or bundle libarchive for this platform." + ) + } + + private fun checkArchive(status: Int, archive: Pointer, action: String) { + if (status >= ARCHIVE_OK) return + val message = api.archive_error_string(archive).orEmpty() + error("libarchive could not $action: ${message.ifBlank { "error code $status" }}") + } + + @Suppress("FunctionName") + private interface LibarchiveLibrary : Library { + fun archive_read_new(): Pointer? + fun archive_read_support_filter_all(archive: Pointer): Int + fun archive_read_support_format_all(archive: Pointer): Int + fun archive_read_open_filename(archive: Pointer, fileName: String, blockSize: Long): Int + fun archive_read_next_header(archive: Pointer, entry: PointerByReference): Int + fun archive_read_data(archive: Pointer, buffer: ByteArray, size: Long): Long + fun archive_read_data_skip(archive: Pointer): Int + fun archive_read_free(archive: Pointer): Int + fun archive_error_string(archive: Pointer): String? + fun archive_entry_pathname_utf8(entry: Pointer): String? + fun archive_entry_pathname(entry: Pointer): String? + fun archive_entry_filetype(entry: Pointer): Int + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt new file mode 100644 index 0000000..54a86b9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStore.kt @@ -0,0 +1,162 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.CustomFontItem +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.net.HttpURLConnection +import java.net.URLEncoder +import java.net.URL +import java.util.UUID + +class DesktopCustomFontStore( + private val fontsDir: File = defaultFontsDir(), + private val googleFontsDownloadAvailable: () -> Boolean = { true } +) { + private var googleFontsCache: List? = null + + fun importFont(source: File, displayNameOverride: String? = null): Result { + if (!source.isFile) { + return Result.failure(IllegalArgumentException("Choose a font file.")) + } + val extension = source.extension.lowercase() + if (extension !in SupportedFontExtensions) { + return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2.")) + } + + return runCatching { + fontsDir.mkdirs() + val fontId = UUID.randomUUID().toString() + val fileName = "font_$fontId.$extension" + val destination = File(fontsDir, fileName) + source.inputStream().use { input -> + destination.outputStream().use { output -> input.copyTo(output) } + } + CustomFontItem( + id = fontId, + displayName = displayNameOverride?.takeIf { it.isNotBlank() } + ?: source.nameWithoutExtension.ifBlank { "Imported font" }, + fileName = fileName, + fileExtension = extension, + path = destination.absolutePath, + timestamp = System.currentTimeMillis() + ) + } + } + + fun deleteFont(font: CustomFontItem): Boolean { + val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false + val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false + val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root } + if (!insideFontStore) return false + return !target.exists() || target.delete() + } + + fun getFontFile(fileName: String): File { + fontsDir.mkdirs() + return File(fontsDir, fileName) + } + + internal fun syncedFontItem(metadata: DesktopCloudFontMetadata): CustomFontItem { + return CustomFontItem( + id = metadata.id, + displayName = metadata.displayName, + fileName = metadata.fileName, + fileExtension = metadata.fileExtension, + path = getFontFile(metadata.fileName).absolutePath, + timestamp = metadata.timestamp, + isDeleted = metadata.isDeleted + ) + } + + fun loadGoogleFontsList(): List { + googleFontsCache?.let { return it } + val loaded = runCatching { + val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource) + ?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource) + ?: return@runCatching emptyList() + stream.bufferedReader(Charsets.UTF_8).use { reader -> + googleFontsFromJson(reader.readText()) + } + }.getOrDefault(emptyList()) + googleFontsCache = loaded + return loaded + } + + fun downloadGoogleFont(fontName: String): Result { + if (!googleFontsDownloadAvailable()) { + return Result.failure(IllegalStateException("Google Fonts download is unavailable in this desktop build.")) + } + val normalizedFontName = fontName.trim() + if (normalizedFontName.isBlank()) { + return Result.failure(IllegalArgumentException("Choose a Google Font.")) + } + + return runCatching { + val encodedName = URLEncoder.encode(normalizedFontName, Charsets.UTF_8.name()) + val cssConnection = URL("https://fonts.googleapis.com/css?family=$encodedName") + .openConnection() as HttpURLConnection + cssConnection.setRequestProperty("User-Agent", GoogleFontsSafariUserAgent) + cssConnection.connectTimeout = 15_000 + cssConnection.readTimeout = 15_000 + + if (cssConnection.responseCode != HttpURLConnection.HTTP_OK) { + throw IllegalStateException("Font '$normalizedFontName' was not found on Google Fonts.") + } + + val css = cssConnection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val fontUrl = googleFontDownloadUrlFromCss(css) + ?: throw IllegalStateException("Could not parse a download link for $normalizedFontName.") + val extension = googleFontFileExtension(fontUrl) + if (extension !in SupportedFontExtensions) { + throw IllegalStateException("Unsupported format ($extension) returned for $normalizedFontName.") + } + + val tempFile = File.createTempFile("episteme_google_font_", ".$extension") + try { + val fontConnection = URL(fontUrl).openConnection() as HttpURLConnection + fontConnection.connectTimeout = 15_000 + fontConnection.readTimeout = 30_000 + fontConnection.inputStream.use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } + importFont(tempFile, displayNameOverride = normalizedFontName).getOrThrow() + } finally { + tempFile.delete() + } + } + } + + companion object { + private val SupportedFontExtensions = setOf("ttf", "otf", "woff2") + private const val GoogleFontsResource = "google_fonts.json" + private const val GoogleFontsSafariUserAgent = + "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1" + + fun defaultFontsDir(): File { + return File(desktopUserDataRoot(), "custom_fonts") + } + } +} + +internal fun googleFontDownloadUrlFromCss(css: String): String? { + return Regex("""url\((https://[^)]+)\)""") + .find(css) + ?.groupValues + ?.getOrNull(1) +} + +internal fun googleFontFileExtension(fontUrl: String): String { + return fontUrl.substringBefore('?') + .substringAfterLast('.', "ttf") + .lowercase() +} + +internal fun googleFontsFromJson(rawJson: String): List { + return Json.parseToJsonElement(rawJson) + .jsonArray + .mapNotNull { element -> + runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt new file mode 100644 index 0000000..ded1895 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopDiagnostics.kt @@ -0,0 +1,40 @@ +package org.dueattendant149.bookreader.desktop + +internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics" +private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" +private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS" +private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS" + +private val DesktopDiagnosticTags: Set = + listOfNotNull( + System.getProperty(DesktopDiagnosticsTagsProperty), + System.getenv(DesktopDiagnosticsTagsEnv) + ) + .joinToString(" ") + .split(',', ';', ' ', '\t', '\n') + .mapNotNull { rawTag -> + rawTag.trim() + .takeIf { it.isNotBlank() } + ?.lowercase() + } + .toSet() + +internal val DesktopDiagnosticsEnabled: Boolean = + desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) || + desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) || + DesktopDiagnosticTags.isNotEmpty() + +internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean { + return rawValue?.trim()?.equals("true", ignoreCase = true) == true +} + +private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean { + if (DesktopDiagnosticTags.isEmpty()) return true + return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags +} + +internal fun logDesktopDiagnostic(tag: String, message: () -> String) { + if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) { + println("$tag ${message()}") + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt new file mode 100644 index 0000000..9884b1e --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsing.kt @@ -0,0 +1,373 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.toStableReaderPositionCfi +import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderLinkClick +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.awt.event.KeyEvent as AwtKeyEvent +import java.net.URLDecoder + +internal data class DesktopReaderPosition( + val pageIndex: Int, + val locator: ReaderLocator? +) + +internal data class DesktopReaderHighlightClick( + val highlightId: String +) + +internal data class DesktopEpubLinkClick( + val href: String, + val chapterIndex: Int?, + val text: String? = null, + val chapterId: String? = null, + val chapterHref: String? = null, + val source: String = "bridge" +) + +internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick { + return DesktopEpubLinkClick( + href = href, + chapterIndex = chapterIndex, + text = text, + source = "native" + ) +} + +internal data class DesktopEpubHandledLink( + val href: String, + val handledAtMs: Long +) + +internal enum class DesktopReaderSelectionAction { + DEFINE, + SPEAK, + SEARCH, + PALETTE +} + +internal enum class DesktopReaderKeyNavigation { + NEXT, + PREVIOUS, + FIRST, + LAST, + SEARCH, + NEXT_SEARCH, + EXIT_FULLSCREEN +} + +internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull( + fullscreen: Boolean, + rightToLeftPagination: Boolean = false +): DesktopReaderKeyNavigation? { + if (id != AwtKeyEvent.KEY_PRESSED) return null + if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { + return DesktopReaderKeyNavigation.EXIT_FULLSCREEN + } + if (isControlDown && keyCode == AwtKeyEvent.VK_F) { + return DesktopReaderKeyNavigation.SEARCH + } + if (isControlDown && keyCode == AwtKeyEvent.VK_G) { + return DesktopReaderKeyNavigation.NEXT_SEARCH + } + return when (keyCode) { + AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { + DesktopReaderKeyNavigation.PREVIOUS + } else { + DesktopReaderKeyNavigation.NEXT + } + AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { + DesktopReaderKeyNavigation.NEXT + } else { + DesktopReaderKeyNavigation.PREVIOUS + } + AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT + AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS + AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST + AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST + else -> null + } +} + +internal data class DesktopReaderSelectionActionPayload( + val action: DesktopReaderSelectionAction, + val text: String, + val locator: ReaderLocator? = null +) + +internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? { + fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val highlightId = obj["id"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: obj["highlightId"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + DesktopReaderHighlightClick(highlightId) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? { + fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val text = obj["text"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + val action = when ( + obj["action"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.lowercase() + ) { + "define" -> DesktopReaderSelectionAction.DEFINE + "speak" -> DesktopReaderSelectionAction.SPEAK + "web-search", "search" -> DesktopReaderSelectionAction.SEARCH + "palette" -> DesktopReaderSelectionAction.PALETTE + else -> return@runCatching null + } + val locator = obj["locator"] + ?.takeUnless { it is JsonNull } + ?.jsonObject + ?.let { locatorObj -> + ReaderLocator( + chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() + ) + } + DesktopReaderSelectionActionPayload(action, text, locator) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerSelectionDebugMessageOrNull(): String? { + fun parse(rawJson: String): String? = runCatching { + Json.parseToJsonElement(rawJson) + .jsonObject["message"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerPaginationLogMessageOrNull(): String? { + fun parse(rawJson: String): String? = runCatching { + Json.parseToJsonElement(rawJson) + .jsonObject["message"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerPositionOrNull(): DesktopReaderPosition? { + fun parse(rawJson: String): DesktopReaderPosition? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val pageIndex = obj["pageIndex"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.intOrNull + ?: return@runCatching null + val locator = ReaderLocator( + chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + pageIndex = pageIndex, + startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi() + ) + DesktopReaderPosition(pageIndex, locator) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? { + fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching { + val action = Json.parseToJsonElement(rawJson) + .jsonObject["action"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?: return@runCatching null + when (action) { + "next" -> DesktopReaderKeyNavigation.NEXT + "previous" -> DesktopReaderKeyNavigation.PREVIOUS + "first" -> DesktopReaderKeyNavigation.FIRST + "last" -> DesktopReaderKeyNavigation.LAST + "search" -> DesktopReaderKeyNavigation.SEARCH + "nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH + "exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN + else -> null + } + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? { + fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching { + val obj = Json.parseToJsonElement(rawJson).jsonObject + val href = obj["href"] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@runCatching null + DesktopEpubLinkClick( + href = href, + chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull, + text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull, + chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull + ) + }.getOrNull() + + parse(this)?.let { return it } + return runCatching { + Json.parseToJsonElement(this).jsonPrimitive.contentOrNull + }.getOrNull()?.let { parse(it) } +} + +internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? { + val trimmed = trim() + if (trimmed.startsWith("readerlink:", ignoreCase = true)) { + logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"") + val payload = trimmed.substringAfter("?", missingDelimiterValue = "") + .split('&') + .firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) } + ?.substringAfter("=", missingDelimiterValue = "") + ?.takeIf { it.isNotBlank() } + if (payload == null) { + logEpubLink("request_intercept_readerlink_ignored reason=missing_payload") + return null + } + val decoded = runCatching { + URLDecoder.decode(payload, Charsets.UTF_8.name()) + }.getOrElse { + logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"") + return null + } + val link = decoded.readerLinkClickOrNull()?.copy(source = "request") + if (link == null) { + logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"") + } + return link + } + return readerHrefFromIntercept()?.let { href -> + DesktopEpubLinkClick( + href = href, + chapterIndex = null, + source = "request" + ) + } +} + +private fun String.readerHrefFromIntercept(): String? { + val trimmed = trim() + if (trimmed.isBlank()) return null + if (trimmed.equals("about:blank", ignoreCase = true)) return null + if (trimmed.startsWith("file:/", ignoreCase = true)) return null + if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}" + if (trimmed.startsWith("data:", ignoreCase = true)) return null + if (trimmed.startsWith("blob:", ignoreCase = true)) return null + return trimmed +} + +internal fun ReaderLocator.toReaderLocatorJson(): String { + return buildString { + append("{") + val values = buildList { + chapterIndex?.let { add("\"chapterIndex\":$it") } + chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") } + href?.let { add("\"href\":${it.toJsonStringLiteral()}") } + pageIndex?.let { add("\"pageIndex\":$it") } + startOffset?.let { add("\"startOffset\":$it") } + endOffset?.let { add("\"endOffset\":$it") } + blockIndex?.let { add("\"blockIndex\":$it") } + charOffset?.let { add("\"charOffset\":$it") } + cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") } + textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") } + } + append(values.joinToString(",")) + append("}") + } +} + +private fun String.toJsonStringLiteral(): String { + val builder = StringBuilder("\"") + forEach { char -> + when (char) { + '\\' -> builder.append("\\\\") + '"' -> builder.append("\\\"") + '\n' -> builder.append("\\n") + '\r' -> builder.append("\\r") + '\t' -> builder.append("\\t") + '\b' -> builder.append("\\b") + '\u000C' -> builder.append("\\f") + else -> { + if (char.code < 0x20) { + builder.append("\\u") + builder.append(char.code.toString(16).padStart(4, '0')) + } else { + builder.append(char) + } + } + } + } + builder.append('"') + return builder.toString() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt new file mode 100644 index 0000000..e5e9e62 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubLoader.kt @@ -0,0 +1,12 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.reader.SharedEpubBook +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader +import java.io.File + +object DesktopEpubLoader { + fun load(file: File): SharedEpubBook { + return SharedJvmBookLoader.load(file, FileType.EPUB) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt new file mode 100644 index 0000000..7d7b5de --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPagination.kt @@ -0,0 +1,105 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.reader.ReaderLayoutSignature +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec +import org.dueattendant149.bookreader.shared.reader.SharedEpubBook + +internal data class DesktopEpubPaginationRequest( + val bookId: String, + val chapterSignature: Int, + val layoutSignature: ReaderLayoutSignature, + val viewport: ReaderViewportSpec, + val density: DesktopEpubPaginationDensity, + val cacheGeneration: Int +) + +internal data class DesktopEpubPaginationDensity( + val density: Float, + val fontScale: Float +) + +internal fun desktopMeasuredPaginationReady( + request: DesktopEpubPaginationRequest?, + completedRequest: DesktopEpubPaginationRequest?, + currentPages: List, + measuredPages: List +): Boolean { + return request != null && + completedRequest == request && + measuredPages.isNotEmpty() && + currentPages.samePageLayoutAs(measuredPages) +} + +internal fun desktopPaginatedLayoutReadyForDisplay( + readingMode: ReaderReadingMode, + measuredPagesApplied: Boolean +): Boolean { + return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied +} + +internal fun desktopPagesWithMeasuredChapter( + currentPages: List, + chapterIndex: Int, + measuredChapterPages: List +): List { + if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages + val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex } + if (firstChapterPage < 0) return currentPages + val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex } + val combined = currentPages.take(firstChapterPage) + + measuredChapterPages + + currentPages.drop(lastChapterPage + 1) + return combined.mapIndexed { index, page -> page.copy(pageIndex = index) } +} + +internal fun List.firstPageIndexForChapter(chapterIndex: Int): Int? { + return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } +} + +internal fun SharedEpubBook.desktopPaginationContentSignature(): Int { + return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter -> + 31 * acc + + chapter.id.hashCode() + + chapter.plainText.length + + chapter.plainText.hashCode() + + chapter.semanticBlocks.hashCode() + + chapter.htmlContent.length + + chapter.htmlContent.hashCode() + + chapter.baseHref.orEmpty().hashCode() + } +} + +@Composable +internal fun DesktopEpubPaginationPreparing( + active: Boolean, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text( + if (active) "Preparing pages" else "Measuring reader layout", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt new file mode 100644 index 0000000..8013732 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubWebView.kt @@ -0,0 +1,279 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.UserHighlight +import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget +import kotlinx.coroutines.launch + +@Composable +internal fun DesktopEpubWebView( + html: String, + appearanceScript: String, + highlightPaletteScript: String, + navigationTarget: ReaderContentNavigationTarget, + highlights: List, + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + isFullscreen: Boolean, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit = {}, + networkAccessEnabled: Boolean, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + val backend = desktopEpubWebViewBackend() + LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) { + logDesktopWebView2( + "backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " + + "network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " + + "navMode=${navigationTarget.readingMode}" + } + } + DesktopNativeSwtEpubWebView( + html = html, + appearanceScript = appearanceScript, + highlightPaletteScript = highlightPaletteScript, + navigationTarget = navigationTarget, + highlights = highlights, + onHighlightCreated = onHighlightCreated, + onHighlightSelected = onHighlightSelected, + isFullscreen = isFullscreen, + onKeyboardNavigation = onKeyboardNavigation, + onSelectionAction = onSelectionAction, + onLinkClicked = onLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onPointerActivity, + networkAccessEnabled = networkAccessEnabled, + backgroundColor = backgroundColor, + modifier = modifier + ) +} + +internal data class DesktopEpubBridgeHandler( + val methodName: String, + val onMessage: (String) -> Unit +) + +@Composable +internal fun rememberDesktopEpubBridgeHandlers( + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit +): List { + 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() diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt new file mode 100644 index 0000000..2b8c4a9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopExternalLinks.kt @@ -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 } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt new file mode 100644 index 0000000..a106401 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacement.kt @@ -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() }) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt new file mode 100644 index 0000000..ebacf70 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDialogs.kt @@ -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 = DesktopBookFileTypes + +internal fun chooseFiles(): List { + 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 + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt new file mode 100644 index 0000000..240ef38 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFileDropTarget.kt @@ -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) -> Unit, + onDragStateChange: (DesktopDropImportState) -> Unit +) { + val onFilesDroppedState = rememberUpdatedState(onFilesDropped) + val onDragStateChangeState = rememberUpdatedState(onDragStateChange) + + DisposableEffect(window) { + if (window == null) { + onDispose { } + } else { + val installedTargets = mutableListOf() + 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 +) { + 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 { + val collected = mutableListOf() + + 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 { + if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList() + return runCatching { + @Suppress("UNCHECKED_CAST") + (getTransferData(DataFlavor.javaFileListFlavor) as? List<*>) + .orEmpty() + .filterIsInstance() + }.getOrDefault(emptyList()) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt new file mode 100644 index 0000000..89f21c4 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFirebaseAuthRepository.kt @@ -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>() + 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 """ + + + + + + ${title.escapeHtml()} + + + +
+

${title.escapeHtml()}

+

${message.escapeHtml()}

+
+ + + """.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 { + return formEncode(pairs.asIterable()) +} + +private fun formEncode(pairs: Iterable>): 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) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt new file mode 100644 index 0000000..7a1a4bb --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractor.kt @@ -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, + 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, + sourceFolder: String + ): DesktopFolderMetadataExtractionResult { + return enrichBooks(books) { book -> book.sourceFolder == sourceFolder } + } + + fun enrichFolderBooks( + books: List, + sourceFolders: Set + ): DesktopFolderMetadataExtractionResult { + if (sourceFolders.isEmpty()) { + return DesktopFolderMetadataExtractionResult(books) + } + return enrichBooks(books) { book -> book.sourceFolder in sourceFolders } + } + + fun enrichImportedBooks( + books: List, + importedBookIds: Set + ): 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, + 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("""]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE) + .find(containerXml) + ?.groupValues + ?.get(1) + ?.takeIf { it.isNotBlank() } + } + + private fun parseEpubManifest(opf: String): List { + return Regex("""]*>""", 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? { + val coverId = Regex("""]*>""", 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("", 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("", ignoreCase = true)) break + } + } + } + Regex("""]*>""", 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 { + val words = text.replace(Regex("\\s+"), " ").trim().split(' ').filter { it.isNotBlank() } + if (words.isEmpty()) return listOf("Untitled") + val lines = mutableListOf() + 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("") + ?.decodeEntities() + ?.trim() + .orEmpty() + } + + private fun String.metaContent(name: String): String? { + return Regex("""]*>""", 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() + 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 + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt new file mode 100644 index 0000000..e612816 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncFeedback.kt @@ -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 + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt new file mode 100644 index 0000000..44149df --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderSyncLog.kt @@ -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("\"", "\\\"") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt new file mode 100644 index 0000000..2cba6df --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopGeminiCloudTtsAdapter.kt @@ -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, + 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, + 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, + 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() + val currentTurnComplete = AtomicReference?>(null) + val activeCacheOutput = AtomicReference(null) + val failure = CompletableDeferred() + 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 { + 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() + 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 { + 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, + turnComplete: CompletableDeferred?, + failure: CompletableDeferred, + 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().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") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt new file mode 100644 index 0000000..71f3cf2 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLanguageSettings.kt @@ -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")) + } + } + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt new file mode 100644 index 0000000..1b087c9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabase.kt @@ -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 } diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt new file mode 100644 index 0000000..b93b635 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryUi.kt @@ -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) -> Unit, + onCreateSmartShelf: () -> Unit, + onRenameShelf: (Shelf) -> Unit, + onDeleteShelf: (Shelf) -> Unit, + onRemoveFolder: (Shelf) -> Unit, + onTagSelectedBooks: () -> Unit, + onAddSelectedBooksToShelf: () -> Unit, + onAddBooksToShelf: (Set) -> 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 SmartRuleDropdown( + label: String, + selected: T, + options: List, + 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 { + 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.updateAt( + index: Int, + transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft +): List { + 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 { + 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.collectTags(): List { + 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]}" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt new file mode 100644 index 0000000..9c078af --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSync.kt @@ -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, + val stats: LocalFolderSyncStats, + val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(), + val idMigrations: Map = emptyMap(), + val removedBookIds: Set = emptySet(), + val failedFolders: List = emptyList(), + val processedFolderUris: List = 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, + 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() + val allRemovedBookIds = linkedSetOf() + val failedFolders = mutableListOf() + val processedFolderUris = mutableListOf() + + 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 { + 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 { + 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 { + 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 { + 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) { + 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 + ) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt new file mode 100644 index 0000000..95d9de9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLogFormatting.kt @@ -0,0 +1,8 @@ +package org.dueattendant149.bookreader.desktop + +internal fun String.logPreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt new file mode 100644 index 0000000..7a984be --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsCoverImage.kt @@ -0,0 +1,106 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage + +@Composable +internal fun DesktopOpdsCoverImage( + entry: OpdsEntry, + catalog: OpdsCatalog?, + modifier: Modifier = Modifier +) { + val coverUrl = entry.coverUrl?.takeIf { it.isNotBlank() } + val cacheKey = remember(coverUrl, catalog?.id, catalog?.username) { + coverUrl?.let { DesktopOpdsCoverImageCache.cacheKey(it, catalog) } + } + var bitmap by remember(cacheKey) { mutableStateOf(cacheKey?.let { DesktopOpdsCoverImageCache.peek(it) }) } + + LaunchedEffect(cacheKey) { + bitmap = if (coverUrl == null || cacheKey == null) { + null + } else { + withContext(Dispatchers.IO) { + DesktopOpdsCoverImageCache.load(cacheKey, coverUrl, catalog) + } + } + } + + Box( + modifier = modifier + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + val imageBitmap = bitmap + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } else { + Text( + text = entry.title.take(1).uppercase(), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private object DesktopOpdsCoverImageCache { + private const val MaxEntries = 160 + + private val cache = object : LinkedHashMap(MaxEntries, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MaxEntries + } + } + + fun cacheKey(url: String, catalog: OpdsCatalog?): String { + return "${catalog?.id.orEmpty()}|${catalog?.username.orEmpty()}|$url" + } + + fun peek(cacheKey: String): ImageBitmap? { + return synchronized(cache) { cache[cacheKey] } + } + + fun load(cacheKey: String, url: String, catalog: OpdsCatalog?): ImageBitmap? { + peek(cacheKey)?.let { return it } + val bitmap = runCatching { + DesktopOpdsHttp.fetchBytes(url, catalog).toImageBitmap() + }.getOrNull() ?: return null + + synchronized(cache) { + cache[cacheKey] = bitmap + } + return bitmap + } + + private fun ByteArray.toImageBitmap(): ImageBitmap? { + return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt new file mode 100644 index 0000000..f694c4e --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepository.kt @@ -0,0 +1,288 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.opds.OpdsAcquisition +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsEntry +import org.dueattendant149.bookreader.shared.opds.OpdsFeed +import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs +import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer +import org.dueattendant149.bookreader.shared.opds.SharedOpdsParser +import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.Closeable +import java.io.File +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.security.MessageDigest +import java.time.Duration +import java.util.Base64 +import java.util.UUID + +internal class DesktopOpdsRepository( + private val catalogFile: File = defaultCatalogFile(), + private val idFactory: () -> String = { UUID.randomUUID().toString() } +) : SharedOpdsRepository { + private val parser = SharedOpdsParser() + + override fun loadCatalogs(): List { + val rawJson = catalogFile.takeIf { it.exists() }?.readText() + val decodedCatalogs = SharedOpdsCatalogs.decode(rawJson) + val catalogs = decodedCatalogs.ifEmpty { SharedOpdsCatalogs.defaultCatalogs(idFactory) } + if (decodedCatalogs.isEmpty()) saveCatalogs(catalogs) + return catalogs + } + + override fun saveCatalogs(catalogs: List) { + catalogFile.parentFile?.mkdirs() + catalogFile.writeText(SharedOpdsCatalogs.encode(catalogs)) + } + + override suspend fun fetchFeed(url: String, username: String?, password: String?): Result = withContext(Dispatchers.IO) { + runCatching { + val response = DesktopOpdsHttp.fetchString(url, username, password) + if (response.statusCode !in 200..299) { + error("HTTP ${response.statusCode}") + } + if (response.body.isBlank()) error("Empty response body") + parser.parse(response.body, url) + } + } + + override suspend fun getSearchTemplate(openSearchUrl: String, username: String?, password: String?): String? = withContext(Dispatchers.IO) { + runCatching { + val response = DesktopOpdsHttp.fetchString(openSearchUrl, username, password) + if (response.statusCode !in 200..299) return@withContext null + parser.extractOpenSearchTemplate(response.body, openSearchUrl) + }.getOrNull() + } + + suspend fun downloadBook( + entry: OpdsEntry, + acquisition: OpdsAcquisition, + catalog: OpdsCatalog?, + onProgress: (Float?) -> Unit + ): File = withContext(Dispatchers.IO) { + val response = DesktopOpdsHttp.fetchStream(acquisition.url, catalog?.username, catalog?.password) + if (response.statusCode !in 200..299) { + response.body.close() + error("HTTP ${response.statusCode}") + } + + val contentLength = response.headers.firstValueAsLong("content-length").orElse(-1L) + val contentDisposition = response.headers.firstValue("content-disposition").orElse(null) + val urlName = runCatching { + URI(acquisition.url).path.substringAfterLast('/').takeIf { it.isNotBlank() } + }.getOrNull() + val extension = SharedOpdsDownloadNamer.resolveExtension(acquisition, contentDisposition, urlName) + val target = uniqueDownloadFile(SharedOpdsDownloadNamer.safeFileStem(entry.title), extension) + + response.body.use { input -> + target.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var totalRead = 0L + var lastProgressAt = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (read > 0) { + output.write(buffer, 0, read) + totalRead += read + if (contentLength > 0) { + val now = System.currentTimeMillis() + if (now - lastProgressAt >= 200L) { + onProgress((totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)) + lastProgressAt = now + } + } + } + } + } + } + onProgress(1f) + target + } + + fun catalogById(id: String?): OpdsCatalog? { + if (id.isNullOrBlank()) return null + return loadCatalogs().firstOrNull { it.id == id } + } + + private fun uniqueDownloadFile(stem: String, extension: String): File { + val dir = opdsDownloadsDir().apply { mkdirs() } + var candidate = File(dir, "$stem$extension") + var index = 1 + while (candidate.exists()) { + candidate = File(dir, "${stem}_$index$extension") + index += 1 + } + return candidate + } + + companion object { + fun defaultCatalogFile(): File { + return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_catalogs.json") + } + + fun opdsDownloadsDir(): File { + return File(DesktopLibraryDatabase.defaultDatabaseFile().parentFile, "opds_downloads") + } + } +} + +internal data class DesktopOpdsTextResponse( + val statusCode: Int, + val body: String +) + +internal data class DesktopOpdsStreamResponse( + val statusCode: Int, + val headers: java.net.http.HttpHeaders, + val body: java.io.InputStream +) + +internal object DesktopOpdsHttp { + fun fetchString(url: String, username: String?, password: String?): DesktopOpdsTextResponse { + val response = send(url, username, password, HttpResponse.BodyHandlers.ofString()) + return DesktopOpdsTextResponse(response.statusCode(), response.body().orEmpty()) + } + + fun fetchStream(url: String, username: String?, password: String?): DesktopOpdsStreamResponse { + val response = send(url, username, password, HttpResponse.BodyHandlers.ofInputStream()) + return DesktopOpdsStreamResponse(response.statusCode(), response.headers(), response.body()) + } + + fun fetchBytes(url: String, catalog: OpdsCatalog?): ByteArray { + val response = send(url, catalog?.username, catalog?.password, HttpResponse.BodyHandlers.ofByteArray()) + if (response.statusCode() !in 200..299) { + error("HTTP ${response.statusCode()}") + } + return response.body() + } + + private fun send( + url: String, + username: String?, + password: String?, + bodyHandler: HttpResponse.BodyHandler + ): HttpResponse { + ensureNetworkAccess() + val uri = URI(url.trim()) + val response = client().send(request(uri).build(), bodyHandler) + val challenge = response.headers().firstValue("www-authenticate").orElse(null) + val authorization = if (response.statusCode() == 401) { + authorizationHeaderForChallenge( + challenge = challenge, + url = uri.toString(), + username = username, + password = password + ) + } else { + null + } + if (authorization == null) return response + + (response.body() as? Closeable)?.close() + return client().send( + request(uri) + .header("Authorization", authorization) + .build(), + bodyHandler + ) + } + + private fun request(uri: URI): HttpRequest.Builder { + return HttpRequest.newBuilder(uri) + .timeout(Duration.ofSeconds(45)) + .header("User-Agent", "EpistemeReader/1.0 (Desktop)") + } + + private fun client(): HttpClient { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + } + + private fun ensureNetworkAccess() { + check(currentDesktopBuildProfile().featurePolicy.networkAccess) { + "Network access is disabled in this desktop build." + } + } + + internal fun authorizationHeaderForChallenge( + challenge: String?, + url: String, + username: String?, + password: String?, + method: String = "GET", + cnonce: String = UUID.randomUUID().toString().replace("-", ""), + nonceCount: String = "00000001" + ): String? { + if (challenge.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) return null + return when { + challenge.startsWith("Basic", ignoreCase = true) -> { + val credentials = "$username:$password".toByteArray(Charsets.ISO_8859_1) + "Basic ${Base64.getEncoder().encodeToString(credentials)}" + } + + challenge.startsWith("Digest", ignoreCase = true) -> { + val params = parseAuthParams(challenge) + val realm = params["realm"].orEmpty() + val nonce = params["nonce"] ?: return null + val qop = params["qop"] + ?.split(',') + ?.map { it.trim().trim('"') } + ?.firstOrNull { it.equals("auth", ignoreCase = true) } + val opaque = params["opaque"] + val uri = URI(url) + val requestUri = buildString { + append(uri.rawPath.takeIf { !it.isNullOrBlank() } ?: "/") + uri.rawQuery?.let { append('?').append(it) } + } + val ha1 = md5("$username:$realm:$password") + val ha2 = md5("${method.uppercase()}:$requestUri") + val responseHash = if (qop != null) { + md5("$ha1:$nonce:$nonceCount:$cnonce:$qop:$ha2") + } else { + md5("$ha1:$nonce:$ha2") + } + + buildString { + append("Digest username=\"${username.escapeAuthQuote()}\", ") + append("realm=\"${realm.escapeAuthQuote()}\", ") + append("nonce=\"${nonce.escapeAuthQuote()}\", ") + append("uri=\"${requestUri.escapeAuthQuote()}\", ") + append("response=\"$responseHash\"") + if (qop != null) { + append(", qop=$qop, nc=$nonceCount, cnonce=\"${cnonce.escapeAuthQuote()}\"") + } + if (opaque != null) { + append(", opaque=\"${opaque.escapeAuthQuote()}\"") + } + } + } + + else -> null + } + } + + private fun parseAuthParams(challenge: String): Map { + return Regex("""(\w+)=(?:"([^"]*)"|([^,\s]+))""") + .findAll(challenge) + .associate { match -> + match.groupValues[1].lowercase() to (match.groupValues[2].ifBlank { match.groupValues[3] }) + } + } + + private fun md5(input: String): String { + val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun String.escapeAuthQuote(): String { + return replace("\\", "\\\\").replace("\"", "\\\"") + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt new file mode 100644 index 0000000..95a9d08 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiAdapter.kt @@ -0,0 +1,341 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.AiAdapter +import org.dueattendant149.bookreader.shared.AiDefinitionResult +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.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import kotlin.math.ceil + +internal class DesktopPaidAiAdapter( + private val config: DesktopCloudConfig, + private val networkAccess: () -> Boolean, + private val hideReaderAiFeatures: () -> Boolean, + private val currentAuthToken: suspend () -> String?, + private val currentSignedIn: () -> Boolean, + private val currentIsProUser: () -> Boolean, + private val currentCredits: () -> Int, + private val onUsageReported: (DesktopPaidAiUsage) -> Unit = {} +) : AiAdapter { + override val isAvailable: Boolean + get() = networkAccess() && + config.isAiWorkerConfigured && + !hideReaderAiFeatures() + + override suspend fun define(text: String, context: String?): AiDefinitionResult { + val trimmed = text.trim() + if (trimmed.isBlank()) return AiDefinitionResult(error = "There is no text to define.") + val multiWord = wordCount(trimmed) > 1 + if (multiWord && !currentSignedIn()) { + return AiDefinitionResult(error = "Sign in with Google to use multi-word smart dictionary.") + } + if (multiWord && !currentIsProUser()) { + return AiDefinitionResult(error = "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app.") + } + val result = callWorker( + path = "/define", + body = buildJsonObject { put("text", JsonPrimitive(trimmed.take(2400))) }.toString(), + authRequired = multiWord + ) + return AiDefinitionResult(definition = result.getOrNull()?.text, error = result.exceptionOrNull()?.message) + } + + override suspend fun defineStreaming( + text: String, + context: String?, + onUpdate: (String) -> Unit + ): AiDefinitionResult { + val trimmed = text.trim() + if (trimmed.isBlank()) return AiDefinitionResult(error = "There is no text to define.") + val multiWord = wordCount(trimmed) > 1 + if (multiWord && !currentSignedIn()) { + return AiDefinitionResult(error = "Sign in with Google to use multi-word smart dictionary.") + } + if (multiWord && !currentIsProUser()) { + return AiDefinitionResult(error = "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app.") + } + val result = callWorker( + path = "/define", + body = buildJsonObject { put("text", JsonPrimitive(trimmed.take(2400))) }.toString(), + authRequired = multiWord, + onChunk = onUpdate + ) + return AiDefinitionResult(definition = result.getOrNull()?.text, error = result.exceptionOrNull()?.message) + } + + override suspend fun summarize(text: String): SummarizationResult { + val trimmed = text.trim() + if (trimmed.isBlank()) return SummarizationResult(error = "There is no text to summarize.") + val gate = paidGenerationGate(freeProSummaryAllowed = true) + if (gate != null) return SummarizationResult(error = gate) + val result = callWorker( + path = "/summarize", + body = buildJsonObject { + put("content_type", JsonPrimitive("text")) + put("data", JsonPrimitive(trimmed)) + }.toString(), + authRequired = true + ) + val response = result.getOrNull() + return SummarizationResult( + summary = response?.text, + error = result.exceptionOrNull()?.message, + cost = response?.cost, + freeRemaining = response?.freeRemaining + ) + } + + override suspend fun summarizeStreaming( + text: String, + onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, + onUpdate: (String) -> Unit + ): SummarizationResult { + val trimmed = text.trim() + if (trimmed.isBlank()) return SummarizationResult(error = "There is no text to summarize.") + val gate = paidGenerationGate(freeProSummaryAllowed = true) + if (gate != null) return SummarizationResult(error = gate) + val result = callWorker( + path = "/summarize", + body = buildJsonObject { + put("content_type", JsonPrimitive("text")) + put("data", JsonPrimitive(trimmed)) + }.toString(), + authRequired = true, + onChunk = onUpdate, + onUsageReceived = onUsageReceived + ) + val response = result.getOrNull() + return SummarizationResult( + summary = response?.text, + error = result.exceptionOrNull()?.message, + cost = response?.cost, + freeRemaining = response?.freeRemaining + ) + } + + override suspend fun recap(textBeforeCurrentLocation: String): RecapResult { + return recapWithContext(emptyList(), textBeforeCurrentLocation) + } + + suspend fun recapWithContext(pastSummaries: List, currentText: String): RecapResult { + val trimmed = currentText.trim() + if (trimmed.isBlank()) return RecapResult(error = "There is no reading context for a recap.") + val gate = paidGenerationGate(freeProSummaryAllowed = false) + if (gate != null) return RecapResult(error = gate) + val result = callWorker( + path = "/recap", + body = buildJsonObject { + put( + "past_summaries", + buildJsonArray { + pastSummaries.filter { it.isNotBlank() }.forEach { summary -> + add(JsonPrimitive(summary)) + } + } + ) + put("current_text", JsonPrimitive(trimmed)) + }.toString(), + authRequired = true + ) + val response = result.getOrNull() + return RecapResult( + recap = response?.text, + error = result.exceptionOrNull()?.message, + cost = response?.cost, + freeRemaining = response?.freeRemaining + ) + } + + private fun paidGenerationGate(freeProSummaryAllowed: Boolean): String? { + if (!config.isAiWorkerConfigured) return "Desktop AI is not configured." + if (!networkAccess()) return "AI features are unavailable in this desktop build." + if (hideReaderAiFeatures()) return "Reader AI features are hidden." + if (!currentSignedIn()) return "Sign in with Google to use this AI feature." + if (!(freeProSummaryAllowed && currentIsProUser()) && currentCredits() <= 0) { + return "This action needs credits. Pro and credits can only be purchased from the Android app." + } + return null + } + + private suspend fun callWorker( + path: String, + body: String, + authRequired: Boolean, + onChunk: (String) -> Unit = {}, + onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> } + ): Result = withContext(Dispatchers.IO) { + if (!isAvailable) return@withContext Result.failure(IllegalStateException("AI features are unavailable.")) + val token = currentAuthToken() + if (authRequired && token.isNullOrBlank()) { + return@withContext Result.failure(IllegalStateException("Sign in with Google to use this AI feature.")) + } + runCatching { + val url = URL(config.aiWorkerUrl.removeSuffix("/") + path) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json; charset=UTF-8") + setRequestProperty("Accept", "application/json") + if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token") + connectTimeout = 15_000 + readTimeout = 120_000 + doOutput = true + doInput = true + } + try { + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + val responseCode = connection.responseCode + val stream = if (responseCode in 200..299) connection.inputStream else connection.errorStream + if (responseCode in 200..299) { + val parsed = readWorkerStream( + stream = stream, + onChunk = onChunk, + onUsageReceived = onUsageReceived, + onUsageReported = onUsageReported + ) + if (parsed.text.isBlank()) throw IllegalStateException("The AI service returned an empty response.") + return@runCatching parsed + } + val responseText = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + if (connection.responseCode == 402 || responseText.contains("INSUFFICIENT_CREDITS")) { + onUsageReported(DesktopPaidAiUsage()) + throw IllegalStateException("Out of credits. Pro and credits can only be purchased from the Android app.") + } + if (connection.responseCode == 401) { + throw IllegalStateException("Sign in again to use this AI feature.") + } + if (connection.responseCode == 403 && responseText.contains("MULTI_WORD_REQUIRES_PRO")) { + throw IllegalStateException("Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app.") + } + if (connection.responseCode !in 200..299) { + throw IllegalStateException(workerErrorMessage(responseText) ?: "AI request failed: HTTP ${connection.responseCode}") + } + error("Unreachable AI response state.") + } finally { + connection.disconnect() + } + } + } +} + +internal data class DesktopPaidAiUsage( + val cost: Double? = null, + val freeRemaining: Int? = null +) + +internal fun desktopCreditsAfterPaidAiUsage(currentCredits: Int, cost: Double?): Int { + val deducted = cost + ?.takeIf { it.isFinite() && it > 0.0 } + ?.let { ceil(it).toInt() } + ?: return currentCredits + return (currentCredits - deducted).coerceAtLeast(0) +} + +private data class DesktopPaidAiResponse( + val text: String, + val cost: Double? = null, + val freeRemaining: Int? = null +) + +private val DesktopPaidAiJson = Json { ignoreUnknownKeys = true } + +private fun readWorkerStream( + stream: InputStream?, + onChunk: (String) -> Unit, + onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit, + onUsageReported: (DesktopPaidAiUsage) -> Unit +): DesktopPaidAiResponse { + val output = StringBuilder() + var cost: Double? = null + var freeRemaining: Int? = null + var paidUsageReported = false + var freeUsageReported = false + stream?.bufferedReader(Charsets.UTF_8)?.useLines { lines -> + lines.forEach { line -> + val parsed = try { + parseWorkerStreamLine(line) + } catch (error: IllegalStateException) { + if (desktopPaidAiShouldRefreshAccountAfterError(error)) { + onUsageReported(DesktopPaidAiUsage()) + } + throw error + } ?: return@forEach + parsed.cost?.let { cost = it } + parsed.freeRemaining?.let { freeRemaining = it } + if (parsed.cost != null || parsed.freeRemaining != null) { + onUsageReceived(parsed.cost, parsed.freeRemaining) + if (parsed.cost != null && !paidUsageReported) { + paidUsageReported = true + onUsageReported(DesktopPaidAiUsage(cost = parsed.cost, freeRemaining = parsed.freeRemaining)) + } else if (parsed.freeRemaining != null && !freeUsageReported) { + freeUsageReported = true + onUsageReported(DesktopPaidAiUsage(freeRemaining = parsed.freeRemaining)) + } + } + parsed.chunk?.let { chunk -> + output.append(chunk) + onChunk(chunk) + } + } + } + return DesktopPaidAiResponse(text = output.toString().trim(), cost = cost, freeRemaining = freeRemaining) +} + +private fun parseWorkerStreamLine(line: String): DesktopPaidAiStreamLine? { + val trimmed = line.trim() + if (trimmed.isBlank()) return null + val parsed = runCatching { DesktopPaidAiJson.parseToJsonElement(trimmed).jsonObject }.getOrNull() ?: return null + parsed.get("error")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { error -> + throw IllegalStateException(workerErrorMessage(error) ?: error) + } + return DesktopPaidAiStreamLine( + chunk = parsed.get("chunk")?.jsonPrimitive?.contentOrNull, + cost = parsed.get("cost_deducted")?.jsonPrimitive?.contentOrNull?.toDoubleOrNull(), + freeRemaining = parsed.get("free_summaries_remaining")?.jsonPrimitive?.contentOrNull?.toIntOrNull() + ) +} + +private data class DesktopPaidAiStreamLine( + val chunk: String? = null, + val cost: Double? = null, + val freeRemaining: Int? = null +) + +private fun desktopPaidAiShouldRefreshAccountAfterError(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 fun workerErrorMessage(errorBody: String): String? { + return when { + errorBody.contains("INSUFFICIENT_CREDITS") -> "Out of credits. Pro and credits can only be purchased from the Android app." + errorBody.contains("SUMMARY_LIMIT") || + (errorBody.contains("free summar", ignoreCase = true) && errorBody.contains("limit", ignoreCase = true)) -> + "Free summaries are used up for today. More summaries need credits, and credits can only be purchased from the Android app." + errorBody.contains("MULTI_WORD_REQUIRES_PRO") -> "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app." + errorBody.contains("Authentication required") -> "Sign in with Google to use this AI feature." + else -> runCatching { + DesktopPaidAiJson.parseToJsonElement(errorBody) + .jsonObject["error"] + ?.jsonPrimitive + ?.contentOrNull + }.getOrNull() + } +} + +private fun wordCount(text: String): Int { + return text.trim().split(Regex("\\s+")).count { it.isNotBlank() } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt new file mode 100644 index 0000000..5b6d718 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAnnotationUi.kt @@ -0,0 +1,880 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontStyle +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.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationComment +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.pdfCommentChildren +import org.dueattendant149.bookreader.shared.pdf.sharedPdfStrokePercent +import org.dueattendant149.bookreader.shared.pdf.sharedPdfStrokeWidthRange +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle +import org.dueattendant149.bookreader.shared.pdf.visiblePdfAnnotationComments +import org.dueattendant149.bookreader.shared.pdf.withoutPdfCommentThread +import org.dueattendant149.bookreader.shared.pdf.withSharedPdfTextStyle +import org.dueattendant149.bookreader.shared.ui.SharedHsvColorPickerDialog +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextStyleControls +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString +import java.text.DateFormat +import java.util.Date +import java.util.UUID + +internal val DesktopPdfAnnotationTools = listOf( + PdfInkTool.PEN, + PdfInkTool.FOUNTAIN_PEN, + PdfInkTool.PENCIL, + PdfInkTool.HIGHLIGHTER, + PdfInkTool.HIGHLIGHTER_ROUND, + PdfInkTool.TEXT, + PdfInkTool.ERASER +) + +private enum class DesktopPdfAnnotationSheetSection { + NOTE, + COMMENTS +} + +@Composable +internal fun DesktopPdfAnnotationEditor( + annotation: SharedPdfAnnotation, + onUpdate: (SharedPdfAnnotation) -> Unit, + onDelete: () -> Unit, + onClose: () -> Unit, + onCopy: () -> Unit, + showSearch: Boolean, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, + onSearch: () -> Unit +) { + val highlighterColors = remember(highlighterPalette) { + SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors + } + var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf(null) } + var editingHighlighterDraftColors by remember(annotation.id, highlighterColors) { + mutableStateOf>(emptyList()) + } + val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT || + annotation.tool == PdfInkTool.HIGHLIGHTER || + annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND + var selectedSection by remember(annotation.id) { mutableStateOf(DesktopPdfAnnotationSheetSection.NOTE) } + var commentText by remember(annotation.id) { mutableStateOf("") } + var replyTargetId by remember(annotation.id) { mutableStateOf(null) } + var editingCommentId by remember(annotation.id) { mutableStateOf(null) } + var commentAuthor by remember(annotation.id) { + mutableStateOf( + annotation.comments + .lastOrNull { it.author.isNotBlank() } + ?.author + ?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR + ) + } + + fun updateComments(nextComments: List) { + onUpdate(annotation.copy(comments = nextComments)) + } + + fun highlighterDraftColors(): List { + return editingHighlighterDraftColors.ifEmpty { highlighterColors } + } + + fun updateHighlighterDraft(slotIndex: Int, color: Color): List { + val nextColors = highlighterDraftColors().toMutableList() + if (slotIndex in nextColors.indices) { + nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + editingHighlighterDraftColors = nextColors + } + return nextColors + } + + fun openHighlighterEditor(slotIndex: Int) { + if (editingHighlighterSlot == null) { + editingHighlighterDraftColors = highlighterColors + } + editingHighlighterSlot = slotIndex + } + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + readerString("desktop_selected_annotation_format", "Selected %1\$s", annotation.desktopLabel()), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text(readerString("action_close", "Close")) + } + } + Text( + readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + if (annotation.text.isNotBlank()) { + Surface( + color = Color(annotation.colorArgb).copy(alpha = 0.10f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.heightIn(min = 72.dp)) { + Box( + modifier = Modifier + .width(6.dp) + .fillMaxHeight() + .background(Color(annotation.colorArgb)) + ) + Text( + "\"${annotation.text}\"", + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), + maxLines = 4, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f), + modifier = Modifier.padding(14.dp) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + DesktopBottomSheetToolButton( + icon = Icons.Default.ContentCopy, + label = readerString("action_copy", "Copy"), + onClick = onCopy + ) + if (showSearch) { + DesktopBottomSheetToolButton( + icon = Icons.Default.Search, + label = readerString("action_search", "Search"), + onClick = onSearch + ) + } + } + } + if (annotation.kind == PdfAnnotationKind.TEXT) { + SharedStableOutlinedTextField( + value = annotation.text, + onValueChange = { onUpdate(annotation.copy(text = it)) }, + label = { Text(readerString("desktop_text_note", "Text note")) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + selectionKey = annotation.id + ) + SharedPdfTextStyleControls( + style = annotation.sharedPdfTextStyle(), + onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) } + ) + } + if (annotation.kind != PdfAnnotationKind.TEXT) { + val palette = if (isHighlighterAnnotation) { + highlighterColors + } else { + SharedPdfAnnotationDefaults.penPalette + } + Text(readerString("desktop_color", "Color"), style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + palette.forEachIndexed { _, argb -> + Surface( + modifier = Modifier + .size(26.dp) + .clickable { + onUpdate(annotation.copy(colorArgb = argb)) + }, + color = Color(argb), + shape = RoundedCornerShape(13.dp), + content = {} + ) + } + if (isHighlighterAnnotation) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(15.dp)) + .background( + Brush.sweepGradient( + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp)) + .clickable { + openHighlighterEditor( + highlighterColors + .indexOf(annotation.colorArgb) + .takeIf { it >= 0 } + ?: 0 + ) + } + ) + } + } + DesktopPdfAnnotationSheetTabs( + selectedSection = selectedSection, + commentCount = annotation.comments.count { it.contents.isNotBlank() }, + onSectionChange = { selectedSection = it } + ) + if (selectedSection == DesktopPdfAnnotationSheetSection.NOTE) { + SharedStableOutlinedTextField( + value = annotation.note.orEmpty(), + onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) }, + label = { Text(readerString("label_note", "Note")) }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + selectionKey = annotation.id + ) + } else { + DesktopPdfHighlightCommentsEditor( + comments = annotation.comments, + commentText = commentText, + commentAuthor = commentAuthor, + replyTargetId = replyTargetId, + editingCommentId = editingCommentId, + onCommentTextChange = { commentText = it }, + onCommentAuthorChange = { commentAuthor = it }, + onReply = { comment -> + editingCommentId = null + replyTargetId = comment.id + commentText = "" + }, + onCancelReply = { replyTargetId = null }, + onEdit = { comment -> + editingCommentId = comment.id + replyTargetId = null + commentText = comment.contents + commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } + }, + onCancelEdit = { + editingCommentId = null + commentText = "" + }, + onDelete = { comment -> + val nextComments = annotation.comments.withoutPdfCommentThread(comment.id) + updateComments(nextComments) + if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) { + replyTargetId = null + } + if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) { + editingCommentId = null + commentText = "" + } + }, + onAddComment = { + val contents = commentText.trim() + if (contents.isNotBlank()) { + val now = System.currentTimeMillis() + val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR } + val nextComments = if (editingCommentId != null) { + annotation.comments.map { comment -> + if (comment.id == editingCommentId) { + comment.copy( + author = author, + contents = contents, + modifiedAt = now + ) + } else { + comment + } + } + } else { + annotation.comments + SharedPdfAnnotationComment( + id = UUID.randomUUID().toString(), + parentId = replyTargetId, + author = author, + contents = contents, + createdAt = now, + modifiedAt = now + ) + } + updateComments(nextComments) + commentText = "" + replyTargetId = null + editingCommentId = null + } + } + ) + } + } + if (annotation.kind == PdfAnnotationKind.INK) { + val strokeRange = annotation.tool.sharedPdfStrokeWidthRange() + val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive) + Text( + readerString( + "desktop_thickness_format", + "Thickness %1\$s", + strokeValue.sharedPdfStrokePercent(strokeRange) + ), + style = MaterialTheme.typography.labelLarge + ) + Slider( + value = strokeValue, + onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) }, + valueRange = strokeRange + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDelete) { + Text(readerString("action_delete", "Delete")) + } + } + } + } + editingHighlighterSlot?.let { requestedSlot -> + val draftColors = highlighterDraftColors() + val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } + val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) + val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), + onDismiss = { editingHighlighterSlot = null }, + onSave = { color -> + val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + val nextColors = updateHighlighterDraft(slot, color) + onHighlighterPaletteChange( + SharedPdfHighlighterPalette(nextColors).sanitized() + ) + onUpdate(annotation.copy(colorArgb = nextArgb)) + editingHighlighterSlot = null + }, + resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { + SharedPdfHighlighterPalette.defaultColors.first() + }).copy(alpha = 1f), + stateKey = slot, + onLiveColorChange = { color -> + updateHighlighterDraft(slot, color) + } + ) { liveColor -> + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + highlighterDraftColors().forEachIndexed { index, argb -> + val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(color) + .border( + width = if (index == slot) 3.dp else 1.dp, + color = if (index == slot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + }, + shape = RoundedCornerShape(21.dp) + ) + .clickable { openHighlighterEditor(index) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +@Composable +private fun DesktopPdfAnnotationSheetTabs( + selectedSection: DesktopPdfAnnotationSheetSection, + commentCount: Int, + onSectionChange: (DesktopPdfAnnotationSheetSection) -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(4.dp)) { + DesktopPdfAnnotationSheetTab( + label = readerString("label_note", "Note"), + selected = selectedSection == DesktopPdfAnnotationSheetSection.NOTE, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.NOTE) } + ) + DesktopPdfAnnotationSheetTab( + label = "${readerString("label_comments", "Comments")} ($commentCount)", + selected = selectedSection == DesktopPdfAnnotationSheetSection.COMMENTS, + modifier = Modifier.weight(1f), + onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.COMMENTS) } + ) + } + } +} + +@Composable +private fun DesktopPdfAnnotationSheetTab( + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + modifier = modifier + .height(40.dp) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun DesktopPdfHighlightCommentsEditor( + comments: List, + commentText: String, + commentAuthor: String, + replyTargetId: String?, + editingCommentId: String?, + onCommentTextChange: (String) -> Unit, + onCommentAuthorChange: (String) -> Unit, + onReply: (SharedPdfAnnotationComment) -> Unit, + onCancelReply: () -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onCancelEdit: () -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit, + onAddComment: () -> Unit +) { + val visibleComments = comments.visiblePdfAnnotationComments() + val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId } + val editingComment = visibleComments.firstOrNull { it.id == editingCommentId } + + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 220.dp) + .verticalScroll(rememberScrollState()) + ) { + DesktopPdfHighlightCommentThread( + comments = visibleComments, + parentId = null, + depth = 0, + visitedIds = emptySet(), + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } + + if (editingComment != null || replyTarget != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (editingComment != null) { + readerString("label_editing_comment", "Editing comment") + } else { + readerString( + "label_replying_to", + "Replying to %1\$s", + replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty() + ) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) { + Text(readerString("action_cancel", "Cancel")) + } + } + } + + SharedStableOutlinedTextField( + value = commentAuthor, + onValueChange = onCommentAuthorChange, + label = { Text(readerString("author", "Author")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + selectionKey = "comment-author-${editingCommentId ?: replyTargetId ?: "new"}" + ) + + Spacer(Modifier.height(8.dp)) + + SharedStableOutlinedTextField( + value = commentText, + onValueChange = onCommentTextChange, + placeholder = { Text(readerString("placeholder_add_comment", "Add a comment...")) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp), + minLines = 3, + maxLines = 4, + shape = RoundedCornerShape(12.dp), + selectionKey = "comment-text-${editingCommentId ?: replyTargetId ?: "new"}" + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) { + Text( + readerString( + if (editingComment != null) "action_save_comment" else "action_add_comment", + if (editingComment != null) "Save Comment" else "Add Comment" + ) + ) + } + } + } +} + +@Composable +private fun DesktopPdfHighlightCommentThread( + comments: List, + parentId: String?, + depth: Int, + visitedIds: Set, + onReply: (SharedPdfAnnotationComment) -> Unit, + onEdit: (SharedPdfAnnotationComment) -> Unit, + onDelete: (SharedPdfAnnotationComment) -> Unit +) { + comments.pdfCommentChildren(parentId).forEach { comment -> + if (comment.id in visitedIds) return@forEach + DesktopPdfHighlightCommentItem( + comment = comment, + depth = depth, + onReply = { onReply(comment) }, + onEdit = { onEdit(comment) }, + onDelete = { onDelete(comment) } + ) + DesktopPdfHighlightCommentThread( + comments = comments, + parentId = comment.id, + depth = depth + 1, + visitedIds = visitedIds + comment.id, + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun DesktopPdfHighlightCommentItem( + comment: SharedPdfAnnotationComment, + depth: Int, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + val indentSize = (depth * 16).dp + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentSize, top = 6.dp, bottom = 6.dp) + ) { + if (depth > 0) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.outlineVariant) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + val timestamp = comment.createdAt.formatDesktopPdfCommentTimestamp() + if (timestamp.isNotBlank()) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(Modifier.height(2.dp)) + Text( + text = comment.contents, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Row { + TextButton(onClick = onReply) { + Text(readerString("action_reply", "Reply")) + } + TextButton(onClick = onEdit) { + Text(readerString("label_edit", "Edit")) + } + TextButton(onClick = onDelete) { + Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error) + } + } + } + } +} + +private fun Long.formatDesktopPdfCommentTimestamp(): String { + if (this <= 0L) return "" + return runCatching { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this)) + }.getOrDefault("") +} + +@Composable +private fun DesktopBottomSheetToolButton( + icon: ImageVector, + label: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f), + modifier = Modifier.size(22.dp) + ) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun DesktopPdfEmbeddedAnnotationPanel( + annotation: SharedPdfEmbeddedAnnotation, + onCopy: () -> Unit, + onClose: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + readerString("desktop_embedded_pdf_comment", "Embedded PDF comment"), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text(readerString("action_close", "Close")) + } + } + Text( + annotation.author.takeIf { it.isNotBlank() }?.let { author -> + readerString( + "desktop_pdf_page_author_format", + "Page %1\$d - %2\$s", + annotation.pageIndex + 1, + author + ) + } ?: readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + DesktopPdfEmbeddedComment( + author = annotation.author, + contents = annotation.contents, + depth = 0 + ) + DesktopPdfEmbeddedReplies(annotation.replies, depth = 1) + TextButton(onClick = onCopy) { + Text(readerString("action_copy_thread", "Copy thread")) + } + } + } +} + +@Composable +private fun DesktopPdfEmbeddedReplies( + replies: List, + depth: Int +) { + replies.forEach { reply -> + HorizontalDivider() + DesktopPdfEmbeddedComment( + author = reply.author, + contents = reply.contents, + depth = depth + ) + if (reply.replies.isNotEmpty()) { + DesktopPdfEmbeddedReplies(reply.replies, depth + 1) + } + } +} + +@Composable +private fun DesktopPdfEmbeddedComment( + author: String, + contents: String, + depth: Int +) { + Column( + modifier = Modifier.padding(start = (depth * 12).dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + author.ifBlank { readerString("unknown", "Unknown") }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + contents.ifBlank { readerString("desktop_no_comment", "No comment") }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +internal fun SharedPdfAnnotation.desktopLabel(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "highlight") + PdfAnnotationKind.INK -> tool.desktopLabel() + PdfAnnotationKind.TEXT -> readerString("desktop_text_note_lowercase", "text note") + } +} + +@Composable +internal fun SharedPdfAnnotation.desktopSheetTitle(): String { + return when (kind) { + PdfAnnotationKind.HIGHLIGHT -> readerString("label_highlight_color", "Highlight") + PdfAnnotationKind.INK -> readerString("desktop_annotation", "Annotation") + PdfAnnotationKind.TEXT -> readerString("desktop_text_note", "Text note") + } +} + +@Composable +private fun PdfInkTool.desktopLabel(): String { + return when (this) { + PdfInkTool.PEN -> readerString("content_desc_pen", "Pen") + PdfInkTool.FOUNTAIN_PEN -> readerString("desktop_fountain_pen", "Fountain pen") + PdfInkTool.PENCIL -> readerString("desktop_pencil", "Pencil") + PdfInkTool.HIGHLIGHTER -> readerString("content_desc_highlighter", "Highlighter") + PdfInkTool.HIGHLIGHTER_ROUND -> readerString("desktop_round_highlighter", "Round highlighter") + PdfInkTool.TEXT -> readerString("desktop_text_note", "Text note") + PdfInkTool.ERASER -> readerString("content_desc_eraser", "Eraser") + PdfInkTool.NONE -> readerString("label_none", "None") + } +} + +internal fun SharedPdfEmbeddedAnnotation.threadText(): String { + return buildString { + append(author.ifBlank { "Unknown" }) + append(": ") + appendLine(contents.ifBlank { "No comment" }) + fun appendReplies(replies: List, indent: String) { + replies.forEach { reply -> + append(indent) + append(reply.author.ifBlank { "Unknown" }) + append(": ") + appendLine(reply.contents.ifBlank { "No comment" }) + appendReplies(reply.replies, "$indent ") + } + } + appendReplies(replies, " ") + }.trimEnd() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt new file mode 100644 index 0000000..b56fb3e --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfAppearance.kt @@ -0,0 +1,218 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.Canvas +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.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.reader.ReaderSettings + +internal enum class DesktopPdfInspectorTab(val title: String) { + APPEARANCE("Appearance"), + APP_THEME("App theme"), + VISUAL("Visual"), + MARKUP("Markup"), + TTS("TTS") +} + +internal data class DesktopPdfThemeStyle( + val theme: ReaderTheme, + val viewerBackgroundColor: Color, + val pageBackgroundColor: Color, + val colorFilter: ColorFilter?, + val textureBitmap: ImageBitmap?, + val textureAlpha: Float, + val textureBlendMode: BlendMode +) + +@Composable +internal fun DesktopPdfThemedPageImage( + bitmap: ImageBitmap, + contentDescription: String, + themeStyle: DesktopPdfThemeStyle, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) { + val textureBitmap = themeStyle.textureBitmap + Canvas( + modifier = Modifier + .fillMaxSize() + .semantics { this.contentDescription = contentDescription } + ) { + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = IntSize(bitmap.width, bitmap.height), + dstOffset = IntOffset.Zero, + dstSize = IntSize( + size.width.toInt().coerceAtLeast(1), + size.height.toInt().coerceAtLeast(1) + ), + colorFilter = themeStyle.colorFilter, + filterQuality = FilterQuality.High + ) + if (textureBitmap != null && themeStyle.textureAlpha > 0f) { + drawRect( + brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)), + size = size, + blendMode = themeStyle.textureBlendMode, + alpha = themeStyle.textureAlpha + ) + } + } + } +} + +internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings { + val defaults = DesktopDefaultPdfReaderSettings + val settings = this ?: defaults + val themeId = settings.themeId + val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId } + val hasCustomColors = settings.backgroundColorArgb != null && settings.textColorArgb != null + return settings.copy( + themeId = when { + themeId == null -> "no_theme" + hasPdfTheme || hasCustomColors -> themeId + else -> "no_theme" + } + ) +} + +internal fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle { + val theme = toDesktopPdfTheme() + val pageBackground = desktopPdfPageBackgroundColor(theme, displayMode) + val isDarkTexture = theme.isDark || theme.id == "reverse" + return DesktopPdfThemeStyle( + theme = theme, + viewerBackgroundColor = pageBackground, + pageBackgroundColor = pageBackground, + colorFilter = theme.toDesktopPdfColorFilter(), + textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId), + textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f), + textureBlendMode = if (isDarkTexture) BlendMode.Screen else BlendMode.Multiply + ) +} + +private fun ReaderSettings.toDesktopPdfTheme(): ReaderTheme { + BuiltInPdfReaderThemes.firstOrNull { it.id == themeId }?.let { return it } + val background = backgroundColorArgb?.toComposeColor() + val text = textColorArgb?.toComposeColor() + return if (background != null && text != null) { + ReaderTheme( + id = themeId ?: "desktop_pdf_custom", + name = "Custom", + backgroundColor = background, + textColor = text, + isDark = darkMode, + textureId = textureId, + isCustom = true + ) + } else { + BuiltInPdfReaderThemes.first() + } +} + +private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? { + return when (id) { + "no_theme", "system" -> null + "reverse" -> { + val colorMatrix = floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ) + ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) + } + else -> { + if (!backgroundColor.isSpecified || !textColor.isSpecified) return null + val bgR = backgroundColor.red * 255f + val bgG = backgroundColor.green * 255f + val bgB = backgroundColor.blue * 255f + val fgR = textColor.red * 255f + val fgG = textColor.green * 255f + val fgB = textColor.blue * 255f + val dr = (bgR - fgR) / 255f + val dg = (bgG - fgG) / 255f + val db = (bgB - fgB) / 255f + val lumR = 0.2126f + val lumG = 0.7152f + val lumB = 0.0722f + val colorMatrix = floatArrayOf( + dr * lumR, dr * lumG, dr * lumB, 0f, fgR, + dg * lumR, dg * lumG, dg * lumB, 0f, fgG, + db * lumR, db * lumG, db * lumB, 0f, fgB, + 0f, 0f, 0f, 1f, 0f + ) + ColorFilter.colorMatrix(ColorMatrix(colorMatrix)) + } + } +} + +@Composable +internal fun DesktopPdfInspectorSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + content() + } +} + +@Composable +internal fun DesktopPdfVisualOptionSwitch( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +private fun Long.toComposeColor(): Color { + return Color(this and 0xFFFFFFFFL) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt new file mode 100644 index 0000000..5018a0a --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfChromeUi.kt @@ -0,0 +1,593 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.NavigateBefore +import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import org.dueattendant149.bookreader.shared.SearchHighlightMode +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.ui.ReaderMinimalSlider +import org.dueattendant149.bookreader.shared.ui.ReaderTooltipIconButton +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString +import kotlinx.coroutines.delay + +@Composable +internal fun DesktopPdfFullscreenBottomChrome( + pageIndex: Int, + pageCount: Int, + pageLabel: String = "Page ${pageIndex + 1} of $pageCount", + canGoPrevious: Boolean = pageIndex > 0, + canGoNext: Boolean = pageIndex < pageCount - 1, + showJumpHistory: Boolean, + jumpBackPage: Int?, + jumpForwardPage: Int?, + onPrevious: () -> Unit, + onNext: () -> Unit, + onPageScrub: (Float) -> Unit, + onPageScrubFinished: () -> Unit, + onJumpBack: () -> Unit, + onJumpForward: () -> Unit, + onClearJumpHistory: () -> Unit, + extraContent: @Composable ColumnScope.() -> Unit = {} +) { + val chromeBackground = MaterialTheme.colorScheme.surfaceVariant + val chromeContent = MaterialTheme.colorScheme.onSurface + val sliderActive = MaterialTheme.colorScheme.primary + val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) + Column(modifier = Modifier.fillMaxWidth()) { + extraContent() + Surface( + modifier = Modifier + .fillMaxWidth(), + shape = RoundedCornerShape(0.dp), + color = chromeBackground, + contentColor = chromeContent, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null + DesktopPdfJumpHistoryControls( + visible = showJumpHistory, + backPage = jumpBackPage, + forwardPage = jumpForwardPage, + onBack = onJumpBack, + onForward = onJumpForward, + onClear = onClearJumpHistory + ) + if (showJumpHistory && hasJumpTargets) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_previous_page", "Previous page"), + onClick = onPrevious, + enabled = canGoPrevious + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = readerString("desktop_previous_page", "Previous page"), + tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) + ) + } + Text( + pageLabel, + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) + ) + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = onPageScrub, + onValueChangeFinished = onPageScrubFinished, + valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(), + enabled = pageCount > 1, + activeColor = sliderActive, + inactiveColor = sliderInactive, + thumbColor = sliderActive, + modifier = Modifier.weight(1f) + ) + ReaderTooltipIconButton( + tooltip = readerString("desktop_next_page", "Next page"), + onClick = onNext, + enabled = canGoNext + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = readerString("desktop_next_page", "Next page"), + tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + ) + } + } + } + } + } +} + +@Composable +internal fun DesktopPdfBottomChrome( + pageIndex: Int, + pageCount: Int, + pageLabel: String = "Page ${pageIndex + 1} of $pageCount", + progressPercent: Float, + canGoPrevious: Boolean, + canGoNext: Boolean, + showJumpHistory: Boolean, + jumpBackPage: Int?, + jumpForwardPage: Int?, + onPrevious: () -> Unit, + onNext: () -> Unit, + onPageScrub: (Float) -> Unit, + onPageScrubFinished: () -> Unit, + onJumpBack: () -> Unit, + onJumpForward: () -> Unit, + onClearJumpHistory: () -> Unit, + extraContent: @Composable ColumnScope.() -> Unit = {} +) { + val chromeBackground = MaterialTheme.colorScheme.surfaceVariant + val chromeContent = MaterialTheme.colorScheme.onSurface + val sliderActive = MaterialTheme.colorScheme.primary + val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f) + Column(modifier = Modifier.fillMaxWidth()) { + extraContent() + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(0.dp), + color = chromeBackground, + contentColor = chromeContent, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + DesktopPdfJumpHistoryControls( + visible = showJumpHistory, + backPage = jumpBackPage, + forwardPage = jumpForwardPage, + onBack = onJumpBack, + onForward = onJumpForward, + onClear = onClearJumpHistory + ) + if (showJumpHistory && (jumpBackPage != null || jumpForwardPage != null)) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ReaderTooltipIconButton( + tooltip = readerString("desktop_previous_page", "Previous page"), + onClick = onPrevious, + enabled = canGoPrevious + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateBefore, + contentDescription = readerString("desktop_previous_page", "Previous page"), + tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f) + ) + } + Text( + pageLabel, + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) + ) + if (pageCount > 1) { + ReaderMinimalSlider( + value = pageIndex.toFloat(), + onValueChange = onPageScrub, + onValueChangeFinished = onPageScrubFinished, + valueRange = 0f..(pageCount - 1).toFloat(), + activeColor = sliderActive, + inactiveColor = sliderInactive, + thumbColor = sliderActive, + modifier = Modifier.weight(1f) + ) + } else { + Spacer(Modifier.weight(1f)) + } + Text( + "${progressPercent.toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = chromeContent.copy(alpha = 0.72f) + ) + ReaderTooltipIconButton( + tooltip = readerString("desktop_next_page", "Next page"), + onClick = onNext, + enabled = canGoNext + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = readerString("desktop_next_page", "Next page"), + tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f) + ) + } + } + } + } + } +} + +@Composable +internal fun DesktopPdfZoomPercentageIndicator( + percentage: Int, + onResetZoomClick: () -> Unit +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = "$percentage%", + color = Color.White, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .width(1.dp) + .height(16.dp) + .background(Color.White.copy(alpha = 0.5f)) + ) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.ZoomOut, + contentDescription = readerString("content_desc_reset_zoom", "Reset zoom"), + tint = Color.White, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onResetZoomClick) + ) + } + } +} + +@Composable +internal fun DesktopPdfSearchTopBar( + query: String, + showResultsPanel: Boolean, + onQueryChange: (String) -> Unit, + onClose: () -> Unit, + onToggleResults: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(80) + runCatching { focusRequester.requestFocus() } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(0.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_close_search_desc", "Exit search and go back to the reader"), + onClick = onClose, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Close, contentDescription = readerString("content_desc_close_search", "Close search")) + } + SharedStableOutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(readerString("desktop_search_in_pdf", "Search in PDF")) }, + singleLine = true, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + trailingIcon = if (query.isNotEmpty()) { + { + ReaderTooltipIconButton( + tooltip = readerString("tooltip_clear_search_desc", "Erase your current search query and start over"), + onClick = { onQueryChange("") } + ) { + Icon(Icons.Default.Close, contentDescription = readerString("tooltip_clear_search", "Clear search")) + } + } + } else { + null + }, + selectionKey = "desktop-pdf-search" + ) + val resultsTooltip = if (showResultsPanel) { + readerString("tooltip_hide_results_desc", "Collapse the search results panel") + } else { + readerString("tooltip_show_results_desc", "Expand the panel to see all search matches") + } + ReaderTooltipIconButton( + tooltip = resultsTooltip, + onClick = onToggleResults, + modifier = Modifier.size(36.dp) + ) { + Icon( + if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (showResultsPanel) { + readerString("desktop_hide_search_results", "Hide search results") + } else { + readerString("desktop_show_search_results", "Show search results") + } + ) + } + } + } +} + +@Composable +internal fun BoxScope.DesktopPdfSearchOverlay( + isSearchActive: Boolean, + showResultsPanel: Boolean, + query: String, + results: List, + activeSearchIndex: Int, + highlightMode: SearchHighlightMode, + isIndexing: Boolean, + indexedPageCount: Int, + pageCount: Int, + onResultClick: (Int) -> Unit, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleHighlightMode: () -> Unit +) { + AnimatedVisibility( + visible = isSearchActive && showResultsPanel, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + modifier = Modifier.fillMaxSize().zIndex(30f) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column(Modifier.fillMaxSize()) { + if (isIndexing) { + val progress = indexedPageCount.toFloat() / pageCount.coerceAtLeast(1).toFloat() + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) { + Text( + readerString( + "desktop_indexing_pages_format", + "Indexing %1\$d/%2\$d pages", + indexedPageCount.coerceAtMost(pageCount), + pageCount + ), + style = MaterialTheme.typography.bodySmall + ) + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp) + ) + } + } + } + + when { + query.isBlank() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(readerString("desktop_type_to_search_pdf", "Type to search this PDF"), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + results.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (isIndexing) { + readerString("desktop_no_matches_indexed_pages_yet", "No matches in indexed pages yet") + } else { + readerString("desktop_no_matches", "No matches") + }, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + Text( + when { + isIndexing -> readerString("desktop_matches_so_far_format", "%1\$d matches so far", results.size) + else -> readerString("desktop_matches_format", "%1\$d matches", results.size) + }, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + HorizontalDivider() + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed( + items = results, + key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" } + ) { index, result -> + Surface( + modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) }, + color = if (index == activeSearchIndex) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + readerString("pdf_page_short", "Page %1\$d", result.pageIndex + 1), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + result.preview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + HorizontalDivider() + } + } + } + } + } + } + } + + AnimatedVisibility( + visible = isSearchActive && !showResultsPanel && results.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 18.dp) + .zIndex(31f) + ) { + DesktopPdfSearchNavigationPill( + activeSearchIndex = activeSearchIndex, + resultCount = results.size, + highlightMode = highlightMode, + onShowResults = onShowResults, + onPrevious = onPrevious, + onNext = onNext, + onToggleHighlightMode = onToggleHighlightMode + ) + } +} + +@Composable +private fun DesktopPdfSearchNavigationPill( + activeSearchIndex: Int, + resultCount: Int, + highlightMode: SearchHighlightMode, + onShowResults: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onToggleHighlightMode: () -> Unit +) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ReaderTooltipIconButton( + tooltip = if (highlightMode == SearchHighlightMode.ALL) { + readerString("desktop_show_current_match_only", "Show current match only") + } else { + readerString("desktop_show_all_search_matches", "Show all search matches") + }, + onClick = onToggleHighlightMode, + modifier = Modifier.size(36.dp) + ) { + Icon( + if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = readerString("content_desc_toggle_search_highlights", "Toggle search highlights"), + tint = if (highlightMode == SearchHighlightMode.ALL) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + ReaderTooltipIconButton( + tooltip = readerString("tooltip_prev_result_desc", "Jump to the previous search match in the document"), + onClick = onPrevious, + enabled = resultCount > 0, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_search_result", "Previous search result")) + } + Text( + text = if (activeSearchIndex in 0 until resultCount) { + "${activeSearchIndex + 1}/$resultCount" + } else { + readerString("desktop_matches_format", "%1\$d matches", resultCount) + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp) + ) + ReaderTooltipIconButton( + tooltip = readerString("tooltip_next_result_desc", "Jump to the next search match in the document"), + onClick = onNext, + enabled = resultCount > 0, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_search_result", "Next search result")) + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt new file mode 100644 index 0000000..59c88bf --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActions.kt @@ -0,0 +1,500 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.isSpecified +import org.dueattendant149.bookreader.shared.SaveMode +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.SHARED_PDF_PAGE_BREAK_CHAR +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationExportMapper +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextPageRelativeFontSize +import java.awt.FileDialog +import java.awt.Font +import java.awt.Frame +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.font.LineBreakMeasurer +import java.awt.font.TextAttribute +import java.awt.image.BufferedImage +import java.awt.print.Book +import java.awt.print.PageFormat +import java.awt.print.Printable +import java.awt.print.PrinterJob +import java.io.File +import java.text.AttributedString +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.random.Random + +private const val DesktopPdfTextBoxPaddingPx = 8f +private const val DesktopPdfTextRasterPointScale = 3f +private const val DesktopPdfTextRasterMinPageHeightPx = 1200f +private const val DesktopPdfTextRasterMaxPageHeightPx = 3600f +private const val DesktopPdfRichTextMarginX = 0.1f +private const val DesktopPdfRichTextMarginY = 0.08f + +internal data class DesktopPdfFileActionNotice( + val title: String, + val message: String, + val isError: Boolean = false +) + +internal data class DesktopPdfRasterOverlay( + val pageIndex: Int, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val width: Int, + val height: Int, + val pixels: IntArray +) + +internal fun desktopSuggestedPdfFilename( + originalName: String?, + isAnnotated: Boolean, + shortId: String = Random.nextInt(1000, 9999).toString() +): String { + val base = originalName + ?.substringBeforeLast('.') + ?.takeIf { it.isNotBlank() } + ?: "Document" + val safeBase = base.replace("[^a-zA-Z0-9._-]".toRegex(), "_") + .take(50) + .ifBlank { "Document" } + val suffix = if (isAnnotated) "_annotated" else "" + return "${safeBase}${suffix}_$shortId.pdf" +} + +internal fun hasExportableDesktopPdfAnnotations( + annotations: List, + richTextPageLayouts: List +): Boolean { + return SharedPdfAnnotationExportMapper.build(annotations).hasPdfAnnotations || + annotations.any { annotation -> + if (annotation.kind != PdfAnnotationKind.HIGHLIGHT) return@any false + val startIndex = annotation.rangeStartIndex ?: return@any false + val endIndex = annotation.rangeEndIndex ?: return@any false + endIndex >= startIndex + } || + annotations.any { annotation -> + annotation.kind == PdfAnnotationKind.TEXT && + annotation.bounds != null && + annotation.text.isNotBlank() + } || + richTextPageLayouts.any { layout -> + layout.visibleText.text + .replace(SHARED_PDF_PAGE_BREAK_CHAR.toString(), "") + .isNotBlank() + } +} + +internal fun shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady: Boolean, + annotations: List, + richTextPageLayouts: List +): Boolean { + return !sidecarsReady || hasExportableDesktopPdfAnnotations(annotations, richTextPageLayouts) +} + +internal fun chooseSavePdfFile(defaultFileName: String): File? { + val dialog = FileDialog(null as Frame?, "Save PDF", FileDialog.SAVE).apply { + file = defaultFileName + isVisible = true + } + val directory = dialog.directory ?: return null + val fileName = dialog.file ?: return null + val selected = File(directory, fileName) + return if (selected.extension.equals("pdf", ignoreCase = true)) { + selected + } else { + File(selected.parentFile, "${selected.name}.pdf") + } +} + +internal fun saveDesktopPdfCopy( + document: DesktopPdfDocument, + target: File, + mode: SaveMode, + annotations: List = emptyList(), + richTextPageLayouts: List = emptyList() +) { + val source = File(document.path) + require(source.isFile) { "The original PDF is not available as a local file." } + require(document.formatLabel == "PDF") { "Only PDF files can be saved as PDF copies." } + target.parentFile?.mkdirs() + + when (mode) { + SaveMode.ORIGINAL -> { + if (source.canonicalFile == target.canonicalFile) return + source.copyTo(target, overwrite = true) + } + SaveMode.ANNOTATED -> { + require(source.canonicalFile != target.canonicalFile) { + "Choose a different file name for an annotated copy." + } + DesktopPdfium.exportAnnotatedPdf( + document = document, + destination = target, + annotations = annotations, + richTextPageLayouts = richTextPageLayouts + ) + } + } +} + +internal fun printDesktopPdfDocument(document: DesktopPdfDocument) { + require(document.formatLabel == "PDF") { "Only PDF files can be printed from the PDF reader." } + val job = PrinterJob.getPrinterJob() + job.jobName = "Episteme - ${document.title}" + val printableBook = Book() + val pageFormat = job.defaultPage() + for (pageIndex in 0 until document.pageCount) { + printableBook.append( + Printable { graphics, format, _ -> + drawPdfPageForPrint(document, pageIndex, graphics as Graphics2D, format) + Printable.PAGE_EXISTS + }, + pageFormat + ) + } + job.setPageable(printableBook) + if (job.printDialog()) { + job.print() + } +} + +internal fun buildDesktopPdfRasterOverlays( + annotations: List, + richTextPageLayouts: List, + pageSizes: List +): List { + val overlays = mutableListOf() + annotations.mapNotNullTo(overlays) { annotation -> + if (annotation.kind != PdfAnnotationKind.TEXT) return@mapNotNullTo null + renderDesktopTextBoxOverlay(annotation, pageSizes.getOrNull(annotation.pageIndex) ?: return@mapNotNullTo null) + } + richTextPageLayouts.mapNotNullTo(overlays) { layout -> + renderDesktopRichTextOverlay(layout, pageSizes.getOrNull(layout.pageIndex) ?: return@mapNotNullTo null) + } + return overlays +} + +private fun drawPdfPageForPrint( + document: DesktopPdfDocument, + pageIndex: Int, + graphics: Graphics2D, + pageFormat: PageFormat +) { + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return + val availableWidth = pageFormat.imageableWidth + val availableHeight = pageFormat.imageableHeight + val fit = minOf( + availableWidth / pageSize.width.toDouble(), + availableHeight / pageSize.height.toDouble() + ).coerceAtLeast(0.01) + val drawWidth = pageSize.width * fit + val drawHeight = pageSize.height * fit + val drawX = pageFormat.imageableX + (availableWidth - drawWidth) / 2.0 + val drawY = pageFormat.imageableY + (availableHeight - drawHeight) / 2.0 + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex, scale = 2f, renderAnnotations = true) + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) + graphics.drawImage( + image, + drawX.roundToInt(), + drawY.roundToInt(), + drawWidth.roundToInt().coerceAtLeast(1), + drawHeight.roundToInt().coerceAtLeast(1), + null + ) +} + +private fun renderDesktopTextBoxOverlay( + annotation: SharedPdfAnnotation, + pageSize: DesktopPdfPageSize +): DesktopPdfRasterOverlay? { + val text = annotation.text.sanitizeDesktopRasterText() + val bounds = annotation.bounds ?: return null + if (annotation.pageIndex < 0 || text.isBlank()) return null + + val left = bounds.left.coerceIn(0f, 1f) + val top = bounds.top.coerceIn(0f, 1f) + val right = bounds.right.coerceIn(left, 1f) + val bottom = bounds.bottom.coerceIn(top, 1f) + if (right - left <= 0f || bottom - top <= 0f) return null + + val pageHeightPx = pageSize.exportHeightPx() + val pageWidthPx = pageHeightPx * pageSize.aspect + val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1) + val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1) + val paddingPx = DesktopPdfTextBoxPaddingPx + .coerceAtMost((minOf(bitmapWidth, bitmapHeight) / 2f).coerceAtLeast(0f)) + val contentWidth = (bitmapWidth - paddingPx * 2f).roundToInt().coerceAtLeast(1) + val fontSizePx = (annotation.sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f) + val bitmap = BufferedImage(bitmapWidth, bitmapHeight, BufferedImage.TYPE_INT_ARGB) + + val plainText = AnnotatedString(text) + val baseStyle = DesktopTextRasterStyle( + color = annotation.colorArgb.toAwtColor(), + background = annotation.backgroundArgb.toAwtColor().takeIf { it.alpha > 0 }, + fontSize = fontSizePx, + isBold = annotation.isBold, + isItalic = annotation.isItalic, + isUnderline = annotation.isUnderline, + isStrikeThrough = annotation.isStrikeThrough, + fontName = annotation.fontName + ) + drawDesktopAttributedText( + bitmap = bitmap, + text = plainText, + baseStyle = baseStyle, + width = contentWidth, + translateX = paddingPx, + translateY = paddingPx + ) + return bitmap.toDesktopRasterOverlay(annotation.pageIndex, left, top, right, bottom) +} + +private fun renderDesktopRichTextOverlay( + layout: SharedPdfRichPageLayout, + pageSize: DesktopPdfPageSize +): DesktopPdfRasterOverlay? { + val visibleText = layout.visibleText.withoutTrailingDesktopPageBreak() + if (layout.pageIndex < 0 || visibleText.text.isBlank()) return null + + val pageHeightPx = layout.pageHeightPx.takeIf { it > 0f } ?: pageSize.exportHeightPx() + val pageWidthPx = pageHeightPx * pageSize.aspect + val left = DesktopPdfRichTextMarginX + val top = DesktopPdfRichTextMarginY + val right = 1f - DesktopPdfRichTextMarginX + val bottom = 1f - DesktopPdfRichTextMarginY + val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1) + val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1) + val bitmap = BufferedImage(bitmapWidth, bitmapHeight, BufferedImage.TYPE_INT_ARGB) + + drawDesktopAttributedText( + bitmap = bitmap, + text = visibleText, + baseStyle = DesktopTextRasterStyle( + color = java.awt.Color.BLACK, + background = null, + fontSize = 16f, + isBold = false, + isItalic = false, + isUnderline = false, + isStrikeThrough = false, + fontName = null + ), + width = bitmapWidth, + translateX = 0f, + translateY = 0f + ) + return bitmap.toDesktopRasterOverlay(layout.pageIndex, left, top, right, bottom) +} + +private fun drawDesktopAttributedText( + bitmap: BufferedImage, + text: AnnotatedString, + baseStyle: DesktopTextRasterStyle, + width: Int, + translateX: Float, + translateY: Float +) { + val graphics = bitmap.createGraphics() + try { + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + graphics.clipRect(0, 0, bitmap.width, bitmap.height) + var paragraphStart = 0 + var drawY = translateY + val raw = text.text.sanitizeDesktopRasterTextPreservingLength() + raw.split('\n').forEach { paragraph -> + val paragraphEnd = paragraphStart + paragraph.length + val attributed = attributedParagraph( + paragraph = paragraph, + paragraphStart = paragraphStart, + text = text, + baseStyle = baseStyle + ) + val iterator = attributed.iterator + val measurer = LineBreakMeasurer(iterator, graphics.fontRenderContext) + while (measurer.position < iterator.endIndex && drawY < bitmap.height) { + val layout = measurer.nextLayout(width.toFloat()) + drawY += layout.ascent + layout.draw(graphics, translateX, drawY) + drawY += layout.descent + layout.leading + } + if (paragraph.isEmpty()) { + drawY += baseStyle.fontSize * 1.2f + } + paragraphStart = paragraphEnd + 1 + } + } finally { + graphics.dispose() + } +} + +private fun attributedParagraph( + paragraph: String, + paragraphStart: Int, + text: AnnotatedString, + baseStyle: DesktopTextRasterStyle +): AttributedString { + val safeParagraph = paragraph.ifEmpty { " " } + val attributed = AttributedString(safeParagraph) + attributed.applyRasterStyle(baseStyle, 0, safeParagraph.length) + text.spanStyles.forEach { range -> + val start = maxOf(range.start, paragraphStart) - paragraphStart + val end = minOf(range.end, paragraphStart + paragraph.length) - paragraphStart + if (start < end) { + attributed.applyRasterStyle(range.item.toDesktopTextRasterStyle(baseStyle), start, end) + } + } + return attributed +} + +private fun AttributedString.applyRasterStyle(style: DesktopTextRasterStyle, start: Int, end: Int) { + val safeStart = start.coerceAtLeast(0) + val safeEnd = end.coerceAtLeast(safeStart) + if (safeStart >= safeEnd) return + addAttribute(TextAttribute.FONT, style.awtFont(), safeStart, safeEnd) + addAttribute(TextAttribute.FOREGROUND, style.color, safeStart, safeEnd) + style.background?.let { addAttribute(TextAttribute.BACKGROUND, it, safeStart, safeEnd) } + if (style.isUnderline) { + addAttribute(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_ON, safeStart, safeEnd) + } + if (style.isStrikeThrough) { + addAttribute(TextAttribute.STRIKETHROUGH, TextAttribute.STRIKETHROUGH_ON, safeStart, safeEnd) + } +} + +private data class DesktopTextRasterStyle( + val color: java.awt.Color, + val background: java.awt.Color?, + val fontSize: Float, + val isBold: Boolean, + val isItalic: Boolean, + val isUnderline: Boolean, + val isStrikeThrough: Boolean, + val fontName: String? +) { + fun awtFont(): Font { + val style = (if (isBold) Font.BOLD else Font.PLAIN) or (if (isItalic) Font.ITALIC else Font.PLAIN) + return Font(awtFontFamily(fontName), style, fontSize.roundToInt().coerceAtLeast(1)) + } +} + +private fun SpanStyle.toDesktopTextRasterStyle(base: DesktopTextRasterStyle): DesktopTextRasterStyle { + val color = this.color.takeUnless { it == Color.Unspecified }?.toAwtColor() ?: base.color + val background = this.background.takeUnless { it == Color.Unspecified || it.alpha <= 0f }?.toAwtColor() + ?: base.background + val fontSize = if (this.fontSize.isSpecified && this.fontSize.isSp) { + this.fontSize.value + } else { + base.fontSize + } + val fontWeight = this.fontWeight?.weight ?: if (base.isBold) 700 else 400 + return base.copy( + color = color, + background = background, + fontSize = fontSize, + isBold = fontWeight >= 600, + isItalic = this.fontStyle == FontStyle.Italic || base.isItalic, + isUnderline = this.textDecoration?.contains(TextDecoration.Underline) == true || + base.isUnderline, + isStrikeThrough = this.textDecoration?.contains(TextDecoration.LineThrough) == true || + base.isStrikeThrough + ) +} + +private fun awtFontFamily(fontName: String?): String { + return when (fontName?.lowercase()) { + "serif" -> Font.SERIF + "monospace" -> Font.MONOSPACED + else -> Font.SANS_SERIF + } +} + +private fun BufferedImage.toDesktopRasterOverlay( + pageIndex: Int, + boundsLeft: Float, + boundsTop: Float, + boundsRight: Float, + boundsBottom: Float +): DesktopPdfRasterOverlay? { + val allPixels = IntArray(width * height) + getRGB(0, 0, width, height, allPixels, 0, width) + + var minX = width + var minY = height + var maxX = -1 + var maxY = -1 + for (y in 0 until height) { + val rowOffset = y * width + for (x in 0 until width) { + if ((allPixels[rowOffset + x] ushr 24) != 0) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + if (maxX < minX || maxY < minY) return null + + val cropWidth = maxX - minX + 1 + val cropHeight = maxY - minY + 1 + val cropped = IntArray(cropWidth * cropHeight) + for (row in 0 until cropHeight) { + System.arraycopy( + allPixels, + (minY + row) * width + minX, + cropped, + row * cropWidth, + cropWidth + ) + } + + val boundsWidth = boundsRight - boundsLeft + val boundsHeight = boundsBottom - boundsTop + return DesktopPdfRasterOverlay( + pageIndex = pageIndex, + left = boundsLeft + boundsWidth * (minX.toFloat() / width), + top = boundsTop + boundsHeight * (minY.toFloat() / height), + right = boundsLeft + boundsWidth * ((maxX + 1).toFloat() / width), + bottom = boundsTop + boundsHeight * ((maxY + 1).toFloat() / height), + width = cropWidth, + height = cropHeight, + pixels = cropped + ) +} + +private val DesktopPdfPageSize.aspect: Float + get() = if (width > 0f && height > 0f) width / height else 612f / 792f + +private fun DesktopPdfPageSize.exportHeightPx(): Float { + return (height * DesktopPdfTextRasterPointScale) + .coerceIn(DesktopPdfTextRasterMinPageHeightPx, DesktopPdfTextRasterMaxPageHeightPx) +} + +private fun AnnotatedString.withoutTrailingDesktopPageBreak(): AnnotatedString = + if (text.lastOrNull() == SHARED_PDF_PAGE_BREAK_CHAR) subSequence(0, length - 1) else this + +private fun String.sanitizeDesktopRasterText(): String = + replace(SHARED_PDF_PAGE_BREAK_CHAR, '\n') + .replace("\u200B", "") + .replace('\r', ' ') + +private fun String.sanitizeDesktopRasterTextPreservingLength(): String = + replace(SHARED_PDF_PAGE_BREAK_CHAR, '\n') + .replace('\r', ' ') + +private fun Int.toAwtColor(): java.awt.Color = java.awt.Color(this, true) + +private fun Color.toAwtColor(): java.awt.Color = java.awt.Color(toArgb(), true) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt new file mode 100644 index 0000000..780bc1b --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfInspectorUi.kt @@ -0,0 +1,444 @@ +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.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.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.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.BuiltInPdfReaderThemes +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextStyleConfig +import org.dueattendant149.bookreader.shared.pdf.currentSharedPdfTextStyleConfig +import org.dueattendant149.bookreader.shared.pdf.updateCurrentSharedPdfTextStyle +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.ui.SharedPdfHighlighterPaletteEditor +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextAnnotationDock +import org.dueattendant149.bookreader.shared.ui.SharedReaderThemeControls +import org.dueattendant149.bookreader.shared.ui.SharedReaderVerticalScrollbar +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedAcceleratedLazyWheelScroll + +@Composable +internal fun DesktopPdfInspectorPanel( + document: DesktopPdfDocument, + displayMode: PdfDisplayMode, + pdfReaderSettings: ReaderSettings, + appThemeControls: (@Composable () -> Unit)? = null, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, + customTextureIds: List, + onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, + onReaderSettingsChange: (ReaderSettings) -> Unit, + selectedTool: PdfInkTool, + isRichTextMode: Boolean, + pdfHighlighterPalette: SharedPdfHighlighterPalette, + effectiveTextStyleConfig: SharedPdfTextStyleConfig, + richTextController: SharedPdfRichTextController, + pdfExtrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + cloudTtsFeatureAvailable: Boolean, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + onDisplayModeSelected: (PdfDisplayMode) -> Unit, + onRichTextModeToggle: () -> Unit, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, + onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, + onCloudTtsClearCache: () -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit +) { + val inspectorTabs = remember(appThemeControls != null) { + desktopPdfInspectorTabs(appThemeControlsAvailable = appThemeControls != null) + } + var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VISUAL) } + LaunchedEffect(inspectorTabs) { + if (selectedPdfInspectorTab !in inspectorTabs) { + selectedPdfInspectorTab = DesktopPdfInspectorTab.VISUAL.takeIf { it in inspectorTabs } + ?: inspectorTabs.first() + } + } + val appThemeInspectorListState = rememberLazyListState() + val appearanceInspectorListState = rememberLazyListState() + val visualInspectorListState = rememberLazyListState() + val markupInspectorListState = rememberLazyListState() + val ttsInspectorListState = rememberLazyListState() + val pdfInspectorListState = when (selectedPdfInspectorTab) { + DesktopPdfInspectorTab.APP_THEME -> appThemeInspectorListState + DesktopPdfInspectorTab.APPEARANCE -> appearanceInspectorListState + DesktopPdfInspectorTab.VISUAL -> visualInspectorListState + DesktopPdfInspectorTab.MARKUP -> markupInspectorListState + DesktopPdfInspectorTab.TTS -> ttsInspectorListState + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(0.dp) + ) { + Column(modifier = Modifier.fillMaxSize()) { + DesktopPdfInspectorHeader( + tabs = inspectorTabs, + selectedTab = selectedPdfInspectorTab, + onTabSelected = { selectedPdfInspectorTab = it } + ) + HorizontalDivider() + DesktopPdfInspectorContent( + document = document, + displayMode = displayMode, + pdfReaderSettings = pdfReaderSettings, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, + customTextureIds = customTextureIds, + onImportTexture = onImportTexture, + onReaderSettingsChange = onReaderSettingsChange, + selectedTool = selectedTool, + isRichTextMode = isRichTextMode, + pdfHighlighterPalette = pdfHighlighterPalette, + effectiveTextStyleConfig = effectiveTextStyleConfig, + richTextController = richTextController, + pdfExtrasState = pdfExtrasState, + aiByokSettings = aiByokSettings, + cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, + ttsReplacementPreferences = ttsReplacementPreferences, + selectedTab = selectedPdfInspectorTab, + listState = pdfInspectorListState, + onDisplayModeSelected = onDisplayModeSelected, + onRichTextModeToggle = onRichTextModeToggle, + onHighlighterPaletteChange = onHighlighterPaletteChange, + onTextStyleChange = onTextStyleChange, + onCloudTtsClearCache = onCloudTtsClearCache, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange + ) + } + } +} + +@Composable +private fun DesktopPdfInspectorHeader( + tabs: List, + selectedTab: DesktopPdfInspectorTab, + onTabSelected: (DesktopPdfInspectorTab) -> Unit +) { + ScrollableTabRow( + selectedTabIndex = tabs.indexOf(selectedTab).coerceAtLeast(0), + edgePadding = 0.dp, + modifier = Modifier.fillMaxWidth() + ) { + tabs.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { onTabSelected(tab) }, + icon = { + Icon( + tab.icon(), + contentDescription = null + ) + }, + text = { + Text( + tab.localizedTitle(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } +} + +@Composable +private fun ColumnScope.DesktopPdfInspectorContent( + document: DesktopPdfDocument, + displayMode: PdfDisplayMode, + pdfReaderSettings: ReaderSettings, + appThemeControls: (@Composable () -> Unit)?, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, + customTextureIds: List, + onImportTexture: ((ReaderSettings) -> ReaderSettings?)?, + onReaderSettingsChange: (ReaderSettings) -> Unit, + selectedTool: PdfInkTool, + isRichTextMode: Boolean, + pdfHighlighterPalette: SharedPdfHighlighterPalette, + effectiveTextStyleConfig: SharedPdfTextStyleConfig, + richTextController: SharedPdfRichTextController, + pdfExtrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + cloudTtsFeatureAvailable: Boolean, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + selectedTab: DesktopPdfInspectorTab, + listState: LazyListState, + onDisplayModeSelected: (PdfDisplayMode) -> Unit, + onRichTextModeToggle: () -> Unit, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, + onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit, + onCloudTtsClearCache: () -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit +) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(listState, multiplier = 2.8f) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + when (selectedTab) { + DesktopPdfInspectorTab.APP_THEME -> { + appThemeControls?.let { controls -> + item { + controls() + } + } + } + DesktopPdfInspectorTab.APPEARANCE -> { + item { + DesktopPdfInspectorSection(readerString("desktop_pdf_theme", "PDF theme")) { + SharedReaderThemeControls( + settings = pdfReaderSettings, + builtInThemes = BuiltInPdfReaderThemes, + customThemes = customReaderThemes, + onCustomThemesChange = onCustomReaderThemesChange, + customTextureIds = customTextureIds, + onImportTexture = onImportTexture, + texturePreviewContent = { textureId, previewModifier -> + DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) + }, + onSettingsChange = onReaderSettingsChange + ) + } + } + } + DesktopPdfInspectorTab.VISUAL -> { + item { + DesktopPdfInspectorSection(readerString("visual_options_title", "Visual options")) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + FilterChip( + selected = displayMode == PdfDisplayMode.PAGINATION && !pdfReaderSettings.rightToLeftPagination, + onClick = { + onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = false)) + onDisplayModeSelected(PdfDisplayMode.PAGINATION) + }, + label = { Text(readerString("menu_reading_mode_paginated", "Paginated (left-to-right)")) } + ) + FilterChip( + selected = displayMode == PdfDisplayMode.PAGINATION && pdfReaderSettings.rightToLeftPagination, + onClick = { + onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = true)) + onDisplayModeSelected(PdfDisplayMode.PAGINATION) + }, + label = { Text(readerString("menu_right_to_left_pagination", "Paginated (right-to-left)")) } + ) + FilterChip( + selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL, + onClick = { onDisplayModeSelected(PdfDisplayMode.VERTICAL_SCROLL) }, + label = { Text(readerString("desktop_scroll", "Scroll")) } + ) + } + if (displayMode == PdfDisplayMode.PAGINATION) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = pdfReaderSettings.pageSpreadMode == ReaderPageSpreadMode.SINGLE, + onClick = { + onReaderSettingsChange( + pdfReaderSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE) + ) + }, + label = { Text(readerString("visual_options_pdf_spread_single", "Single page")) } + ) + FilterChip( + selected = pdfReaderSettings.pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE, + onClick = { + onReaderSettingsChange( + pdfReaderSettings.copy(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE) + ) + }, + label = { Text(readerString("visual_options_pdf_spread_two", "Two pages")) } + ) + } + if (pdfReaderSettings.pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE) { + DesktopPdfVisualOptionSwitch( + title = readerString("visual_options_pdf_first_page_alone", "First page alone"), + description = readerString( + "visual_options_pdf_first_page_alone_desc", + "Starts facing-page spreads after the cover page." + ), + checked = pdfReaderSettings.pdfFirstPageStandaloneInSpread, + onCheckedChange = { enabled -> + onReaderSettingsChange( + pdfReaderSettings.copy(pdfFirstPageStandaloneInSpread = enabled) + ) + } + ) + } + } + DesktopPdfVisualOptionSwitch( + title = readerString("visual_options_remove_page_gap", "Remove gap between pages"), + description = readerString( + "desktop_remove_gap_between_pages_desc", + "Applies to vertical reading and two-page spreads." + ), + checked = !pdfReaderSettings.pdfVerticalPageGapVisible, + onCheckedChange = { removeGap -> + onReaderSettingsChange( + pdfReaderSettings.copy(pdfVerticalPageGapVisible = !removeGap) + ) + } + ) + DesktopPdfVisualOptionSwitch( + title = readerString("visual_options_hide_page_number_overlay", "Hide page number overlay"), + description = readerString( + "visual_options_hide_page_number_overlay_desc", + "Removes the small page count label from each page." + ), + checked = !pdfReaderSettings.pdfPageNumberOverlayVisible, + onCheckedChange = { hideOverlay -> + onReaderSettingsChange( + pdfReaderSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay) + ) + } + ) + } + } + } + DesktopPdfInspectorTab.MARKUP -> { + item { + DesktopPdfInspectorSection(readerString("desktop_document_text", "Document text")) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = isRichTextMode, + onClick = onRichTextModeToggle, + label = { Text(readerString("desktop_document_text", "Document text")) } + ) + } + } + } + item { + DesktopPdfInspectorSection(readerString("desktop_highlighter_palette", "Highlighter palette")) { + SharedPdfHighlighterPaletteEditor( + palette = pdfHighlighterPalette, + onPaletteChange = onHighlighterPaletteChange + ) + } + } + if (isRichTextMode || selectedTool == PdfInkTool.TEXT) { + item { + DesktopPdfInspectorSection(readerString("desktop_text_style", "Text style")) { + SharedPdfTextAnnotationDock( + style = if (isRichTextMode) { + richTextController.currentSharedPdfTextStyleConfig() + } else { + effectiveTextStyleConfig + }, + onStyleChange = { style -> + if (isRichTextMode) { + richTextController.updateCurrentSharedPdfTextStyle(style) + } else { + onTextStyleChange(style) + } + } + ) + } + } + } + } + DesktopPdfInspectorTab.TTS -> { + item { + DesktopPdfTtsPanel( + extrasState = pdfExtrasState, + aiByokSettings = aiByokSettings, + cloudTtsFeatureAvailable = cloudTtsFeatureAvailable, + onCloudTtsClearCache = onCloudTtsClearCache, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = document.path, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange + ) + } + } + } + } + SharedReaderVerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } +} + +@Composable +private fun DesktopPdfInspectorTab.localizedTitle(): String { + return when (this) { + DesktopPdfInspectorTab.APP_THEME -> readerString("app_theme_title", "App theme") + DesktopPdfInspectorTab.APPEARANCE -> readerString("desktop_pdf_theme", "PDF theme") + DesktopPdfInspectorTab.VISUAL -> readerString("visual_options_title", "Visual") + DesktopPdfInspectorTab.MARKUP -> readerString("desktop_markup", "Markup") + DesktopPdfInspectorTab.TTS -> readerString("menu_tts_settings", "TTS") + } +} + +private fun DesktopPdfInspectorTab.icon(): ImageVector { + return when (this) { + DesktopPdfInspectorTab.APP_THEME -> Icons.Default.Palette + DesktopPdfInspectorTab.APPEARANCE -> Icons.Default.Palette + DesktopPdfInspectorTab.VISUAL -> Icons.Default.Tune + DesktopPdfInspectorTab.MARKUP -> Icons.Default.Edit + DesktopPdfInspectorTab.TTS -> Icons.AutoMirrored.Filled.VolumeUp + } +} + +private fun desktopPdfInspectorTabs(appThemeControlsAvailable: Boolean): List { + return buildList { + add(DesktopPdfInspectorTab.APPEARANCE) + if (appThemeControlsAvailable) add(DesktopPdfInspectorTab.APP_THEME) + add(DesktopPdfInspectorTab.VISUAL) + add(DesktopPdfInspectorTab.TTS) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt new file mode 100644 index 0000000..a5b3adb --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfKeyCommands.kt @@ -0,0 +1,97 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import java.awt.event.KeyEvent as AwtKeyEvent + +internal enum class DesktopPdfKeyCommand { + PREVIOUS_PAGE, + NEXT_PAGE, + SCROLL_UP, + SCROLL_DOWN, + FIRST_PAGE, + LAST_PAGE, + SEARCH, + ZOOM_IN, + ZOOM_OUT, + EXIT_FULLSCREEN +} + +internal fun KeyEvent.desktopPdfKeyCommandOrNull( + fullscreen: Boolean, + editingText: Boolean, + rightToLeftPagination: Boolean = false +): DesktopPdfKeyCommand? { + if (type != KeyEventType.KeyDown) return null + if (fullscreen && key == Key.Escape) { + return DesktopPdfKeyCommand.EXIT_FULLSCREEN + } + if (editingText && !isCtrlPressed) { + return null + } + return when { + key == Key.DirectionLeft -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.NEXT_PAGE + } else { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } + key == Key.DirectionRight -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } else { + DesktopPdfKeyCommand.NEXT_PAGE + } + key == Key.DirectionUp -> DesktopPdfKeyCommand.SCROLL_UP + key == Key.DirectionDown -> DesktopPdfKeyCommand.SCROLL_DOWN + key == Key.PageUp -> DesktopPdfKeyCommand.PREVIOUS_PAGE + key == Key.PageDown -> DesktopPdfKeyCommand.NEXT_PAGE + key == Key.MoveHome -> DesktopPdfKeyCommand.FIRST_PAGE + key == Key.MoveEnd -> DesktopPdfKeyCommand.LAST_PAGE + isCtrlPressed && key == Key.F -> DesktopPdfKeyCommand.SEARCH + isCtrlPressed && key == Key.Equals -> DesktopPdfKeyCommand.ZOOM_IN + isCtrlPressed && key == Key.Minus -> DesktopPdfKeyCommand.ZOOM_OUT + else -> null + } +} + +internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull( + fullscreen: Boolean, + editingText: Boolean, + rightToLeftPagination: Boolean = false +): DesktopPdfKeyCommand? { + if (id != AwtKeyEvent.KEY_PRESSED) return null + if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) { + return DesktopPdfKeyCommand.EXIT_FULLSCREEN + } + if (editingText && !isControlDown) { + return null + } + return when (keyCode) { + AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.NEXT_PAGE + } else { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } + AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) { + DesktopPdfKeyCommand.PREVIOUS_PAGE + } else { + DesktopPdfKeyCommand.NEXT_PAGE + } + AwtKeyEvent.VK_UP -> DesktopPdfKeyCommand.SCROLL_UP + AwtKeyEvent.VK_DOWN -> DesktopPdfKeyCommand.SCROLL_DOWN + AwtKeyEvent.VK_PAGE_UP -> DesktopPdfKeyCommand.PREVIOUS_PAGE + AwtKeyEvent.VK_PAGE_DOWN -> DesktopPdfKeyCommand.NEXT_PAGE + AwtKeyEvent.VK_HOME -> DesktopPdfKeyCommand.FIRST_PAGE + AwtKeyEvent.VK_END -> DesktopPdfKeyCommand.LAST_PAGE + AwtKeyEvent.VK_F -> if (isControlDown) DesktopPdfKeyCommand.SEARCH else null + AwtKeyEvent.VK_EQUALS, + AwtKeyEvent.VK_PLUS, + AwtKeyEvent.VK_ADD -> if (isControlDown) DesktopPdfKeyCommand.ZOOM_IN else null + AwtKeyEvent.VK_MINUS, + AwtKeyEvent.VK_SUBTRACT -> if (isControlDown) DesktopPdfKeyCommand.ZOOM_OUT else null + else -> null + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt new file mode 100644 index 0000000..d9ee5b1 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationUi.kt @@ -0,0 +1,786 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +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.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle +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.PdfTocEntry +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.ui.SharedReaderVerticalScrollbar +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedAcceleratedLazyWheelScroll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +internal fun DesktopPdfJumpHistoryControls( + visible: Boolean, + modifier: Modifier = Modifier, + backPage: Int?, + forwardPage: Int?, + onBack: () -> Unit, + onForward: () -> Unit, + onClear: () -> Unit +) { + val hasJumpTargets = backPage != null || forwardPage != null + AnimatedVisibility( + visible = visible && hasJumpTargets, + enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), + exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(), + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + onClick = onBack, + enabled = backPage != null, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = readerString("content_desc_jump_back", "Jump back"), + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + backPage?.let { readerString("desktop_pdf_compact_page_number", "p. %1\$d", it + 1) } ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + TextButton( + onClick = onClear, + modifier = Modifier.weight(0.8f) + ) { + Icon( + Icons.Default.Close, + contentDescription = readerString("desktop_clear_jump_history", "Clear jump history"), + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text(readerString("action_clear", "Clear"), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + TextButton( + onClick = onForward, + enabled = forwardPage != null, + modifier = Modifier.weight(1f) + ) { + Text( + forwardPage?.let { readerString("desktop_pdf_compact_page_number", "p. %1\$d", it + 1) } ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(4.dp)) + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = readerString("content_desc_jump_forward", "Jump forward"), + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +internal fun desktopPdfTocParentIndices(toc: List): Set { + return toc.indices.filter { index -> + val next = toc.getOrNull(index + 1) + next != null && next.nestLevel > toc[index].nestLevel + }.toSet() +} + +internal fun desktopPdfTocAncestorIndices( + toc: List, + originalIndex: Int +): Set { + val targetDepth = toc.getOrNull(originalIndex)?.nestLevel ?: return emptySet() + val ancestors = mutableSetOf() + var currentDepth = targetDepth + for (index in originalIndex downTo 0) { + val entry = toc[index] + if (entry.nestLevel < currentDepth) { + ancestors += index + currentDepth = entry.nestLevel + } + if (currentDepth == 0) break + } + return ancestors +} + +internal fun desktopVisiblePdfTocEntries( + toc: List, + expandedIndices: Set +): List> { + val result = mutableListOf>() + val visibilityStack = BooleanArray(50) { false } + visibilityStack[0] = true + + toc.forEachIndexed { index, entry -> + val depth = entry.nestLevel.coerceIn(0, visibilityStack.lastIndex) + if (visibilityStack[depth]) { + result += index to entry + if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = index in expandedIndices + } + } else if (depth + 1 < visibilityStack.size) { + visibilityStack[depth + 1] = false + } + } + return result +} + +internal fun desktopPdfSidebarHighlights(annotations: List): List { + return annotations + .filter { it.kind == PdfAnnotationKind.HIGHLIGHT } + .sortedBy { it.pageIndex } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DesktopPdfNavigationSidebar( + document: DesktopPdfDocument, + pageIndex: Int, + sortedHighlights: List, + bookmarks: List, + onPageSelected: (Int) -> Unit, + onAnnotationOpened: (SharedPdfAnnotation) -> Unit, + onAnnotationSelected: (SharedPdfAnnotation) -> Unit, + onAnnotationDeleted: (SharedPdfAnnotation) -> Unit +) { + val documentHandleId = document.handleId + val tabs = listOf( + readerString("desktop_toc", "TOC"), + readerString("tab_highlights", "Highlights"), + readerString("tab_bookmarks", "Bookmarks"), + readerString("tab_pages", "Pages") + ) + var selectedTabIndex by remember(documentHandleId) { mutableStateOf(0) } + val navigationScope = rememberCoroutineScope() + val pdfTocParentIndices = remember(document.toc) { desktopPdfTocParentIndices(document.toc) } + var expandedPdfTocEntryIndices by remember(documentHandleId, document.toc) { + mutableStateOf(pdfTocParentIndices) + } + + Surface( + modifier = Modifier + .width(300.dp) + .fillMaxHeight(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + tonalElevation = 2.dp + ) { + Column(Modifier.fillMaxSize()) { + ScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 0.dp + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTabIndex == index, + onClick = { selectedTabIndex = index }, + text = { + Text( + title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + + when (selectedTabIndex) { + 0 -> { + if (document.toc.isEmpty()) { + DesktopPdfNavigationEmpty(readerString("desktop_no_table_of_contents", "No table of contents")) + } else { + val tocListState = rememberLazyListState() + val visibleTocItems by remember(document.toc, expandedPdfTocEntryIndices) { + derivedStateOf { desktopVisiblePdfTocEntries(document.toc, expandedPdfTocEntryIndices) } + } + val currentOriginalIndex = remember(document.toc, pageIndex) { + document.toc.indexOfLast { it.pageIndex <= pageIndex } + .takeIf { it >= 0 } + ?: document.toc.indexOfFirst { it.pageIndex == pageIndex }.takeIf { it >= 0 } + } + fun locateCurrentTocEntry() { + val originalIndex = currentOriginalIndex ?: return + navigationScope.launch { + expandedPdfTocEntryIndices = expandedPdfTocEntryIndices + + desktopPdfTocAncestorIndices(document.toc, originalIndex) + repeat(4) { + val visibleIndex = visibleTocItems.indexOfFirst { it.first == originalIndex } + if (visibleIndex >= 0) { + tocListState.animateScrollToItem(visibleIndex) + return@launch + } + delay(30) + } + } + } + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton(onClick = { expandedPdfTocEntryIndices = pdfTocParentIndices }) { + Text(readerString("action_expand_all", "Expand all")) + } + TextButton(onClick = { expandedPdfTocEntryIndices = emptySet() }) { + Text(readerString("action_collapse_all", "Collapse all")) + } + TextButton(onClick = ::locateCurrentTocEntry, enabled = currentOriginalIndex != null) { + Text(readerString("action_locate", "Locate")) + } + } + HorizontalDivider() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = tocListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(tocListState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + visibleTocItems, + key = { (index, entry) -> "nav_toc_${index}_${entry.pageIndex}_${entry.nestLevel}" } + ) { (originalIndex, entry) -> + val nextItem = document.toc.getOrNull(originalIndex + 1) + val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel + val isExpanded = originalIndex in expandedPdfTocEntryIndices + DesktopPdfTocTreeItem( + entry = entry, + selected = originalIndex == currentOriginalIndex, + hasChildren = hasChildren, + isExpanded = isExpanded, + onToggleExpand = { + expandedPdfTocEntryIndices = if (isExpanded) { + expandedPdfTocEntryIndices - originalIndex + } else { + expandedPdfTocEntryIndices + originalIndex + } + }, + onClick = { onPageSelected(entry.pageIndex) } + ) + } + } + SharedReaderVerticalScrollbar( + listState = tocListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } + } + 1 -> { + if (sortedHighlights.isEmpty()) { + DesktopPdfNavigationEmpty(readerString("no_highlights_yet", "No highlights yet")) + } else { + val highlightsListState = rememberLazyListState() + var deleteHighlightConfirmFor by remember { mutableStateOf(null) } + var filterWithNotesOnly by remember { mutableStateOf(false) } + val filteredHighlights = remember(sortedHighlights, filterWithNotesOnly) { + if (filterWithNotesOnly) { + sortedHighlights.filter { !it.note.isNullOrBlank() } + } else { + sortedHighlights + } + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = !filterWithNotesOnly, + onClick = { filterWithNotesOnly = false }, + label = { Text(readerString("read_status_all", "All")) } + ) + FilterChip( + selected = filterWithNotesOnly, + onClick = { filterWithNotesOnly = true }, + label = { Text(readerString("filter_with_notes", "With notes")) } + ) + } + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = highlightsListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(highlightsListState) + .padding(end = 12.dp) + ) { + items(filteredHighlights, key = { "nav_highlight_${it.id}" }) { highlight -> + ListItem( + headlineContent = { + Text( + text = highlight.text.ifBlank { + readerString( + "msg_highlighted_section_default", + "Highlighted section" + ) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(12.dp) + .background(Color(highlight.colorArgb).copy(alpha = 1f), CircleShape) + ) + Spacer(Modifier.width(8.dp)) + Text( + readerString("pdf_page_short", "Page %1\$d", highlight.pageIndex + 1), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + highlight.note?.takeIf { it.isNotBlank() }?.let { note -> + Spacer(Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = note, + style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic), + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + }, + trailingContent = { + Box { + var highlightMenuExpanded by remember(highlight.id) { mutableStateOf(false) } + IconButton(onClick = { highlightMenuExpanded = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = readerString("content_desc_options", "Options") + ) + } + DropdownMenu( + expanded = highlightMenuExpanded, + onDismissRequest = { highlightMenuExpanded = false } + ) { + DropdownMenuItem( + text = { + Text( + if (highlight.note.isNullOrBlank()) { + readerString("menu_add_note", "Add note") + } else { + readerString("menu_edit_note", "Edit note") + } + ) + }, + onClick = { + onAnnotationSelected(highlight) + highlightMenuExpanded = false + } + ) + DropdownMenuItem( + text = { Text(readerString("action_delete", "Delete")) }, + onClick = { + deleteHighlightConfirmFor = highlight + highlightMenuExpanded = false + } + ) + } + } + }, + modifier = Modifier.clickable { onAnnotationOpened(highlight) } + ) + HorizontalDivider() + } + } + SharedReaderVerticalScrollbar( + listState = highlightsListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + + deleteHighlightConfirmFor?.let { highlight -> + AlertDialog( + onDismissRequest = { deleteHighlightConfirmFor = null }, + title = { Text(readerString("dialog_delete_highlight", "Delete highlight?")) }, + text = { Text(readerString("dialog_delete_highlight_desc", "This removes the highlight from this PDF.")) }, + confirmButton = { + TextButton( + onClick = { + onAnnotationDeleted(highlight) + deleteHighlightConfirmFor = null + } + ) { + Text(readerString("action_delete", "Delete")) + } + }, + dismissButton = { + TextButton(onClick = { deleteHighlightConfirmFor = null }) { + Text(readerString("action_cancel", "Cancel")) + } + } + ) + } + } + } + 2 -> { + if (bookmarks.isEmpty()) { + DesktopPdfNavigationEmpty(readerString("desktop_no_bookmarks_yet", "No bookmarks yet")) + } else { + val bookmarksListState = rememberLazyListState() + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = bookmarksListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(bookmarksListState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(bookmarks, key = { "nav_bookmark_${it.pageIndex}" }) { bookmark -> + Surface( + color = if (bookmark.pageIndex == pageIndex) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { onPageSelected(bookmark.pageIndex) } + ) { + Text( + bookmark.label.ifBlank { + readerString("pdf_page_short", "Page %1\$d", bookmark.pageIndex + 1) + }, + modifier = Modifier.padding(8.dp) + ) + } + } + } + SharedReaderVerticalScrollbar( + listState = bookmarksListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } + 3 -> { + val pageRows = remember(document.pageCount) { (0 until document.pageCount).chunked(3) } + val pagesListState = rememberLazyListState() + val currentRowIndex = pageIndex / 3 + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton( + onClick = { + navigationScope.launch { + pagesListState.animateScrollToItem( + currentRowIndex.coerceIn(0, pageRows.lastIndex.coerceAtLeast(0)) + ) + } + } + ) { + Text(readerString("action_locate", "Locate")) + } + } + HorizontalDivider() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = pagesListState, + modifier = Modifier + .fillMaxSize() + .sharedAcceleratedLazyWheelScroll(pagesListState) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(pageRows, key = { row -> row.firstOrNull() ?: 0 }) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + row.forEach { page -> + DesktopPdfThumbnailTile( + document = document, + pageIndex = page, + selected = page == pageIndex, + onClick = { onPageSelected(page) }, + modifier = Modifier.weight(1f) + ) + } + repeat(3 - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } + SharedReaderVerticalScrollbar( + listState = pagesListState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } + } + } + } +} + +@Composable +internal fun DesktopPdfTocTreeItem( + entry: PdfTocEntry, + selected: Boolean, + hasChildren: Boolean, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth().clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 46.dp) + .padding(start = (entry.nestLevel.coerceAtLeast(0) * 14).dp) + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clickable(enabled = hasChildren) { onToggleExpand() }, + contentAlignment = Alignment.Center + ) { + if (hasChildren) { + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = if (isExpanded) { + readerString("content_desc_collapse", "Collapse") + } else { + readerString("content_desc_expand", "Expand") + }, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Text( + entry.title, + fontWeight = if (selected) FontWeight.Bold else if (entry.nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + readerString("desktop_pdf_compact_page_number", "p. %1\$d", entry.pageIndex + 1), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +@Composable +internal fun DesktopPdfNavigationEmpty(message: String) { + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +internal fun DesktopPdfThumbnailTile( + document: DesktopPdfDocument, + pageIndex: Int, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val documentHandleId = document.handleId + var thumbnail by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var renderFailed by remember(documentHandleId, pageIndex) { mutableStateOf(false) } + val pageSize = document.pageSizes.getOrNull(pageIndex) + val thumbnailScale = remember(pageSize) { + val width = pageSize?.width?.coerceAtLeast(1f) ?: 612f + (120f / width).coerceIn(0.08f, 0.35f) + } + + LaunchedEffect(documentHandleId, pageIndex, thumbnailScale) { + thumbnail = null + renderFailed = false + val rendered = withContext(Dispatchers.IO) { + runCatching { + DesktopPdfium.renderPage( + document = document, + pageIndex = pageIndex, + scale = thumbnailScale, + renderAnnotations = false + ) + }.getOrNull() + } + thumbnail = rendered + renderFailed = rendered == null + } + + Surface( + modifier = modifier.aspectRatio(0.707f).clickable(onClick = onClick), + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + ) + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val render = thumbnail + if (render != null) { + Image( + bitmap = render.image, + contentDescription = readerString("pdf_page_short", "Page %1\$d", pageIndex + 1), + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().padding(3.dp) + ) + } else { + Text( + if (renderFailed) "!" else "${pageIndex + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = "${pageIndex + 1}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .background(Color.Black.copy(alpha = 0.58f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } +} + +@Composable +internal fun DesktopPdfPageScrubOverlay( + pageIndex: Int?, + pageCount: Int, + pageLabel: String? = pageIndex?.let { "Page ${it + 1} of $pageCount" } +) { + if (pageIndex == null || pageCount <= 0) return + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shape = RoundedCornerShape(16.dp), + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Text( + text = pageLabel ?: readerString( + "desktop_pdf_page_of_count", + "Page %1\$s of %2\$d", + "${pageIndex + 1}", + pageCount + ), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt new file mode 100644 index 0000000..bf7c1e0 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPage.kt @@ -0,0 +1,988 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.SearchHighlightMode +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchEngine +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle +import org.dueattendant149.bookreader.shared.ui.SharedPdfAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfEmbeddedAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfInlineTextEditorOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfPageNumberOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextLayer +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextBoxEditorOverlay +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedPdfEmbeddedHitTest +import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest +import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +private const val DesktopVerticalPdfPageTurnAnimationMillis = 140 + +@Composable +internal fun DesktopVerticalPdfPage( + document: DesktopPdfDocument, + pageIndex: Int, + scale: Float, + zoomSpec: PdfZoomSpec, + annotations: List, + searchResults: List, + activeSearchIndex: Int, + searchHighlightMode: SearchHighlightMode, + activeTtsChunk: ReaderTtsChunk?, + searchQuery: String, + isTextSelectionMode: Boolean, + selectedAnnotationId: String?, + selectedEmbeddedAnnotationId: String?, + selectedTool: PdfInkTool, + selectedColor: Int, + highlighterPalette: List, + strokeWidth: Float, + isHighlighterSnapEnabled: Boolean, + activeTextDraft: SharedPdfTextDraft?, + richTextController: SharedPdfRichTextController, + isRichTextMode: Boolean, + readerAiFeaturesAvailable: Boolean, + cloudTtsAvailable: Boolean, + externalLookupAvailable: Boolean, + themeStyle: DesktopPdfThemeStyle, + shouldRender: Boolean, + zoomPreview: DesktopPdfZoomPreview?, + zoomPreviewAnchorPageRootOffset: Offset? = null, + zoomPreviewScrollBounds: DesktopPdfZoomScrollBounds? = null, + zoomViewportRootOffset: Offset, + showPageNumberOverlay: Boolean = true, + onSelectPage: (Int) -> Unit, + onCopySelection: (DesktopPdfTextSelection) -> Unit, + onHighlightSelection: (Int, DesktopPdfTextSelection, IntSize, Int) -> Unit, + onExternalSearchSelection: (DesktopPdfTextSelection) -> Unit, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, + onDefineSelection: (DesktopPdfTextSelection) -> Unit, + onSpeakSelection: (DesktopPdfTextSelection) -> Unit, + onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit, + onAnnotationSelected: (SharedPdfAnnotation?) -> Unit, + onLinkActivated: (DesktopPdfLinkTarget) -> Unit, + onAnnotationAdded: (SharedPdfAnnotation) -> Unit, + onAnnotationUpdated: (SharedPdfAnnotation) -> Unit, + onAnnotationsChanged: (List) -> Unit, + onTextAnnotationSelected: (SharedPdfAnnotation) -> Unit, + onTextDraftStarted: (Int, Offset, IntSize) -> Unit, + onTextDraftChanged: (String, IntSize) -> Unit, + onTextDraftBoundsChanged: (PdfPageBounds) -> Unit, + onPan: (Offset) -> Unit, + onPageSizeChanged: (Int, IntSize) -> Unit = { _, _ -> }, + onPagePositioned: (Int, Offset) -> Unit +) { + val documentHandleId = document.handleId + val density = LocalDensity.current + var renderedPage by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageIndex by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageScale by remember(documentHandleId) { mutableStateOf(null) } + var renderError by remember(documentHandleId) { mutableStateOf(null) } + var isRendering by remember(documentHandleId) { mutableStateOf(true) } + var pageCanvasSize by remember(documentHandleId, pageIndex) { mutableStateOf(IntSize.Zero) } + var pageRootOffset by remember(documentHandleId, pageIndex) { mutableStateOf(Offset.Zero) } + var selectionStartIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionEndIndex by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionStartHit by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionEndHit by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var textSelection by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var selectionMenuOffset by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var activeSelectionHandle by remember(documentHandleId, pageIndex) { mutableStateOf(null) } + var activeStroke by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf>(emptyList()) } + var eraserPosition by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf(null) } + val currentTextSelection by rememberUpdatedState(textSelection) + val currentAnnotations by rememberUpdatedState(annotations) + + fun clearSelection() { + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + textSelection = null + selectionMenuOffset = null + activeSelectionHandle = null + } + + fun clearInteractionState() { + clearSelection() + activeStroke = emptyList() + eraserPosition = null + } + val failedRenderMessage = readerString("desktop_failed_render_page", "Failed to render page.") + + LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) { + if (!shouldRender) { + logPdfZoomSettle { + "item_render_skip page=${pageIndex + 1} reason=outside_window scale=${scale.formatLogFloat()}" + } + renderedPage = null + renderedPageIndex = null + renderedPageScale = null + renderError = null + isRendering = false + clearInteractionState() + return@LaunchedEffect + } + val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) + logPdfZoomSettle { + "item_render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} shouldRender=$shouldRender " + + "hasRender=$hasPageRender renderedPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } + if (!hasPageRender) { + renderedPage = null + renderedPageIndex = null + renderedPageScale = null + isRendering = true + } + renderError = null + val pageSize = document.pageSizes.getOrNull(pageIndex) + if (pageSize == null) { + renderedPage = null + renderedPageIndex = null + renderedPageScale = null + renderError = failedRenderMessage + isRendering = false + return@LaunchedEffect + } + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + if (hasPageRender && !desktopPdfRenderScaleNeedsUpgrade(renderedPageScale, safeScale)) { + logPdfZoomSettle { + "item_render_skip page=${pageIndex + 1} reason=no_scale_upgrade " + + "safeScale=${safeScale.formatLogFloat()} existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } + isRendering = false + return@LaunchedEffect + } + logPdfZoomSettle { + "item_render_scheduled page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + + "delayMs=${if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L} hasRender=$hasPageRender" + } + delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L) + isRendering = true + val renderStartedAt = System.currentTimeMillis() + val result = withContext(Dispatchers.IO) { + runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) } + } + val renderElapsedMs = System.currentTimeMillis() - renderStartedAt + result.getOrNull()?.let { + renderedPage = it + renderedPageIndex = pageIndex + renderedPageScale = safeScale + } + val renderedCurrentPage = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) + renderError = result.exceptionOrNull()?.message + ?: if (!renderedCurrentPage && renderedPage == null) failedRenderMessage else null + isRendering = false + logPdfZoomSettle { + "item_render_end page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " + + "elapsedMs=$renderElapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + + "canvas=${pageCanvasSize.formatLogSize()} root=${pageRootOffset.formatLogOffset()}" + } + } + + LaunchedEffect(isTextSelectionMode) { + if (!isTextSelectionMode) { + clearSelection() + } else { + activeStroke = emptyList() + eraserPosition = null + } + } + + LaunchedEffect(selectedTool) { + activeStroke = emptyList() + eraserPosition = null + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + val pageSize = document.pageSizes.getOrNull(pageIndex) + val displayPageIndex = renderedPageIndex ?: pageIndex + val displayPageIsCurrent = displayPageIndex == pageIndex + val placeholderScale = zoomSpec.clamp(scale) + val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() } + val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() } + val renderedPageWidth = renderedPage?.width ?: 0 + val renderedPageHeight = renderedPage?.height ?: 0 + val pageRenderScale = if (pageSize != null && pageSize.width > 0f && renderedPageWidth > 0) { + renderedPageWidth / pageSize.width + } else { + placeholderScale + } + val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { + document.embeddedAnnotations.filter { it.pageIndex == pageIndex } + } + + Box( + modifier = Modifier + .size(placeholderWidthDp, placeholderHeightDp) + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pageRootOffset) { + logPdfZoomSettle { + "item_layout page=${pageIndex + 1} prevRoot=${pageRootOffset.formatLogOffset()} " + + "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${zoomPreview != null} canvas=${pageCanvasSize.formatLogSize()}" + } + } + pageRootOffset = rootOffset + onPagePositioned(pageIndex, rootOffset) + } + .onSizeChanged { size -> + if (pageCanvasSize != size) { + logPdfZoomSettle { + "item_size page=${pageIndex + 1} prev=${pageCanvasSize.formatLogSize()} " + + "next=${size.formatLogSize()} scale=${scale.formatLogFloat()} " + + "preview=${zoomPreview != null} renderScale=${pageRenderScale.formatLogFloat()}" + } + } + pageCanvasSize = size + onPageSizeChanged(pageIndex, size) + } + .desktopPdfDocumentZoomPreviewLayer( + preview = zoomPreview, + currentZoom = scale, + viewportRootOffset = zoomViewportRootOffset, + pageRootOffset = pageRootOffset, + anchorPageRootOffset = zoomPreviewAnchorPageRootOffset, + scrollBounds = zoomPreviewScrollBounds + ) + .background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) + .pointerInput( + pageIndex, + displayPageIsCurrent, + pageCanvasSize, + isTextSelectionMode, + selectedTool, + isRichTextMode + ) { + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val point = event.changes.firstOrNull()?.position ?: continue + if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (isTextSelectionMode) { + logPdfChromeTap { + "page_press source=vertical_page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "consumedBefore=${event.changes.any { it.isConsumed }} " + + "selectionActive=${currentTextSelection != null} " + + "selectionMenuOpen=${selectionMenuOffset != null} " + + "selectedTool=$selectedTool richText=$isRichTextMode" + } + } + val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { + currentAnnotations.asReversed().firstOrNull { + it.isDesktopTextSelectionHighlight && + it.pageIndex == pageIndex && + it.sharedPdfHitTest(point, pageCanvasSize) + } + } else { + null + } + if (highlightHit != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=text_selection_highlight annotation=${highlightHit.id}" + } + onSelectPage(pageIndex) + onAnnotationSelected(highlightHit) + clearInteractionState() + event.changes.forEach { it.consume() } + continue + } + if (selectedTool != PdfInkTool.TEXT) { + val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) + if (linkTarget != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=link target=${linkTarget.formatLogTarget()}" + } + logPdfLink( + "tap_hit mode=vertical page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "textSelection=$isTextSelectionMode target=${linkTarget.formatLogTarget()}" + ) + onSelectPage(pageIndex) + onLinkActivated(linkTarget) + clearInteractionState() + event.changes.forEach { it.consume() } + continue + } + } + val embeddedHit = pageEmbeddedAnnotations.findLast { + it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) + } + if (embeddedHit != null) { + logPdfChromeTap { + "page_press_consume source=vertical_page page=${pageIndex + 1} " + + "reason=embedded_annotation annotation=${embeddedHit.id}" + } + onSelectPage(pageIndex) + onEmbeddedAnnotationSelected(embeddedHit) + clearInteractionState() + event.changes.forEach { it.consume() } + } else if ( + currentTextSelection != null && + selectionMenuOffset == null + ) { + logPdfChromeTap { + "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + + "action=clear_selection consumed=false" + } + clearSelection() + } else if (isTextSelectionMode) { + logPdfChromeTap { + "page_press_passthrough source=vertical_page page=${pageIndex + 1} " + + "action=none consumed=false" + } + } + } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { + val selection = currentTextSelection + if (selection != null) { + onSelectPage(pageIndex) + selectionMenuOffset = point + logPdfSelection( + "menu_open page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length}" + ) + event.changes.forEach { it.consume() } + } + } + } + } + } + .pointerInput(pageIndex, displayPageIsCurrent, pageCanvasSize, isTextSelectionMode, isRichTextMode) { + if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput + detectDesktopPdfTextSelectionLongPress( + source = "vertical_page", + pageIndex = pageIndex + ) { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + logPdfChromeTap { + "long_press_selection source=vertical_page page=${pageIndex + 1} " + + "selectionFound=${selection != null} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" + } + if (selection != null) { + onSelectPage(pageIndex) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + textSelection = selection + selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) + logPdfSelection( + "long_press page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} " + + "text=\"${selection.text.logPreview()}\"" + ) + } + } + } + .pointerInput(pageIndex, displayPageIsCurrent, selectedTool, isTextSelectionMode, isRichTextMode) { + if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) { + return@pointerInput + } + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val pointerId = down.id + var dragStarted = false + var dragDistance = 0f + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + ?: return@awaitEachGesture + if (change.changedToUp()) { + return@awaitEachGesture + } + if (!change.positionChanged()) continue + val delta = change.positionChange() + if (!dragStarted) { + dragDistance += delta.getDistance() + if (dragDistance <= viewConfiguration.touchSlop) { + continue + } + dragStarted = true + change.consume() + continue + } + onPan(delta) + change.consume() + } + } + } + .pointerInput( + pageIndex, + isTextSelectionMode, + selectedTool, + selectedColor, + strokeWidth, + isHighlighterSnapEnabled, + activeTextDraft?.id, + isRichTextMode, + pageCanvasSize, + renderedPageWidth, + renderedPageHeight, + displayPageIsCurrent + ) { + if (displayPageIsCurrent && renderedPageWidth > 0 && renderedPageHeight > 0) { + if (isRichTextMode) return@pointerInput + if (isTextSelectionMode) { + var latestSelectionDragPoint: Offset? = null + var lastSelectionPreviewAt = 0L + detectDragGestures( + onDragStart = { start -> + latestSelectionDragPoint = start + lastSelectionPreviewAt = 0L + onSelectPage(pageIndex) + activeStroke = emptyList() + selectionMenuOffset = null + val existingSelection = textSelection + val handle = existingSelection?.handleAt(start, pageCanvasSize) + activeSelectionHandle = handle + val hit = document.charHitAt(pageIndex, start, pageCanvasSize) + if (handle != null && existingSelection != null) { + selectionStartHit = null + selectionStartIndex = when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.endIndex + DesktopPdfSelectionHandle.END -> existingSelection.startIndex + } + selectionEndHit = hit + selectionEndIndex = hit?.index ?: when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.startIndex + DesktopPdfSelectionHandle.END -> existingSelection.endIndex + } + } else { + selectionStartHit = hit + selectionStartIndex = hit?.index + selectionEndHit = null + selectionEndIndex = null + textSelection = null + } + logPdfSelection( + "drag_start page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + "handle=${handle?.name ?: "none"} " + + hit.formatLogHit("start") + ) + }, + onDrag = { change, _ -> + latestSelectionDragPoint = change.position + val now = System.currentTimeMillis() + if (lastSelectionPreviewAt == 0L || + now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis + ) { + lastSelectionPreviewAt = now + val startIndex = selectionStartIndex + val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) + selectionEndHit = hit + val endIndex = hit?.index + val previousEndIndex = selectionEndIndex + selectionEndIndex = endIndex + if (endIndex != previousEndIndex || textSelection == null) { + textSelection = if (startIndex != null && endIndex != null) { + document.selectionPreviewBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize + ) + } else { + null + } + } + } + change.consume() + }, + onDragEnd = { + val finalHit = latestSelectionDragPoint + ?.let { document.charHitAt(pageIndex, it, pageCanvasSize) } + ?: selectionEndHit + if (finalHit != null) { + selectionEndHit = finalHit + selectionEndIndex = finalHit.index + } + val startIndex = selectionStartIndex + val endIndex = selectionEndIndex + val selection = if (startIndex != null && endIndex != null) { + document.selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize, + useNativeBounds = true + ) + } else { + textSelection?.takeIf { it.text.isNotBlank() } + } + textSelection = selection + selectionMenuOffset = selection?.menuAnchor( + pageCanvasSize, + finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point + ) + logPdfSelection( + "drag_end page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + " " + + "range=${selection?.startIndex}..${selection?.endIndex} " + + "chars=${selection?.text?.length ?: 0} " + + "lines=${selection?.lineBounds?.size ?: 0} " + + "text=\"${selection?.text.orEmpty().logPreview()}\"" + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L + }, + onDragCancel = { + logPdfSelection( + "drag_cancel page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${renderedPageWidth}x$renderedPageHeight " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L + } + ) + } else if (selectedTool == PdfInkTool.TEXT) { + detectTapGestures( + onTap = { start -> + onSelectPage(pageIndex) + when { + activeTextDraft?.containsOffset(pageIndex, start, pageCanvasSize) == true -> Unit + else -> { + val textHit = currentAnnotations.textAnnotationHitAt( + pageIndex = pageIndex, + point = start, + canvasSize = pageCanvasSize + ) + clearInteractionState() + if (textHit != null) { + onTextAnnotationSelected(textHit) + } else { + onTextDraftStarted(pageIndex, start, pageCanvasSize) + } + } + } + } + ) + } else if (selectedTool != PdfInkTool.NONE) { + var eraserPreviousPoint: Offset? = null + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val start = down.position + onSelectPage(pageIndex) + clearInteractionState() + if (selectedTool == PdfInkTool.ERASER) { + eraserPosition = start + val annotationSnapshot = currentAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = start, + size = pageCanvasSize, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + onAnnotationsChanged(updatedAnnotations) + } + eraserPreviousPoint = start + } else { + activeStroke = listOf( + start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis()) + ) + } + + val pointerId = down.id + var dragStarted = false + while (true) { + val event = awaitPointerEvent() + if (event.changes.size > 1) { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + val change = event.changes.firstOrNull { it.id == pointerId } + ?: run { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + if (change.changedToUp()) { + change.consume() + if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) { + onAnnotationAdded( + SharedPdfAnnotation( + id = "ink_${System.currentTimeMillis()}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + tool = selectedTool, + points = activeStroke, + colorArgb = selectedColor, + strokeWidth = strokeWidth, + createdAt = System.currentTimeMillis() + ) + ) + } + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + if (!change.positionChanged()) continue + val distance = (change.position - start).getDistance() + if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue + dragStarted = true + if (selectedTool == PdfInkTool.ERASER) { + val point = change.position + eraserPosition = point + val previousPoint = eraserPreviousPoint + val annotationSnapshot = currentAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = point, + size = pageCanvasSize, + lastPoint = previousPoint, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + onAnnotationsChanged(updatedAnnotations) + } + eraserPreviousPoint = point + } else { + activeStroke = activeStroke.withDesktopPdfDragPoint( + point = change.position, + canvasSize = pageCanvasSize, + tool = selectedTool, + snapHighlighter = isHighlighterSnapEnabled, + timestamp = System.currentTimeMillis() + ) + } + change.consume() + } + } + } + } + }, + contentAlignment = Alignment.Center + ) { + when { + !shouldRender -> { + Text( + readerString("pdf_page_short", "Page %1\$d", pageIndex + 1), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + renderError != null && renderedPageIndex != pageIndex -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) -> { + val currentRenderedPageIndex = renderedPageIndex!! + Crossfade( + targetState = currentRenderedPageIndex, + animationSpec = tween(DesktopVerticalPdfPageTurnAnimationMillis), + label = "DesktopVerticalPdfPage" + ) { pageIndex -> + val pageRender = renderedPage!! + val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { + document.embeddedAnnotations.filter { it.pageIndex == pageIndex } + } + val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { + annotations + .filter { it.pageIndex == pageIndex } + .flatMap { annotation -> + annotation.toRenderablePdfAnnotations(document, pageIndex, pageCanvasSize) + } + } + val selectedTextAnnotationForPage = remember(annotations, selectedAnnotationId, selectedTool, isTextSelectionMode, pageIndex) { + annotations.firstOrNull { + selectedTool == PdfInkTool.TEXT && + !isTextSelectionMode && + it.id == selectedAnnotationId && + it.kind == PdfAnnotationKind.TEXT && + it.pageIndex == pageIndex + } + } + val visiblePageAnnotations = remember(pageAnnotations, selectedTextAnnotationForPage?.id) { + pageAnnotations.filterNot { + it.kind == PdfAnnotationKind.TEXT && it.id == selectedTextAnnotationForPage?.id + } + } + val searchHighlightBounds: List = remember( + document.path, + searchResults, + pageIndex, + activeSearchIndex, + searchHighlightMode, + pageCanvasSize, + searchQuery + ) { + val queryLength = searchQuery.trim().length + if (queryLength <= 0 || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) { + emptyList() + } else { + SharedPdfSearchEngine.highlightsForPage( + results = searchResults, + pageIndex = pageIndex, + activeResultIndex = activeSearchIndex, + mode = searchHighlightMode + ).flatMap { result -> + val matchLength = result.matchLength.takeIf { it > 0 } ?: queryLength + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = result.matchIndex, + endIndex = result.matchIndex + matchLength - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } + } + val ttsHighlightBounds: List = remember( + document.path, + activeTtsChunk, + pageIndex, + pageCanvasSize + ) { + val chunk = activeTtsChunk?.takeIf { it.pageIndex == pageIndex } + if (chunk == null || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0 || chunk.endOffset <= chunk.startOffset) { + emptyList() + } else { + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = chunk.startOffset, + endIndex = chunk.endOffset - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } + + DesktopPdfThemedPageImage( + bitmap = pageRender.image, + contentDescription = readerString("desktop_pdf_page_content_desc", "PDF page %1\$d", pageIndex + 1), + themeStyle = themeStyle, + modifier = Modifier.fillMaxSize() + ) + SharedPdfRichTextLayer( + pageIndex = pageIndex, + controller = richTextController, + pageWidth = pageCanvasSize.width.toFloat(), + pageHeight = pageCanvasSize.height.toFloat(), + isTextEditingEnabled = isRichTextMode, + onPageTapped = {} + ) + PdfSearchHighlightOverlay( + bounds = searchHighlightBounds, + canvasSize = pageCanvasSize, + color = when (searchHighlightMode) { + SearchHighlightMode.ALL -> Color(0x55FDD835) + SearchHighlightMode.FOCUSED -> Color(0x88FF9800) + } + ) + PdfSearchHighlightOverlay( + bounds = ttsHighlightBounds, + canvasSize = pageCanvasSize, + color = Color(0x887DD3FC) + ) + PdfTextSelectionOverlay( + selection = textSelection, + canvasSize = pageCanvasSize + ) + SharedPdfAnnotationOverlay( + annotations = visiblePageAnnotations, + activeStroke = activeStroke, + canvasSize = pageCanvasSize, + activeTool = selectedTool, + activeStrokeColorArgb = selectedColor, + activeStrokeWidth = strokeWidth, + selectedAnnotationId = selectedAnnotationId, + eraserPosition = eraserPosition, + showEraserIndicator = selectedTool == PdfInkTool.ERASER, + eraserStrokeWidth = strokeWidth + ) + PdfTextSelectionHandles( + selection = textSelection, + canvasSize = pageCanvasSize, + activeHandle = activeSelectionHandle + ) + SharedPdfInlineTextEditorOverlay( + draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex }, + canvasSize = pageCanvasSize, + onTextChange = { onTextDraftChanged(it, pageCanvasSize) }, + onBoundsChange = { onTextDraftBoundsChanged(it) } + ) + selectedTextAnnotationForPage?.let { annotation -> + val bounds = annotation.bounds + if (bounds != null && activeTextDraft == null) { + SharedPdfTextBoxEditorOverlay( + id = annotation.id, + text = annotation.text, + style = annotation.sharedPdfTextStyle(), + bounds = bounds, + canvasSize = pageCanvasSize, + onTextChange = { text -> + onAnnotationUpdated(annotation.copy(text = text)) + }, + onBoundsChange = { nextBounds -> + onAnnotationUpdated(annotation.copy(bounds = nextBounds)) + } + ) + } + } + SharedPdfEmbeddedAnnotationOverlay( + annotations = pageEmbeddedAnnotations, + canvasSize = pageCanvasSize, + selectedAnnotationId = selectedEmbeddedAnnotationId + ) + if (showPageNumberOverlay) { + SharedPdfPageNumberOverlay( + pageIndex = pageIndex, + pageCount = document.pageCount + ) + } + if (textSelection != null && selectionMenuOffset != null) { + Box( + modifier = Modifier + .matchParentSize() + .pointerInput(pageIndex, selectionMenuOffset) { + detectTapGestures { + logPdfChromeTap { + "selection_menu_scrim_tap source=vertical_page page=${pageIndex + 1} " + + "consumedByScrim=true" + } + clearSelection() + } + } + ) + } + PdfSelectionMenu( + selection = textSelection, + menuOffset = selectionMenuOffset, + canvasSize = pageCanvasSize, + highlighterPalette = highlighterPalette, + onHighlighterPaletteChange = onHighlighterPaletteChange, + onCopy = { + textSelection?.let(onCopySelection) + clearSelection() + }, + onHighlight = { colorArgb -> + textSelection?.let { onHighlightSelection(pageIndex, it, pageCanvasSize, colorArgb) } + clearSelection() + }, + onSearch = { + textSelection?.let(onExternalSearchSelection) + clearSelection() + }, + onDefine = { + textSelection?.let(onDefineSelection) + clearSelection() + }, + onSpeak = { + textSelection?.let(onSpeakSelection) + clearSelection() + }, + showDefine = readerAiFeaturesAvailable, + showSpeak = cloudTtsAvailable, + showSearch = externalLookupAvailable, + onClear = ::clearSelection + ) + } + } + isRendering -> CircularProgressIndicator() + renderError != null -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt new file mode 100644 index 0000000..064ad73 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPageInteractions.kt @@ -0,0 +1,446 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.unit.IntSize +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfNormalizedPoint +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.PdfSelectionGeometry +import org.dueattendant149.bookreader.shared.pdf.PdfTextCharBounds +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfInkRenderer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft +import org.dueattendant149.bookreader.shared.pdf.reduce +import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest +import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout + +internal val PdfInkTool.isDesktopHighlighter: Boolean + get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND + +internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean + get() = kind == PdfAnnotationKind.HIGHLIGHT && + text.isNotBlank() && + rangeStartIndex != null && + rangeEndIndex != null + +internal fun SharedPdfReaderState.withDesktopPdfTextSelectionHighlightAdded( + annotation: SharedPdfAnnotation, + zoomSpec: PdfZoomSpec = PdfZoomSpec() +): SharedPdfReaderState { + val next = reduce(SharedPdfReaderAction.AnnotationAdded(annotation), zoomSpec) + return if (annotation.isDesktopTextSelectionHighlight) { + next.reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) + } else { + next + } +} + +internal fun SharedPdfReaderState.withDesktopPdfTextHighlightSheetDismissed( + zoomSpec: PdfZoomSpec = PdfZoomSpec() +): SharedPdfReaderState { + return reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec) +} + +internal fun List.withDesktopPdfDragPoint( + point: Offset, + canvasSize: IntSize, + tool: PdfInkTool, + snapHighlighter: Boolean, + timestamp: Long +): List { + val nextPoint = point.toSharedPdfPoint(canvasSize, timestamp) + if (snapHighlighter && tool.isDesktopHighlighter && isNotEmpty()) { + val pageAspectRatio = canvasSize.width.toFloat() / canvasSize.height.coerceAtLeast(1).toFloat() + return listOf( + first(), + SharedPdfInkRenderer.calculateSnappedPoint( + currentPoint = nextPoint, + startPoint = first(), + pageAspectRatio = pageAspectRatio + ) + ) + } + return this + nextPoint +} + +internal suspend fun PointerInputScope.detectDesktopPdfTextSelectionLongPress( + source: String, + pageIndex: Int, + onLongPress: (Offset) -> Unit +) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val secondaryDown = currentEvent.buttons.isSecondaryPressed + logPdfChromeTap { + "long_press_down source=$source page=${pageIndex + 1} " + + "x=${down.position.x.formatLogFloat()} y=${down.position.y.formatLogFloat()} " + + "downConsumed=${down.isConsumed} secondary=$secondaryDown" + } + if (down.isConsumed || secondaryDown) { + logPdfChromeTap { + "long_press_skip source=$source page=${pageIndex + 1} " + + "reason=${if (down.isConsumed) "down_consumed" else "secondary_button"}" + } + return@awaitEachGesture + } + val pointerId = down.id + val start = down.position + var latestPosition = start + var canceledBeforeLongPress = false + var longPressReached = false + var cancelReason = "" + + try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + while (true) { + val event = awaitPointerEvent() + if (event.buttons.isSecondaryPressed) { + canceledBeforeLongPress = true + cancelReason = "secondary_button" + return@withTimeout + } + val change = event.changes.firstOrNull { it.id == pointerId } + if (change == null) { + canceledBeforeLongPress = true + cancelReason = "pointer_lost" + return@withTimeout + } + latestPosition = change.position + val distance = (latestPosition - start).getDistance() + when { + change.isConsumed -> { + canceledBeforeLongPress = true + cancelReason = "change_consumed" + return@withTimeout + } + change.changedToUp() || !change.pressed -> { + canceledBeforeLongPress = true + cancelReason = "up_before_long_press" + return@withTimeout + } + distance > viewConfiguration.touchSlop -> { + canceledBeforeLongPress = true + cancelReason = "moved distance=${distance.formatLogFloat()}" + return@withTimeout + } + } + } + } + } catch (_: TimeoutCancellationException) { + longPressReached = !canceledBeforeLongPress + } + + if (!longPressReached) { + logPdfChromeTap { + "long_press_cancel source=$source page=${pageIndex + 1} " + + "reason=${cancelReason.ifBlank { "unknown" }} " + + "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" + } + return@awaitEachGesture + } + logPdfChromeTap { + "long_press_reached source=$source page=${pageIndex + 1} " + + "x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}" + } + onLongPress(latestPosition) + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture + change.consume() + if (change.changedToUp() || !change.pressed) return@awaitEachGesture + } + } +} + +internal data class DesktopPdfCharHit( + val index: Int, + val source: String, + val point: Offset, + val normalized: PdfNormalizedPoint +) + +internal fun SharedPdfAnnotation.toDesktopPdfTextSelection(): DesktopPdfTextSelection { + return DesktopPdfTextSelection( + text = text, + lineBounds = boundsList.ifEmpty { listOfNotNull(bounds) }, + startIndex = rangeStartIndex ?: 0, + endIndex = rangeEndIndex ?: text.length + ) +} + +internal fun DesktopPdfDocument.linkAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfLinkTarget? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + return DesktopPdfium.linkAt( + document = this, + pageIndex = pageIndex, + normalizedX = point.x / canvasSize.width, + normalizedY = point.y / canvasSize.height, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) +} + +internal fun DesktopPdfDocument.charHitAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfCharHit? { + val normalized = PdfSelectionGeometry.normalizedPoint( + pointX = point.x, + pointY = point.y, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) ?: return null + val nativeIndex = DesktopPdfium.charIndexAt( + document = this, + pageIndex = pageIndex, + normalizedX = normalized.x, + normalizedY = normalized.y, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ) + if (nativeIndex != null) { + return DesktopPdfCharHit( + index = nativeIndex, + source = "native", + point = point, + normalized = normalized + ) + } + val fallback = PdfSelectionGeometry.nearestCharOnLine( + chars = textPageData(pageIndex).chars.visiblePdfTextBounds(), + point = normalized + ) ?: return null + return DesktopPdfCharHit( + index = fallback.index, + source = "fallback_line", + point = point, + normalized = normalized + ) +} + +internal fun DesktopPdfDocument.wordSelectionAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): DesktopPdfTextSelection? { + val hit = charHitAt(pageIndex, point, canvasSize) ?: return null + if (hit.source == "fallback_line" && !isPointNearTextChar(pageIndex, hit.index, hit.normalized)) { + return null + } + val pageText = textPageData(pageIndex).text + if (pageText.isEmpty()) return null + val hitIndex = hit.index.coerceIn(0, pageText.lastIndex) + if (!pageText[hitIndex].isDesktopPdfWordPart()) return null + var startIndex = hitIndex + while (startIndex > 0 && pageText[startIndex - 1].isDesktopPdfWordPart()) { + startIndex -= 1 + } + var endIndex = hitIndex + while (endIndex < pageText.lastIndex && pageText[endIndex + 1].isDesktopPdfWordPart()) { + endIndex += 1 + } + return selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = canvasSize, + useNativeBounds = true + ) +} + +private fun DesktopPdfDocument.isPointNearTextChar( + pageIndex: Int, + charIndex: Int, + point: PdfNormalizedPoint +): Boolean { + val charBounds = textPageData(pageIndex).chars + .visiblePdfTextBounds() + .firstOrNull { it.index == charIndex } + ?: return false + val horizontalPadding = maxOf((charBounds.right - charBounds.left) * 2f, 0.025f) + val verticalPadding = maxOf((charBounds.bottom - charBounds.top) * 0.65f, 0.006f) + return point.x in (charBounds.left - horizontalPadding)..(charBounds.right + horizontalPadding) && + point.y in (charBounds.top - verticalPadding)..(charBounds.bottom + verticalPadding) +} + +private fun Char.isDesktopPdfWordPart(): Boolean { + return isLetterOrDigit() || this == '\'' || this == '-' || this == '_' +} + +internal fun DesktopPdfDocument.selectionPreviewBetweenIndexes( + pageIndex: Int, + startIndex: Int, + endIndex: Int, + canvasSize: IntSize +): DesktopPdfTextSelection? { + return selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = canvasSize, + useNativeBounds = false, + includeText = false + ) +} + +internal fun DesktopPdfDocument.selectionBetweenIndexes( + pageIndex: Int, + startIndex: Int, + endIndex: Int, + canvasSize: IntSize, + useNativeBounds: Boolean = true, + includeText: Boolean = true +): DesktopPdfTextSelection? { + val chars = textPageData(pageIndex).chars + if (chars.isEmpty()) return null + val firstIndex = minOf(startIndex, endIndex) + val lastIndex = maxOf(startIndex, endIndex) + val selectedChars = chars.filter { it.index in firstIndex..lastIndex } + if (selectedChars.isEmpty()) return null + val text = if (includeText) { + selectedChars.joinToString("") { it.char.toString() } + .replace(DesktopPdfSelectionInlineWhitespaceRegex, " ") + .replace(DesktopPdfSelectionBlankLinesRegex, "\n\n") + .trim() + } else { + "" + } + if (includeText && text.isBlank()) return null + val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds()) + if (!includeText && fallbackBounds.isEmpty()) return null + val nativeBounds = if (useNativeBounds) { + DesktopPdfium.textRectsForRange( + document = this, + pageIndex = pageIndex, + startIndex = firstIndex, + endIndex = lastIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } else { + emptyList() + } + return DesktopPdfTextSelection( + text = text, + lineBounds = nativeBounds.ifEmpty { fallbackBounds }, + startIndex = firstIndex, + endIndex = lastIndex + ) +} + +internal fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds { + return PdfPageBounds( + left = left, + top = top, + right = right, + bottom = bottom + ) +} + +internal fun SharedPdfAnnotation.toRenderablePdfAnnotations( + document: DesktopPdfDocument, + pageIndex: Int, + canvasSize: IntSize +): List { + val startIndex = rangeStartIndex + val endIndex = rangeEndIndex + if (kind != PdfAnnotationKind.HIGHLIGHT || startIndex == null || endIndex == null) { + return listOf(this) + } + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return listOf(this) + } + val dynamicBounds = DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + + return dynamicBounds.ifEmpty { boundsList.ifEmpty { listOfNotNull(bounds) } } + .mapIndexed { index, dynamicBounds -> + copy( + id = "${id}_line_$index", + bounds = dynamicBounds + ) + } +} + +internal fun SharedPdfTextDraft.containsOffset( + pageIndex: Int, + offset: Offset, + canvasSize: IntSize +): Boolean { + if (this.pageIndex != pageIndex || canvasSize.width <= 0 || canvasSize.height <= 0) return false + val left = bounds.left * canvasSize.width + val right = bounds.right * canvasSize.width + val top = bounds.top * canvasSize.height + val bottom = bounds.bottom * canvasSize.height + return offset.x in left..right && offset.y in top..bottom +} + +internal fun List.textAnnotationHitAt( + pageIndex: Int, + point: Offset, + canvasSize: IntSize +): SharedPdfAnnotation? { + return asReversed().firstOrNull { annotation -> + annotation.kind == PdfAnnotationKind.TEXT && + annotation.pageIndex == pageIndex && + annotation.sharedPdfHitTest(point, canvasSize) + } +} + +internal fun List.mergePdfBoundsByLine(): List { + return PdfSelectionGeometry.mergeBoundsByLine(this) +} + +private fun List.visiblePdfTextBounds(): List { + return asSequence() + .filter { it.hasBounds && !it.char.isISOControl() } + .map { it.toPdfTextCharBounds() } + .toList() +} + +private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds { + return PdfTextCharBounds( + index = index, + left = left, + top = top, + right = right, + bottom = bottom + ) +} + +internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L +internal const val DesktopPdfZoomCommitDebounceMillis = 260L +internal const val DesktopPdfZoomRenderDebounceMillis = 300L +internal const val DesktopPdfViewportPersistDebounceMillis = 300L +internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L +internal const val DesktopPdfRenderScaleTolerance = 0.01f +internal const val DesktopPdfPaginationRenderCacheRadius = 2 +private val DesktopPdfSelectionInlineWhitespaceRegex = Regex("[ \\t\\x0B\\f\\r]+") +private val DesktopPdfSelectionBlankLinesRegex = Regex("\\n{3,}") diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt new file mode 100644 index 0000000..adafc75 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfPasswordDialog.kt @@ -0,0 +1,82 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +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.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString + +@Composable +internal fun DesktopPdfPasswordDialog( + title: String, + isError: Boolean, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var password by remember(title, isError) { mutableStateOf("") } + + LaunchedEffect(title, isError) { + password = "" + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(readerString("desktop_password_protected_pdf", "Password protected PDF")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + if (isError) { + readerString( + "desktop_pdf_password_retry_desc", + "That password did not open %1\$s. Enter the PDF password and try again.", + title + ) + } else { + readerString("desktop_pdf_password_required_desc", "%1\$s requires a password before it can be opened.", title) + } + ) + SharedStableOutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(readerString("password", "Password")) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + if (isError) { + Text( + readerString("desktop_pdf_password_required_or_incorrect", "Password is required or incorrect."), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton( + enabled = password.isNotEmpty(), + onClick = { onConfirm(password) } + ) { + Text(readerString("action_open", "Open")) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(readerString("action_cancel", "Cancel")) + } + } + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt new file mode 100644 index 0000000..1be1c5c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReaderScreen.kt @@ -0,0 +1,4376 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import org.dueattendant149.bookreader.shared.AiAdapter +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderAiResultState +import org.dueattendant149.bookreader.shared.ReaderCloudTtsState +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsPlanner +import org.dueattendant149.bookreader.shared.ReaderTtsProgress +import org.dueattendant149.bookreader.shared.ReaderTtsReadScope +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.SaveMode +import org.dueattendant149.bookreader.shared.SearchHighlightMode +import org.dueattendant149.bookreader.shared.SharedFeaturePolicy +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.externalLookupUrl +import org.dueattendant149.bookreader.shared.withTtsReplacements +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout +import org.dueattendant149.bookreader.shared.pdf.PdfVisiblePageLayout +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.pdf.SharedPdfJumpHistory +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchEngine +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextAnnotationDefaults +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextDraft +import org.dueattendant149.bookreader.shared.pdf.SharedPdfTextStyleConfig +import org.dueattendant149.bookreader.shared.pdf.mostVisiblePdfPageIndex +import org.dueattendant149.bookreader.shared.pdf.pdfVerticalPageGapDp +import org.dueattendant149.bookreader.shared.pdf.reduce +import org.dueattendant149.bookreader.shared.pdf.sharedPdfTextStyle +import org.dueattendant149.bookreader.shared.pdf.toAnnotation +import org.dueattendant149.bookreader.shared.pdf.withBounds +import org.dueattendant149.bookreader.shared.pdf.withSharedPdfTextStyle +import org.dueattendant149.bookreader.shared.pdf.withStyle +import org.dueattendant149.bookreader.shared.pdf.withText +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.readerCloudTtsControlsModel +import org.dueattendant149.bookreader.shared.reduce +import org.dueattendant149.bookreader.shared.ui.ReaderWorkspaceFileActionState +import org.dueattendant149.bookreader.shared.ui.ReaderWorkspaceShell +import org.dueattendant149.bookreader.shared.ui.LocalSharedStringResolver +import org.dueattendant149.bookreader.shared.ui.SharedPdfAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfEmbeddedAnnotationOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfInlineTextEditorOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfInteractionDock +import org.dueattendant149.bookreader.shared.ui.SharedPdfPageNumberOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextHiddenInput +import org.dueattendant149.bookreader.shared.ui.SharedPdfRichTextLayer +import org.dueattendant149.bookreader.shared.ui.SharedPdfTextBoxEditorOverlay +import org.dueattendant149.bookreader.shared.ui.SharedPdfVerticalScrollbar +import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsOverlayControls +import org.dueattendant149.bookreader.shared.ui.pdfReaderWorkspaceModel +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedPdfEmbeddedHitTest +import org.dueattendant149.bookreader.shared.ui.sharedPdfHitTest +import org.dueattendant149.bookreader.shared.ui.toSharedPdfPoint +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.event.KeyEvent as AwtKeyEvent +import java.io.File +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs +import kotlin.math.roundToInt + +private val DesktopPdfReaderFullscreenFocusRetryDelaysMillis = longArrayOf(80L, 120L, 160L, 240L) +private const val DesktopPdfPaginationPageTurnAnimationMillis = 140 + +private data class DesktopPdfPaginatedPageDisplay( + val pageIndex: Int, + val render: DesktopPdfPageRender +) + +private data class DesktopPdfPendingPaginatedScrollRestore( + val requestId: Int, + val pageIndex: Int, + val zoom: Float, + val horizontalScroll: Int, + val verticalScroll: Int +) + +internal fun desktopPdfInitialPageIndex( + requestedPageIndex: Int, + pageCount: Int, + displayMode: PdfDisplayMode, + settings: ReaderSettings +): Int { + val clampedPageIndex = requestedPageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.normalizePageIndex(clampedPageIndex, pageCount, settings) + } else { + clampedPageIndex + } +} + +@Composable +internal fun PdfReaderScreen( + document: DesktopPdfDocument, + initialPageIndex: Int, + initialViewport: SharedPdfReaderViewport? = null, + initialReaderSettings: ReaderSettings? = null, + onReturnToLibrary: (() -> Unit)? = null, + onFullscreenChange: (Boolean) -> Unit = {}, + appThemeControls: (@Composable () -> Unit)? = null, + onPageStateChange: (pageIndex: Int, progress: Float, viewport: SharedPdfReaderViewport) -> Unit, + onReaderSettingsChange: (ReaderSettings) -> Unit = {}, + pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(), + onPdfHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {}, + customReaderThemes: List = emptyList(), + onCustomReaderThemesChange: (List) -> Unit = {}, + customTextureIds: List = emptyList(), + onImportTexture: ((ReaderSettings) -> ReaderSettings?)? = null, + onLocalSidecarsChanged: () -> Unit = {}, + aiByokSettings: ReaderAiByokSettings, + aiAdapter: AiAdapter, + ttsAdapter: DesktopGeminiCloudTtsAdapter, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + summaryCacheStore: DesktopSummaryCacheStore = DesktopSummaryCacheStore(), + credits: Int = 0, + showPaidCredits: Boolean = false, + onAiByokSettingsChange: (ReaderAiByokSettings) -> Unit = {}, + featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard, + cloudTtsControlsAvailable: Boolean = true, + onReaderAiEntitlementRequired: (ReaderAiFeature, String) -> Boolean = { _, _ -> false }, + onCloudTtsEntitlementRequired: () -> Boolean = { false }, + onPaidFeatureError: (String?) -> Unit = {}, + hasReflowFile: Boolean = false, + isReflowingThisBook: Boolean = false, + onReflowAction: ((pageIndex: Int) -> Unit)? = null +) { + val documentHandleId = document.handleId + val stringResolver = LocalSharedStringResolver.current + fun pdfString(name: String, fallback: String, vararg args: Any?): String { + return stringResolver.string(name, fallback, *args) + } + val zoomSpec = remember { DesktopPdfZoomSpec } + val initialDesktopPdfReaderSettings = remember(documentHandleId, initialReaderSettings) { + initialReaderSettings.toDesktopPdfReaderSettings() + } + val initialPdfDisplayMode = initialDesktopPdfReaderSettings.toDesktopPdfDisplayMode() + val restoredInitialViewport = remember( + documentHandleId, + initialViewport, + initialDesktopPdfReaderSettings, + initialPdfDisplayMode + ) { + initialViewport?.sanitized(document.pageCount, zoomSpec)?.let { viewport -> + viewport.copy( + pageIndex = desktopPdfInitialPageIndex( + requestedPageIndex = viewport.pageIndex, + pageCount = document.pageCount, + displayMode = initialPdfDisplayMode, + settings = initialDesktopPdfReaderSettings + ) + ) + } + } + val initialPdfPageIndex = remember( + documentHandleId, + initialPageIndex, + restoredInitialViewport, + initialPdfDisplayMode, + initialDesktopPdfReaderSettings + ) { + desktopPdfInitialPageIndex( + requestedPageIndex = restoredInitialViewport?.pageIndex ?: initialPageIndex, + pageCount = document.pageCount, + displayMode = initialPdfDisplayMode, + settings = initialDesktopPdfReaderSettings + ) + } + var pdfReaderSettings by remember(documentHandleId) { + mutableStateOf(initialDesktopPdfReaderSettings) + } + var pdfState by remember(documentHandleId) { + mutableStateOf( + SharedPdfReaderState.initial( + pageCount = document.pageCount, + initialPageIndex = initialPdfPageIndex, + zoomSpec = zoomSpec + ).copy( + displayMode = initialPdfDisplayMode, + zoom = restoredInitialViewport?.zoom ?: zoomSpec.clamp(zoomSpec.default) + ) + ) + } + var renderedPage by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageIndex by remember(documentHandleId) { mutableStateOf(null) } + var renderedPageScale by remember(documentHandleId) { mutableStateOf(null) } + var renderError by remember(documentHandleId) { mutableStateOf(null) } + var isRendering by remember(documentHandleId) { mutableStateOf(false) } + var renderJob by remember(documentHandleId) { mutableStateOf(null) } + val zoomAnchorJob = remember(documentHandleId) { AtomicReference(null) } + val zoomCommitJob = remember(documentHandleId) { AtomicReference(null) } + var pdfZoomPreview by remember(documentHandleId) { mutableStateOf(null) } + var pdfZoomSettleSequence by remember(documentHandleId) { mutableIntStateOf(0) } + var pdfNavigationScrollRestoreSequence by remember(documentHandleId) { mutableIntStateOf(0) } + var pendingPdfNavigationScrollRestore by remember(documentHandleId) { + mutableStateOf(null) + } + var activeTextDraft by remember(documentHandleId) { mutableStateOf(null) } + var textStyleConfig by remember(documentHandleId) { mutableStateOf(SharedPdfTextStyleConfig()) } + var pageCanvasSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } + var pdfZoomViewportRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + var pdfZoomViewportSize by remember(documentHandleId) { mutableStateOf(IntSize.Zero) } + var paginatedPageRootOffset by remember(documentHandleId) { mutableStateOf(Offset.Zero) } + val paginatedPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } + val paginatedPageCanvasSizes = remember(documentHandleId) { mutableStateMapOf() } + val verticalPageRootOffsets = remember(documentHandleId) { mutableStateMapOf() } + val paginatedRenderCache = remember(documentHandleId) { mutableStateMapOf() } + var activeStroke by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf>(emptyList()) } + var eraserPosition by remember(documentHandleId, pdfState.pageIndex, pdfState.selectedTool) { mutableStateOf(null) } + var isHighlighterSnapEnabled by remember(documentHandleId) { mutableStateOf(false) } + var selectionStartIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionEndIndex by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionStartHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionEndHit by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var textSelection by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var selectionMenuOffset by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var activeSelectionHandle by remember(documentHandleId, pdfState.pageIndex) { mutableStateOf(null) } + var pageScrubPreview by remember(documentHandleId) { mutableStateOf(null) } + var pageScrubStartPage by remember(documentHandleId) { mutableStateOf(null) } + var showPdfZoomIndicator by remember(documentHandleId) { mutableStateOf(false) } + var isPdfZoomIndicatorInitialized by remember(documentHandleId) { mutableStateOf(false) } + var jumpHistory by remember(documentHandleId) { mutableStateOf(SharedPdfJumpHistory()) } + var externalLinkDialogUrl by remember(documentHandleId) { mutableStateOf(null) } + var pdfExtrasState by remember(documentHandleId) { + mutableStateOf( + ReaderExtrasState( + cloudTts = ReaderCloudTtsState( + isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, + cacheSummary = ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) + ) + ) + ) + } + var pdfTtsJob by remember(documentHandleId) { mutableStateOf(null) } + var showPdfAiHub by remember(documentHandleId) { mutableStateOf(false) } + var pdfAiResultRequestId by remember(documentHandleId) { mutableStateOf(0L) } + var dismissedPdfAiResultRequestId by remember(documentHandleId) { mutableStateOf(null) } + var pdfHubSummaryResult by remember(documentHandleId) { mutableStateOf(null) } + var isPdfHubSummaryLoading by remember(documentHandleId) { mutableStateOf(false) } + var isPdfTtsOverlayCollapsed by remember(documentHandleId) { mutableStateOf(false) } + val annotationFile = remember(documentHandleId) { desktopPdfAnnotationFile(document.path) } + val bookmarkFile = remember(documentHandleId) { desktopPdfBookmarkFile(document.path) } + val richTextFile = remember(documentHandleId) { desktopPdfRichTextFile(document.path) } + val searchIndexFile = remember(documentHandleId) { desktopPdfSearchIndexFile(document.path) } + val clipboardManager = LocalClipboardManager.current + val density = LocalDensity.current + val pdfScope = rememberCoroutineScope() + var isFullscreen by remember(documentHandleId) { mutableStateOf(false) } + var showPdfSaveDialog by remember(documentHandleId) { mutableStateOf(false) } + var isPdfFileActionLoading by remember(documentHandleId) { mutableStateOf(false) } + var pdfFileActionNotice by remember(documentHandleId) { mutableStateOf(null) } + val currentPdfFullscreen by rememberUpdatedState(isFullscreen) + val currentOnPdfFullscreenChange by rememberUpdatedState(onFullscreenChange) + DisposableEffect(documentHandleId) { + onDispose { + renderJob?.cancel() + pdfTtsJob?.cancel() + zoomCommitJob.getAndSet(null)?.cancel() + zoomAnchorJob.getAndSet(null)?.cancel() + if (currentPdfFullscreen) { + currentOnPdfFullscreenChange(false) + } + document.close() + } + } + var isRichTextMode by remember(documentHandleId) { mutableStateOf(false) } + var isRichTextLoaded by remember(documentHandleId) { mutableStateOf(false) } + val richTextController = remember(documentHandleId) { + SharedPdfRichTextController( + scope = pdfScope, + onDocumentChange = { richDocument -> + if (isRichTextLoaded) { + SharedPdfRichTextLog.d( + "desktop.documentChange save path=\"${richTextFile.absolutePath.logPreview(160)}\" " + + "textLen=${richDocument.text.length} spans=${richDocument.spans.size}" + ) + withContext(Dispatchers.IO) { + richTextFile.parentFile?.mkdirs() + richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument)) + } + SharedPdfRichTextLog.d( + "desktop.documentChange saved path=\"${richTextFile.absolutePath.logPreview(160)}\" " + + "lastModified=${richTextFile.lastModified()}" + ) + onLocalSidecarsChanged() + } else { + SharedPdfRichTextLog.d( + "desktop.documentChange ignoredBeforeLoad path=\"${richTextFile.absolutePath.logPreview(160)}\" " + + "textLen=${richDocument.text.length} spans=${richDocument.spans.size}" + ) + } + } + ) + } + val pageVerticalScrollState = rememberScrollState( + initial = restoredInitialViewport?.paginatedVerticalScrollOffset ?: 0 + ) + val pageHorizontalScrollState = rememberScrollState( + initial = restoredInitialViewport?.horizontalScrollOffset ?: 0 + ) + val verticalListState = rememberLazyListState( + initialFirstVisibleItemIndex = restoredInitialViewport + ?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL } + ?.verticalFirstPageIndex + ?: pdfState.pageIndex, + initialFirstVisibleItemScrollOffset = restoredInitialViewport + ?.takeIf { it.displayMode == PdfDisplayMode.VERTICAL_SCROLL } + ?.verticalFirstPageScrollOffset + ?: 0 + ) + val pdfReaderFocusRequester = remember(documentHandleId) { FocusRequester() } + var pdfReaderFocusRestoreRequest by remember(documentHandleId) { mutableIntStateOf(0) } + val currentTextSelection by rememberUpdatedState(textSelection) + val currentPdfAnnotations by rememberUpdatedState(pdfState.annotations) + val currentPdfPageIndex by rememberUpdatedState(pdfState.pageIndex) + val currentPdfScale by rememberUpdatedState(pdfState.zoom) + val currentPdfDisplayMode by rememberUpdatedState(pdfState.displayMode) + val pdfSelectionSheetActive = pdfState.selectedAnnotationId?.let { selectedId -> + pdfState.annotations.any { it.id == selectedId && it.isDesktopTextSelectionHighlight } + } == true + val shouldRestorePdfReaderFocus = + !pdfState.isSearchActive && + !pdfSelectionSheetActive && + externalLinkDialogUrl == null && + !pdfExtrasState.aiResult.hasContent && + activeTextDraft == null && + !isRichTextMode && + (textSelection == null || selectionMenuOffset == null) + val currentShouldRestorePdfReaderFocus by rememberUpdatedState(shouldRestorePdfReaderFocus) + fun requestPdfReaderFocusRestore() { + pdfReaderFocusRestoreRequest += 1 + } + + LaunchedEffect(isFullscreen, documentHandleId) { + for (delayMillis in DesktopPdfReaderFullscreenFocusRetryDelaysMillis) { + delay(delayMillis) + if (currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + } + + LaunchedEffect(shouldRestorePdfReaderFocus, documentHandleId) { + if (shouldRestorePdfReaderFocus) { + delay(120L) + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + + fun clearPdfInteractionState() { + activeStroke = emptyList() + eraserPosition = null + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + textSelection = null + selectionMenuOffset = null + activeSelectionHandle = null + } + + fun dispatchPdf(action: SharedPdfReaderAction) { + val previousPage = pdfState.pageIndex + val previousAnnotationIds = pdfState.annotations.mapTo(mutableSetOf()) { it.id } + val next = pdfState.reduce(action, zoomSpec) + val nextAnnotationIds = next.annotations.mapTo(mutableSetOf()) { it.id } + val removedAnnotationIds = previousAnnotationIds - nextAnnotationIds + if (removedAnnotationIds.isNotEmpty()) { + DesktopCloudSidecarSync.recordAnnotationDeletions( + documentPath = document.path, + logBookId = documentHandleId.toString(), + annotationIds = removedAnnotationIds + ) + } + pdfState = next + if (next.pageIndex != previousPage) { + clearPdfInteractionState() + } + } + + fun setPdfFullscreen(enabled: Boolean) { + isFullscreen = enabled + onFullscreenChange(enabled) + } + + fun updatePdfReaderSettings(settings: ReaderSettings) { + val nextSettings = settings.toDesktopPdfReaderSettings() + pdfReaderSettings = nextSettings + onReaderSettingsChange(nextSettings) + } + + fun commitActiveTextDraft() { + val draft = activeTextDraft ?: return + activeTextDraft = null + val annotation = draft.toAnnotation() + if (annotation.text.isNotEmpty()) { + dispatchPdf(SharedPdfReaderAction.AnnotationAdded(annotation)) + } + } + + fun persistActiveTextDraftIfReady(draft: SharedPdfTextDraft) { + val annotation = draft.toAnnotation() + if (annotation.text.isNotEmpty()) { + activeTextDraft = null + textStyleConfig = draft.style + dispatchPdf(SharedPdfReaderAction.AnnotationAdded(annotation)) + } else { + activeTextDraft = draft + } + } + + fun startActiveTextDraft(pageIndex: Int, anchor: Offset, canvasSize: IntSize) { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return + commitActiveTextDraft() + clearPdfInteractionState() + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + val now = System.currentTimeMillis() + activeTextDraft = SharedPdfTextAnnotationDefaults.createDraft( + id = "text_$now", + pageIndex = pageIndex, + anchor = anchor.toSharedPdfPoint(canvasSize, now), + canvasSize = canvasSize, + style = textStyleConfig, + createdAt = now + ) + } + + fun updateActiveTextDraft(text: String, canvasSize: IntSize) { + activeTextDraft?.withText(text, canvasSize)?.let(::persistActiveTextDraftIfReady) + } + + fun updateActiveTextDraftBounds(bounds: PdfPageBounds) { + activeTextDraft = activeTextDraft?.withBounds(bounds) + } + + fun activeTextDraftContains(pageIndex: Int, offset: Offset, canvasSize: IntSize): Boolean { + return activeTextDraft?.containsOffset(pageIndex, offset, canvasSize) == true + } + + fun updateTextStyleConfig(style: SharedPdfTextStyleConfig) { + textStyleConfig = style + val draft = activeTextDraft + if (draft != null) { + activeTextDraft = if (draft.pageIndex == pdfState.pageIndex && pageCanvasSize.width > 0 && pageCanvasSize.height > 0) { + draft.withStyle(style, pageCanvasSize) + } else { + draft.copy(style = style) + } + return + } + + val selectedTextAnnotation = pdfState.annotations.firstOrNull { + it.id == pdfState.selectedAnnotationId && it.kind == PdfAnnotationKind.TEXT + } + if (selectedTextAnnotation != null) { + dispatchPdf(SharedPdfReaderAction.AnnotationUpdated(selectedTextAnnotation.withSharedPdfTextStyle(style))) + } + } + + fun selectTextAnnotation(annotation: SharedPdfAnnotation) { + if (annotation.kind != PdfAnnotationKind.TEXT) return + SharedPdfRichTextLog.d( + "desktop.textBox.select id=${annotation.id} page=${annotation.pageIndex} " + + "richMode=$isRichTextMode textLen=${annotation.text.length}" + ) + if (isRichTextMode) { + isRichTextMode = false + pdfScope.launch { richTextController.saveImmediate() } + } + commitActiveTextDraft() + clearPdfInteractionState() + textStyleConfig = annotation.sharedPdfTextStyle() + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation.id)) + } + + fun activateRichTextMode() { + SharedPdfRichTextLog.d( + "desktop.mode.activate page=${pdfState.pageIndex} " + + "globalLen=${richTextController.globalTextFieldValue.text.length} layouts=${richTextController.pageLayouts.size}" + ) + commitActiveTextDraft() + clearPdfInteractionState() + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + if (pdfState.isTextSelectionMode) { + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) + } + isRichTextMode = true + } + + fun deactivateRichTextMode(save: Boolean = true) { + if (!isRichTextMode) return + SharedPdfRichTextLog.d( + "desktop.mode.deactivate page=${pdfState.pageIndex} save=$save " + + "activePage=${richTextController.activePageIndex} globalLen=${richTextController.globalTextFieldValue.text.length}" + ) + isRichTextMode = false + if (save) { + pdfScope.launch { richTextController.saveImmediate() } + } else { + richTextController.clearSelection() + } + } + + fun selectPdfAnnotationTool(tool: PdfInkTool) { + SharedPdfRichTextLog.d( + "desktop.tool.select tool=$tool richMode=$isRichTextMode page=${pdfState.pageIndex}" + ) + val previousTool = pdfState.selectedTool + deactivateRichTextMode() + if (tool != PdfInkTool.TEXT) { + commitActiveTextDraft() + } + if (pdfState.isTextSelectionMode) { + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) + clearPdfInteractionState() + } + if (previousTool != tool) { + dispatchPdf(SharedPdfReaderAction.ToolSelected(tool)) + } + } + + val pageIndex = pdfState.pageIndex + val scale = pdfState.zoom + val displayMode = pdfState.displayMode + val rightToLeftPdfPaginationActive = displayMode == PdfDisplayMode.PAGINATION && + pdfReaderSettings.rightToLeftPagination + val isPdfTwoPageSpread = displayMode == PdfDisplayMode.PAGINATION && + PdfSpreadLayout.isTwoPageSpreadEnabled(pdfReaderSettings) + val paginatedSpreadPageIndices: List = remember( + pageIndex, + document.pageCount, + displayMode, + pdfReaderSettings.pageSpreadMode, + pdfReaderSettings.pdfFirstPageStandaloneInSpread + ) { + if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.visiblePageIndices(pageIndex, document.pageCount, pdfReaderSettings) + } else { + listOf(pageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0))) + } + } + val paginatedVisiblePageIndices = remember( + paginatedSpreadPageIndices, + rightToLeftPdfPaginationActive + ) { + if (rightToLeftPdfPaginationActive) paginatedSpreadPageIndices.asReversed() else paginatedSpreadPageIndices + } + val pdfPageLabel = desktopPdfPageLabel(pageIndex, document.pageCount, displayMode, pdfReaderSettings) + val pdfPageScrubPreviewLabel = pageScrubPreview?.let { + desktopPdfPageLabel(it, document.pageCount, displayMode, pdfReaderSettings) + } + val zoomControlScale = pdfZoomPreview?.zoom ?: scale + val shouldShowPdfZoomIndicator = abs(zoomControlScale - 1f) > 0.001f + + LaunchedEffect( + documentHandleId, + displayMode, + pdfReaderSettings.pageSpreadMode, + pdfReaderSettings.pdfFirstPageStandaloneInSpread, + pageIndex + ) { + if (displayMode != PdfDisplayMode.PAGINATION) return@LaunchedEffect + val normalizedPage = PdfSpreadLayout.normalizePageIndex(pageIndex, document.pageCount, pdfReaderSettings) + if (normalizedPage != pageIndex) { + dispatchPdf(SharedPdfReaderAction.GoToPage(normalizedPage)) + } + } + + LaunchedEffect(documentHandleId, pageIndex) { + pdfHubSummaryResult = null + isPdfHubSummaryLoading = false + } + + LaunchedEffect(zoomControlScale, document.path) { + if (!isPdfZoomIndicatorInitialized) { + isPdfZoomIndicatorInitialized = true + showPdfZoomIndicator = false + return@LaunchedEffect + } + if (shouldShowPdfZoomIndicator) { + showPdfZoomIndicator = true + delay(1_500) + showPdfZoomIndicator = false + } else { + showPdfZoomIndicator = false + } + } + + LaunchedEffect(documentHandleId, displayMode) { + if (!DesktopDiagnosticsEnabled) return@LaunchedEffect + snapshotFlow { + "mode=$displayMode page=${currentPdfPageIndex + 1} scale=${currentPdfScale.formatLogFloat()} " + + "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + .distinctUntilChanged() + .collect { summary -> + logPdfZoomSettle { "scroll_state seq=$pdfZoomSettleSequence $summary" } + } + } + + fun verticalZoomAnchorItem(anchor: Offset) = verticalListState.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + anchor.y >= item.offset.toFloat() && anchor.y <= (item.offset + item.size).toFloat() + } + ?: verticalListState.layoutInfo.visibleItemsInfo.minByOrNull { item -> + when { + anchor.y < item.offset.toFloat() -> item.offset.toFloat() - anchor.y + anchor.y > (item.offset + item.size).toFloat() -> anchor.y - (item.offset + item.size).toFloat() + else -> 0f + } + } + + fun paginatedZoomPageRoot(page: Int?): Offset? { + if (page == null) return null + return paginatedPageRootOffsets[page] + ?: paginatedPageRootOffset.takeIf { page == currentPdfPageIndex } + } + + fun paginatedZoomAnchorPageIndex(anchor: Offset?): Int { + val activePageIndex = currentPdfPageIndex + if (!isPdfTwoPageSpread) return activePageIndex + val rootOffsets = paginatedPageRootOffsets.toMutableMap() + paginatedSpreadPageIndices.firstOrNull()?.let { firstSpreadPage -> + rootOffsets.putIfAbsent(firstSpreadPage, paginatedPageRootOffset) + } + val pageSizes = paginatedPageCanvasSizes.toMutableMap() + if (pageCanvasSize.width > 0 && pageCanvasSize.height > 0) { + pageSizes.putIfAbsent(activePageIndex, pageCanvasSize) + } + return desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = pdfZoomViewportRootOffset, + anchor = anchor, + visiblePageIndices = paginatedVisiblePageIndices, + pageRootOffsets = rootOffsets, + pageSizes = pageSizes, + fallbackPageIndex = activePageIndex + ) + } + + LaunchedEffect(scale, displayMode, pageIndex, isPdfTwoPageSpread, paginatedSpreadPageIndices) { + val preview = pdfZoomPreview ?: return@LaunchedEffect + val paginationPreviewPageVisible = if (isPdfTwoPageSpread) { + preview.pageIndex in paginatedSpreadPageIndices + } else { + preview.pageIndex == pageIndex + } + if ( + preview.displayMode != displayMode || + (!paginationPreviewPageVisible && displayMode == PdfDisplayMode.PAGINATION) || + !desktopPdfZoomPreviewMatchesScale(preview, scale) + ) { + logPdfZoomSettle { + "preview_cancel seq=$pdfZoomSettleSequence reason=state_mismatch mode=$displayMode " + + "page=${pageIndex + 1} scale=${scale.formatLogFloat()} previewMode=${preview.displayMode} " + + "previewPage=${preview.pageIndex?.plus(1) ?: "none"} base=${preview.baseZoom.formatLogFloat()} " + + "zoom=${preview.zoom.formatLogFloat()}" + } + pdfZoomPreview = null + zoomCommitJob.getAndSet(null)?.cancel() + } + } + + fun applyAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { + if (pdfZoomSettleSequence == 0) { + pdfZoomSettleSequence = 1 + } + val settleSequence = pdfZoomSettleSequence + val activePageIndex = currentPdfPageIndex + val activeDisplayMode = currentPdfDisplayMode + logPdfZoomPerf { + "commit_start mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " + + "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + + "renderPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "renderJobActive=${renderJob?.isActive == true}" + } + val committedPreview = pdfZoomPreview + val viewportRootOffsetAtZoomStart = committedPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset + val committedPreviewPageIndex = committedPreview?.pageIndex ?: activePageIndex + val pageRootOffsetAtZoomStart = committedPreview?.pageRootOffset + ?: paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset + val pageRootOffsetAtCommitStart = paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset + logPdfZoomSettle { + "commit_start seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + + "old=${oldZoom.formatLogFloat()} new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + + "preview=${committedPreview != null} previewPage=${committedPreview?.pageIndex?.plus(1) ?: "none"} " + + "previewBase=${committedPreview?.baseZoom?.formatLogFloat() ?: "none"} " + + "previewZoom=${committedPreview?.zoom?.formatLogFloat() ?: "none"} " + + "viewportStart=${viewportRootOffsetAtZoomStart.formatLogOffset()} " + + "pageStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + + "pageNow=${pageRootOffsetAtCommitStart.formatLogOffset()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + "${verticalListState.firstVisibleItemScrollOffset} renderPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} renderJob=${renderJob?.isActive == true}" + } + val rawTargetHorizontalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(pageHorizontalScrollState.value, it.x, oldZoom, newZoom) + } + val rawTargetVerticalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(pageVerticalScrollState.value, it.y, oldZoom, newZoom) + } + val paginationCommitPrediction: DesktopPdfLayoutScrollPrediction? = if (activeDisplayMode == PdfDisplayMode.PAGINATION) { + val predictedScale = zoomSpec.clamp(newZoom) + if (isPdfTwoPageSpread) { + val predictedSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> + document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> + visiblePageIndex to IntSize( + width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) + ) + } + }.toMap() + desktopPdfSpreadLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + visiblePageIndices = paginatedVisiblePageIndices, + pageCanvasSizes = predictedSizes, + horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, + verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() }, + pageGapPx = with(density) { + desktopPdfSpreadPageGapDp(pdfReaderSettings.pdfVerticalPageGapVisible).toPx() + } + ) + } else { + document.pageSizes.getOrNull(committedPreviewPageIndex)?.let { pageSize -> + desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + pageCanvasSize = IntSize( + width = (pageSize.width * predictedScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * predictedScale).roundToInt().coerceAtLeast(1) + ), + horizontalScroll = rawTargetHorizontalScroll ?: pageHorizontalScrollState.value, + verticalScroll = rawTargetVerticalScroll ?: pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() } + ) + } + } + } else { + null + } + val targetHorizontalScroll = rawTargetHorizontalScroll?.let { target -> + paginationCommitPrediction?.maxHorizontalScroll?.let { maxScroll -> + target.coerceIn(0, maxScroll) + } ?: target + } + val targetVerticalScroll = rawTargetVerticalScroll?.let { target -> + paginationCommitPrediction?.maxVerticalScroll?.let { maxScroll -> + target.coerceIn(0, maxScroll) + } ?: target + } + val targetVerticalItem = if (activeDisplayMode == PdfDisplayMode.VERTICAL_SCROLL && anchor != null) { + verticalZoomAnchorItem(anchor) + ?.let { item -> + val fallbackOffset = desktopPdfAnchoredLazyItemScrollOffset( + itemOffset = item.offset, + anchor = anchor.y, + oldZoom = oldZoom, + newZoom = newZoom + ) + val pageRootOffset = if (committedPreview?.pageIndex == item.index) { + committedPreview.pageRootOffset + } else { + verticalPageRootOffsets[item.index] + } + Triple(item.index, fallbackOffset, pageRootOffset) + } + } else { + null + } + logPdfZoomSettle { + "commit_targets seq=$settleSequence mode=$activeDisplayMode targetH=${targetHorizontalScroll ?: "none"} " + + "targetV=${targetVerticalScroll ?: "none"} rawH=${rawTargetHorizontalScroll ?: "none"} " + + "rawV=${rawTargetVerticalScroll ?: "none"} predictedMaxH=${paginationCommitPrediction?.maxHorizontalScroll ?: "none"} " + + "predictedMaxV=${paginationCommitPrediction?.maxVerticalScroll ?: "none"} " + + "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + + "targetItemOffset=${targetVerticalItem?.second ?: "none"} targetItemRoot=${targetVerticalItem?.third.formatLogOffset()}" + } + var committedPreviewForClear = committedPreview + committedPreview?.let { preview -> + val previewWithCommitTargets = preview.copy( + commitTargetHorizontalScroll = targetHorizontalScroll, + commitTargetVerticalScroll = targetVerticalScroll.takeIf { + activeDisplayMode == PdfDisplayMode.PAGINATION + } + ) + if (pdfZoomPreview == preview) { + pdfZoomPreview = previewWithCommitTargets + committedPreviewForClear = previewWithCommitTargets + logPdfZoomSettle { + "preview_commit_targets seq=$settleSequence targetH=${targetHorizontalScroll ?: "none"} " + + "targetV=${targetVerticalScroll ?: "none"}" + } + } + } + dispatchPdf(SharedPdfReaderAction.ZoomChanged(newZoom)) + fun clearCommittedPreview() { + val matchesCommittedPreview = pdfZoomPreview == committedPreviewForClear + logPdfZoomSettle { + "preview_clear seq=$settleSequence match=$matchesCommittedPreview " + + "current=${pdfZoomPreview != null} committed=${committedPreview != null}" + } + if (matchesCommittedPreview) { + pdfZoomPreview = null + } + } + logPdfZoomSettle { + "zoom_dispatched seq=$settleSequence new=${newZoom.formatLogFloat()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + if (anchor != null) { + zoomAnchorJob.getAndSet(null)?.cancel() + val nextAnchorJob = pdfScope.launch(start = CoroutineStart.UNDISPATCHED) { + when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> { + if (targetHorizontalScroll != null || targetVerticalScroll != null) { + val beforeH = pageHorizontalScrollState.value + val beforeV = pageVerticalScrollState.value + targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) } + targetVerticalScroll?.let { pageVerticalScrollState.scrollTo(it) } + logPdfZoomSettle { + "anchor_pre_scroll seq=$settleSequence mode=pagination beforeH=$beforeH beforeV=$beforeV " + + "targetH=${targetHorizontalScroll ?: "none"} targetV=${targetVerticalScroll ?: "none"} " + + "afterH=${pageHorizontalScrollState.value} afterV=${pageVerticalScrollState.value} " + + "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue}" + } + } + withFrameNanos { } + suspend fun correctPageAnchor(pass: Int) { + val beforeH = pageHorizontalScrollState.value + val beforeV = pageVerticalScrollState.value + val currentRoot = paginatedZoomPageRoot(committedPreviewPageIndex) + ?: paginatedPageRootOffset + val pageDelta = desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = viewportRootOffsetAtZoomStart, + oldPageRootOffset = pageRootOffsetAtZoomStart, + currentPageRootOffset = currentRoot, + anchor = anchor, + oldZoom = oldZoom, + newZoom = newZoom + ) + val reachableDelta = pageDelta?.let { + desktopPdfReachableScrollDelta( + requestedDelta = it, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = pageVerticalScrollState.maxValue + ) + ) + } + logPdfZoomSettle { + "anchor_pass seq=$settleSequence pass=$pass mode=pagination beforeH=$beforeH " + + "beforeV=$beforeV delta=${pageDelta.formatLogIntOffset()} " + + "reachable=${reachableDelta.formatLogIntOffset()} " + + "maxH=${pageHorizontalScrollState.maxValue} maxV=${pageVerticalScrollState.maxValue} " + + "rootStart=${pageRootOffsetAtZoomStart.formatLogOffset()} " + + "rootNow=${currentRoot.formatLogOffset()} viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" + } + if (reachableDelta != null) { + if (abs(reachableDelta.x) > 1) { + pageHorizontalScrollState.scrollTo( + (pageHorizontalScrollState.value + reachableDelta.x).coerceAtLeast( + 0 + ) + ) + } + if (abs(reachableDelta.y) > 1) { + pageVerticalScrollState.scrollTo( + (pageVerticalScrollState.value + reachableDelta.y).coerceAtLeast( + 0 + ) + ) + } + } else if (targetHorizontalScroll != null && targetVerticalScroll != null) { + pageHorizontalScrollState.scrollTo(targetHorizontalScroll) + pageVerticalScrollState.scrollTo(targetVerticalScroll) + } + logPdfZoomSettle { + "anchor_pass_end seq=$settleSequence pass=$pass mode=pagination afterH=${pageHorizontalScrollState.value} " + + "afterV=${pageVerticalScrollState.value} delta=${pageDelta.formatLogIntOffset()} " + + "reachable=${reachableDelta.formatLogIntOffset()}" + } + } + correctPageAnchor(pass = 1) + withFrameNanos { } + correctPageAnchor(pass = 2) + } + + PdfDisplayMode.VERTICAL_SCROLL -> { + withFrameNanos { } + suspend fun correctVerticalAnchor(pass: Int) { + val beforeH = pageHorizontalScrollState.value + val beforeItem = verticalListState.firstVisibleItemIndex + val beforeItemOffset = verticalListState.firstVisibleItemScrollOffset + val oldPageRootOffset = targetVerticalItem?.third + val currentPageRootOffset = + targetVerticalItem?.first?.let { verticalPageRootOffsets[it] } + val pageDelta = + if (oldPageRootOffset != null && currentPageRootOffset != null) { + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = viewportRootOffsetAtZoomStart, + oldPageRootOffset = oldPageRootOffset, + currentPageRootOffset = currentPageRootOffset, + anchor = anchor, + oldZoom = oldZoom, + newZoom = newZoom + ) + } else { + null + } + logPdfZoomSettle { + "anchor_pass seq=$settleSequence pass=$pass mode=vertical beforeH=$beforeH " + + "beforeList=$beforeItem:$beforeItemOffset delta=${pageDelta.formatLogIntOffset()} " + + "targetItem=${targetVerticalItem?.first?.plus(1) ?: "none"} " + + "oldRoot=${oldPageRootOffset.formatLogOffset()} " + + "currentRoot=${currentPageRootOffset.formatLogOffset()} " + + "viewport=${viewportRootOffsetAtZoomStart.formatLogOffset()}" + } + if (pageDelta != null) { + if (abs(pageDelta.x) > 1) { + pageHorizontalScrollState.scrollTo( + (pageHorizontalScrollState.value + pageDelta.x).coerceAtLeast( + 0 + ) + ) + } + if (abs(pageDelta.y) > 1) { + verticalListState.scrollBy(pageDelta.y.toFloat()) + } + } else { + targetHorizontalScroll?.let { pageHorizontalScrollState.scrollTo(it) } + targetVerticalItem?.let { (itemIndex, scrollOffset, _) -> + verticalListState.scrollToItem(itemIndex, scrollOffset) + } + } + logPdfZoomSettle { + "anchor_pass_end seq=$settleSequence pass=$pass mode=vertical afterH=${pageHorizontalScrollState.value} " + + "afterList=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + + "delta=${pageDelta.formatLogIntOffset()}" + } + } + correctVerticalAnchor(pass = 1) + withFrameNanos { } + correctVerticalAnchor(pass = 2) + } + } + clearCommittedPreview() + } + zoomAnchorJob.set(nextAnchorJob) + } else { + val nextAnchorJob = pdfScope.launch { + withFrameNanos { } + logPdfZoomSettle { + "anchor_skip seq=$settleSequence reason=no_anchor mode=$activeDisplayMode h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + clearCommittedPreview() + } + zoomAnchorJob.getAndSet(nextAnchorJob)?.cancel() + } + } + + fun previewAnchoredPdfZoom(oldZoom: Float, newZoom: Float, anchor: Offset?) { + val activePageIndex = currentPdfPageIndex + val activeScale = currentPdfScale + val activeDisplayMode = currentPdfDisplayMode + logPdfZoomPerf { + "preview mode=$activeDisplayMode page=${activePageIndex + 1} old=${oldZoom.formatLogFloat()} " + + "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} " + + "hasRender=${renderedPage != null && renderedPageIndex == activePageIndex} " + + "renderScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "renderJobActive=${renderJob?.isActive == true} cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" + } + val previewPageIndex = when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> paginatedZoomAnchorPageIndex(anchor) + PdfDisplayMode.VERTICAL_SCROLL -> anchor?.let(::verticalZoomAnchorItem)?.index ?: activePageIndex + } + val existingPreview = pdfZoomPreview?.takeIf { + it.displayMode == activeDisplayMode && + it.pageIndex == previewPageIndex && + it.baseZoom.isFinite() && + it.baseZoom > 0f && + abs(it.baseZoom - activeScale) <= 0.0001f + } + if (existingPreview == null && currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + if (existingPreview == null) { + pdfZoomSettleSequence += 1 + } + val settleSequence = pdfZoomSettleSequence + val baseZoom = existingPreview + ?.baseZoom + ?: oldZoom.takeIf { it.isFinite() && it > 0f } + ?: activeScale + val previewPageRootOffset = existingPreview?.pageRootOffset ?: when (activeDisplayMode) { + PdfDisplayMode.PAGINATION -> paginatedZoomPageRoot(previewPageIndex) + PdfDisplayMode.VERTICAL_SCROLL -> verticalPageRootOffsets[previewPageIndex] + } + pdfZoomPreview = DesktopPdfZoomPreview( + baseZoom = baseZoom, + zoom = newZoom, + anchor = anchor, + displayMode = activeDisplayMode, + pageIndex = previewPageIndex, + viewportRootOffset = existingPreview?.viewportRootOffset ?: pdfZoomViewportRootOffset, + pageRootOffset = previewPageRootOffset, + diagnosticSequence = settleSequence + ) + logPdfZoomSettle { + "preview_update seq=$settleSequence mode=$activeDisplayMode page=${activePageIndex + 1} " + + "previewPage=${previewPageIndex + 1} oldEvent=${oldZoom.formatLogFloat()} " + + "activeScale=${activeScale.formatLogFloat()} base=${baseZoom.formatLogFloat()} " + + "new=${newZoom.formatLogFloat()} anchor=${anchor.formatLogOffset()} existing=${existingPreview != null} " + + "viewport=${pdfZoomViewportRootOffset.formatLogOffset()} pageRoot=${previewPageRootOffset.formatLogOffset()} " + + "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "list=${verticalListState.firstVisibleItemIndex}:${verticalListState.firstVisibleItemScrollOffset} " + + "renderPage=${renderedPageIndex?.plus(1) ?: "none"} renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } + val nextCommitJob = pdfScope.launch { + delay(DesktopPdfZoomCommitDebounceMillis) + val preview = pdfZoomPreview ?: return@launch + logPdfZoomSettle { + "commit_debounce_fire seq=$settleSequence base=${preview.baseZoom.formatLogFloat()} " + + "zoom=${preview.zoom.formatLogFloat()} page=${preview.pageIndex?.plus(1) ?: "none"} " + + "anchor=${preview.anchor.formatLogOffset()}" + } + applyAnchoredPdfZoom(preview.baseZoom, preview.zoom, preview.anchor) + } + zoomCommitJob.getAndSet(nextCommitJob)?.cancel() + if ( + activeDisplayMode == PdfDisplayMode.PAGINATION && + renderedPage != null && + renderedPageIndex == activePageIndex + ) { + renderJob?.cancel() + } + } + + fun cancelPendingPdfZoomPreview() { + logPdfZoomSettle { + "preview_cancel seq=$pdfZoomSettleSequence reason=explicit pending=${pdfZoomPreview != null}" + } + pdfZoomPreview = null + zoomCommitJob.getAndSet(null)?.cancel() + } + + fun commitPendingPdfZoomPreviewForNavigation(targetPageIndex: Int) { + val snapshot = desktopPdfNavigationZoomSnapshot( + preview = pdfZoomPreview, + currentHorizontalScroll = pageHorizontalScrollState.value, + currentVerticalScroll = pageVerticalScrollState.value + ) ?: return + val committedZoom = zoomSpec.clamp(snapshot.zoom) + logPdfZoomSettle { + "preview_navigation_commit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "target=${targetPageIndex + 1} zoom=${committedZoom.formatLogFloat()} " + + "h=${snapshot.horizontalScroll} v=${snapshot.verticalScroll}" + } + zoomCommitJob.getAndSet(null)?.cancel() + zoomAnchorJob.getAndSet(null)?.cancel() + pdfZoomPreview = null + dispatchPdf(SharedPdfReaderAction.ZoomChanged(committedZoom)) + if (displayMode == PdfDisplayMode.PAGINATION) { + pdfNavigationScrollRestoreSequence += 1 + pendingPdfNavigationScrollRestore = DesktopPdfPendingPaginatedScrollRestore( + requestId = pdfNavigationScrollRestoreSequence, + pageIndex = targetPageIndex.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)), + zoom = committedZoom, + horizontalScroll = snapshot.horizontalScroll, + verticalScroll = snapshot.verticalScroll + ) + } else { + pdfScope.launch { + pageHorizontalScrollState.scrollTo(snapshot.horizontalScroll) + } + } + } + + fun cachePaginatedRender(page: Int, renderScale: Float, render: DesktopPdfPageRender) { + paginatedRenderCache[page] = DesktopPdfCachedPageRender(render, renderScale) + val activePageIndex = currentPdfPageIndex + val keepRange = + (activePageIndex - DesktopPdfPaginationRenderCacheRadius)..(activePageIndex + DesktopPdfPaginationRenderCacheRadius) + val evictedPages = paginatedRenderCache.keys + .filter { it !in keepRange } + evictedPages.forEach { paginatedRenderCache.remove(it) } + logPdfZoomPerf { + "cache_put page=${page + 1} scale=${renderScale.formatLogFloat()} " + + "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + + "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" + } + logPdfZoomSettle { + "cache_put seq=$pdfZoomSettleSequence page=${page + 1} scale=${renderScale.formatLogFloat()} " + + "bitmap=${render.width}x${render.height} current=${activePageIndex + 1} " + + "keys=${paginatedRenderCache.keys.sorted().map { it + 1 }} evicted=${evictedPages.map { it + 1 }}" + } + } + + LaunchedEffect(documentHandleId, pageIndex, displayMode, scale) { + if (currentShouldRestorePdfReaderFocus) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + + val searchQuery = pdfState.searchQuery + val isPdfSearchActive = pdfState.isSearchActive + val showPdfSearchResultsPanel = pdfState.showSearchResultsPanel + val activeSearchIndex = pdfState.activeSearchResultIndex + val searchHighlightMode = pdfState.searchHighlightMode + val selectedTool = pdfState.selectedTool + val selectedColor = pdfState.selectedColorArgb + val strokeWidth = pdfState.strokeWidth + val pdfHighlighterColors = pdfHighlighterPalette.sanitized().colors + val isTextSelectionMode = pdfState.isTextSelectionMode + val bookmarks = pdfState.bookmarks + val selectedAnnotationId = pdfState.selectedAnnotationId + val annotations = pdfState.annotations + val canGoPrevious = if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.canGoPrevious(pageIndex, document.pageCount, pdfReaderSettings) + } else { + pdfState.canGoPrevious + } + val canGoNext = if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.canGoNext(pageIndex, document.pageCount, pdfReaderSettings) + } else { + pdfState.canGoNext + } + val progressPercent = if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.progressPercent(pageIndex, document.pageCount, pdfReaderSettings) + } else { + pdfState.progressPercent + } + val latestOnPageStateChange by rememberUpdatedState(onPageStateChange) + + fun pdfViewportSnapshot(): SharedPdfReaderViewport { + val state = pdfState + return SharedPdfReaderViewport( + pageIndex = state.pageIndex, + displayMode = state.displayMode, + zoom = pdfZoomPreview?.zoom ?: state.zoom, + horizontalScrollOffset = pageHorizontalScrollState.value, + paginatedVerticalScrollOffset = pageVerticalScrollState.value, + verticalFirstPageIndex = verticalListState.firstVisibleItemIndex, + verticalFirstPageScrollOffset = verticalListState.firstVisibleItemScrollOffset + ).sanitized(document.pageCount, zoomSpec) + } + + fun pdfProgressPercentFor(pageIndex: Int): Float { + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.progressPercent(pageIndex, document.pageCount, pdfReaderSettings) + } else { + ((pageIndex + 1).toFloat() / document.pageCount.coerceAtLeast(1)) * 100f + } + } + + var latestPdfViewport by remember(documentHandleId) { + mutableStateOf(restoredInitialViewport ?: pdfViewportSnapshot()) + } + + fun persistPdfViewport(viewport: SharedPdfReaderViewport = pdfViewportSnapshot()) { + latestPdfViewport = viewport + latestOnPageStateChange(viewport.pageIndex, pdfProgressPercentFor(viewport.pageIndex), viewport) + } + + val pdfThemeStyle = remember(pdfReaderSettings, displayMode) { + pdfReaderSettings.toDesktopPdfThemeStyle(displayMode) + } + val verticalRenderWindow = remember(pageIndex, document.pageCount) { + val start = (pageIndex - 1).coerceAtLeast(0) + val end = (pageIndex + 1).coerceAtMost((document.pageCount - 1).coerceAtLeast(0)) + start..end + } + var arePdfAnnotationsLoaded by remember(documentHandleId) { mutableStateOf(false) } + var arePdfBookmarksLoaded by remember(documentHandleId) { mutableStateOf(false) } + var indexedSearchPageCount by remember(documentHandleId) { mutableStateOf(document.indexedSearchTextPageCount()) } + var isSearchIndexing by remember(documentHandleId) { mutableStateOf(false) } + var searchResults by remember(documentHandleId) { mutableStateOf>(emptyList()) } + var selectedEmbeddedAnnotationId by remember(documentHandleId) { mutableStateOf(null) } + val selectedAnnotation = remember(annotations, selectedAnnotationId) { + annotations.firstOrNull { it.id == selectedAnnotationId } + } + val selectedTextHighlight = selectedAnnotation?.takeIf { it.isDesktopTextSelectionHighlight } + val sortedSidebarHighlights = remember(annotations) { + desktopPdfSidebarHighlights(annotations) + } + val sortedEmbeddedAnnotations = remember(document.embeddedAnnotations) { + document.embeddedAnnotations.sortedWith(compareBy { it.pageIndex }.thenBy { it.index }) + } + val selectedEmbeddedAnnotation = remember(document.embeddedAnnotations, selectedEmbeddedAnnotationId) { + document.embeddedAnnotations.firstOrNull { it.id == selectedEmbeddedAnnotationId } + } + val effectiveTextStyleConfig = remember(activeTextDraft, selectedAnnotation, textStyleConfig) { + activeTextDraft?.style + ?: selectedAnnotation?.takeIf { it.kind == PdfAnnotationKind.TEXT }?.sharedPdfTextStyle() + ?: textStyleConfig + } + val activePdfTtsChunk = pdfExtrasState.cloudTts.progress.currentChunk + val localPdfFile = remember(document.path, document.formatLabel) { + File(document.path).takeIf { document.formatLabel == "PDF" && it.isFile } + } + val pdfFileActions = remember(localPdfFile, hasReflowFile, isReflowingThisBook, onReflowAction) { + ReaderWorkspaceFileActionState( + canSaveCopy = localPdfFile != null, + canPrint = localPdfFile != null, + canGenerateTextView = localPdfFile != null && onReflowAction != null, + hasGeneratedTextView = hasReflowFile, + isGeneratingTextView = isReflowingThisBook + ) + } + val sidecarsReadyForExport = arePdfAnnotationsLoaded && isRichTextLoaded + val annotationsForExportChoice = remember(annotations, activeTextDraft) { + val draftAnnotation = activeTextDraft + ?.toAnnotation() + ?.takeIf { it.text.isNotBlank() } + if (draftAnnotation == null) annotations else annotations + draftAnnotation + } + val shouldShowAnnotationExportChoice = shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady = sidecarsReadyForExport, + annotations = annotationsForExportChoice, + richTextPageLayouts = richTextController.pageLayouts + ) + + fun runPdfFileAction( + successTitle: String, + action: suspend () -> String + ) { + if (isPdfFileActionLoading) return + pdfScope.launch { + isPdfFileActionLoading = true + try { + val message = action() + pdfFileActionNotice = DesktopPdfFileActionNotice( + title = successTitle, + message = message + ) + } catch (error: Throwable) { + pdfFileActionNotice = DesktopPdfFileActionNotice( + title = pdfString("desktop_pdf_action_failed", "PDF action failed"), + message = error.message ?: pdfString( + "desktop_pdf_action_failed_desc", + "The PDF action could not be completed." + ), + isError = true + ) + } finally { + isPdfFileActionLoading = false + } + } + } + + suspend fun preparePdfAnnotationExport() { + commitActiveTextDraft() + if (isRichTextMode) { + isRichTextMode = false + } + richTextController.saveImmediate() + } + + fun savePdfCopy(mode: SaveMode) { + val target = chooseSavePdfFile( + desktopSuggestedPdfFilename( + originalName = localPdfFile?.name ?: document.title, + isAnnotated = mode == SaveMode.ANNOTATED + ) + ) ?: return + runPdfFileAction(successTitle = pdfString("desktop_pdf_saved", "PDF saved")) { + if (mode == SaveMode.ANNOTATED) { + preparePdfAnnotationExport() + } + val annotationSnapshot = pdfState.annotations + val richTextSnapshot = richTextController.pageLayouts + withContext(Dispatchers.IO) { + saveDesktopPdfCopy( + document = document, + target = target, + mode = mode, + annotations = annotationSnapshot, + richTextPageLayouts = richTextSnapshot + ) + } + pdfString("desktop_saved_to_path_format", "Saved to %1\$s", target.absolutePath) + } + } + + val requestSaveCopy: () -> Unit = { + if (shouldShowAnnotationExportChoice) { + showPdfSaveDialog = true + } else { + savePdfCopy(SaveMode.ORIGINAL) + } + } + val requestPrint: () -> Unit = { + runPdfFileAction(successTitle = pdfString("action_print", "Print")) { + withContext(Dispatchers.IO) { + printDesktopPdfDocument(document) + } + pdfString("desktop_print_dialog_finished", "The print dialog has finished.") + } + } + + LaunchedEffect(selectedTool) { + activeStroke = emptyList() + eraserPosition = null + } + + fun updatePdfHighlighterPalette(nextPalette: SharedPdfHighlighterPalette) { + fun sameRgb(left: Int, right: Int): Boolean = (left and 0x00FFFFFF) == (right and 0x00FFFFFF) + + val previousSlot = pdfHighlighterPalette.sanitized().colors.indexOfFirst { sameRgb(it, selectedColor) } + val sanitizedPalette = nextPalette.sanitized() + onPdfHighlighterPaletteChange(sanitizedPalette) + if (selectedTool.isDesktopHighlighter && sanitizedPalette.colors.none { sameRgb(it, selectedColor) }) { + val colorArgb = sanitizedPalette.colors.getOrNull(previousSlot) + ?: sanitizedPalette.colors.firstOrNull() + colorArgb?.let { nextSelectedColor -> + dispatchPdf(SharedPdfReaderAction.ColorSelected(nextSelectedColor)) + } + } + } + + fun currentPdfTtsCacheSummary() = + ttsAdapter.cacheSummary(document.title, aiByokSettings.sanitized().ttsSpeakerId) + + DesktopExternalLinkDialog( + url = externalLinkDialogUrl, + onDismiss = { externalLinkDialogUrl = null } + ) + + val pdfPopupActive = + externalLinkDialogUrl != null || + showPdfAiHub || + showPdfSaveDialog || + pdfFileActionNotice != null || + isPdfFileActionLoading || + selectedTextHighlight != null || + selectedEmbeddedAnnotation != null || + pdfExtrasState.aiResult.hasContent || + (textSelection != null && selectionMenuOffset != null) + LaunchedEffect(pdfPopupActive, documentHandleId) { + if (!pdfPopupActive) { + delay(120L) + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + + LaunchedEffect(pdfReaderFocusRestoreRequest, documentHandleId) { + if (pdfReaderFocusRestoreRequest > 0) { + delay(140L) + if (currentShouldRestorePdfReaderFocus && !pdfPopupActive) { + runCatching { pdfReaderFocusRequester.requestFocus() } + } + } + } + + LaunchedEffect(aiByokSettings, cloudTtsControlsAvailable) { + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + isAvailable = cloudTtsControlsAvailable && aiByokSettings.isCloudTtsAvailable, + errorMessage = null, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + + DesktopPdfAnnotationSidecarEffect( + documentHandleId = documentHandleId, + annotationFile = annotationFile, + annotations = annotations, + annotationsLoaded = arePdfAnnotationsLoaded, + onAnnotationsLoadedChange = { arePdfAnnotationsLoaded = it }, + onAnnotationsLoaded = { loadedAnnotations -> + dispatchPdf(SharedPdfReaderAction.AnnotationsLoaded(loadedAnnotations)) + }, + onLocalSidecarsChanged = onLocalSidecarsChanged + ) + + DesktopPdfRichTextSidecarEffect( + documentHandleId = documentHandleId, + richTextFile = richTextFile, + richTextController = richTextController, + onRichTextLoadedChange = { isRichTextLoaded = it } + ) + + DesktopPdfBookmarkSidecarEffect( + documentHandleId = documentHandleId, + bookmarkFile = bookmarkFile, + bookmarks = bookmarks, + bookmarksLoaded = arePdfBookmarksLoaded, + onBookmarksLoadedChange = { arePdfBookmarksLoaded = it }, + onBookmarksLoaded = { loadedBookmarks -> + dispatchPdf(SharedPdfReaderAction.BookmarksLoaded(loadedBookmarks)) + }, + onLocalSidecarsChanged = onLocalSidecarsChanged + ) + + DesktopPdfSearchIndexSidecarEffect( + documentHandleId = documentHandleId, + document = document, + searchIndexFile = searchIndexFile, + onIndexedSearchPageCountChange = { indexedSearchPageCount = it }, + onSearchIndexingChange = { isSearchIndexing = it } + ) + + DesktopPdfSearchResultsEffect( + documentHandleId = documentHandleId, + document = document, + searchQuery = searchQuery, + indexedSearchPageCount = indexedSearchPageCount, + onSearchResultsChange = { searchResults = it } + ) + + fun goToPage( + target: Int, + scrollVertical: Boolean = true, + recordJump: Boolean = false, + saveRichTextBeforePageChange: Boolean = true, + commitPendingZoomPreview: Boolean = true + ) { + val boundedTarget = target.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) + val clampedTarget = if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.normalizePageIndex(boundedTarget, document.pageCount, pdfReaderSettings) + } else { + boundedTarget + } + val currentPage = pdfState.pageIndex + val selectingDifferentPageInSpread = displayMode == PdfDisplayMode.PAGINATION && + boundedTarget != currentPage + SharedPdfRichTextLog.d( + "desktop.goToPage target=$target clamped=$clampedTarget current=$currentPage " + + "richMode=$isRichTextMode scrollVertical=$scrollVertical recordJump=$recordJump " + + "saveRich=$saveRichTextBeforePageChange activePage=${richTextController.activePageIndex}" + ) + if (clampedTarget != currentPage || selectingDifferentPageInSpread) { + commitActiveTextDraft() + if (isRichTextMode && saveRichTextBeforePageChange) { + SharedPdfRichTextLog.d("desktop.goToPage savingRichTextBeforePageChange from=$currentPage to=$clampedTarget") + pdfScope.launch { richTextController.saveImmediate() } + } + } + if (recordJump) { + jumpHistory = jumpHistory.record( + currentPageIndex = currentPage, + targetPageIndex = clampedTarget, + pageCount = document.pageCount + ) + } + if (commitPendingZoomPreview) { + commitPendingPdfZoomPreviewForNavigation(clampedTarget) + } + dispatchPdf(SharedPdfReaderAction.GoToPage(clampedTarget)) + if (scrollVertical && displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + pdfScope.launch { + verticalListState.scrollToItem(clampedTarget) + } + } + } + + fun updatePdfPageScrub(value: Float) { + if (pageScrubStartPage == null) { + pageScrubStartPage = pdfState.pageIndex + } + val targetPage = desktopPdfPageScrubTarget( + value = value, + pageCount = document.pageCount, + displayMode = displayMode, + settings = pdfReaderSettings + ) + pageScrubPreview = targetPage + } + + fun finishPdfPageScrub() { + val startPage = pageScrubStartPage + val targetPage = desktopPdfPageScrubCommitTarget( + previewPage = pageScrubPreview, + currentPage = pdfState.pageIndex, + pageCount = document.pageCount + ) + pageScrubStartPage = null + pageScrubPreview = null + if (startPage != null) { + jumpHistory = jumpHistory.record( + currentPageIndex = startPage, + targetPageIndex = targetPage, + pageCount = document.pageCount + ) + } + goToPage(targetPage) + } + + fun previousPdfPageTarget(): Int { + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.previousPageIndex(pageIndex, document.pageCount, pdfReaderSettings) + } else { + pageIndex - 1 + } + } + + fun nextPdfPageTarget(): Int { + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.nextPageIndex(pageIndex, document.pageCount, pdfReaderSettings) + } else { + pageIndex + 1 + } + } + + fun goBackInJumpHistory() { + val targetPage = jumpHistory.backPage ?: return + jumpHistory = jumpHistory.stepBack() + goToPage(targetPage) + } + + fun goForwardInJumpHistory() { + val targetPage = jumpHistory.forwardPage ?: return + jumpHistory = jumpHistory.stepForward() + goToPage(targetPage) + } + + fun activatePdfLink(target: DesktopPdfLinkTarget) { + target.destPageIndex + ?.takeIf { it in 0 until document.pageCount } + ?.let { + logPdfLink("activate_internal fromPage=${pageIndex + 1} targetPage=${it + 1}") + clearPdfInteractionState() + goToPage(it, recordJump = true) + return + } + target.uri + ?.takeIf { it.isNotBlank() } + ?.let { + val url = it.normalizedExternalUrl() + logPdfLink("activate_external fromPage=${pageIndex + 1} url=\"${url.logPreview()}\"") + clearPdfInteractionState() + if (featurePolicy.externalLookup) { + externalLinkDialogUrl = url + } + return + } + logPdfLink( + "activate_ignored fromPage=${pageIndex + 1} " + + "dest=${target.destPageIndex} uri=\"${target.uri.orEmpty().logPreview()}\"" + ) + } + + fun toggleBookmark(targetPage: Int) { + val page = targetPage.coerceIn(0, (document.pageCount - 1).coerceAtLeast(0)) + dispatchPdf( + SharedPdfReaderAction.BookmarkToggled( + pageIndex = page, + label = "Page ${page + 1}", + createdAt = System.currentTimeMillis() + ) + ) + } + + fun copySelection(selection: DesktopPdfTextSelection) { + selection.text.takeIf { it.isNotBlank() }?.let { + clipboardManager.setText(AnnotatedString(it)) + } + } + + fun highlightSelection( + pageIndex: Int, + selection: DesktopPdfTextSelection, + canvasSize: IntSize, + colorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb + ) { + val now = System.currentTimeMillis() + val highlightBounds = DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = selection.startIndex, + endIndex = selection.endIndex, + viewportWidth = canvasSize.width, + viewportHeight = canvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + .ifEmpty { selection.lineBounds } + logPdfSelection( + "highlight_create page=${pageIndex + 1} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} lines=${highlightBounds.size} " + + "text=\"${selection.text.logPreview()}\"" + ) + logPdfSelection( + "highlight_store page=${pageIndex + 1} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "mode=dynamic_range" + ) + highlightBounds.forEachIndexed { index, bounds -> + logPdfSelection( + "highlight_bound page=${pageIndex + 1} index=$index " + + "left=${bounds.left.formatLogFloat()} top=${bounds.top.formatLogFloat()} " + + "right=${bounds.right.formatLogFloat()} bottom=${bounds.bottom.formatLogFloat()}" + ) + } + val annotation = SharedPdfAnnotation( + id = "highlight_${now}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + bounds = highlightBounds.firstOrNull(), + boundsList = highlightBounds, + text = selection.text, + colorArgb = SharedPdfHighlighterPalette(listOf(colorArgb)).sanitized().colors.first(), + rangeStartIndex = selection.startIndex, + rangeEndIndex = selection.endIndex, + createdAt = now + ) + pdfState = pdfState.withDesktopPdfTextSelectionHighlightAdded(annotation, zoomSpec) + clearPdfInteractionState() + } + + fun clearSelection() { + textSelection = null + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + selectionMenuOffset = null + activeSelectionHandle = null + } + + fun openPdfExternalLookup(action: ReaderExternalLookupAction, text: String) { + if (!featurePolicy.externalLookup) return + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + openExternalUrl(externalLookupUrl(action, normalizedText.take(1800))) + } + + fun currentPdfPageText(maxChars: Int = 8000): String { + return runCatching { document.textPageData(pageIndex).text.trim().take(maxChars) }.getOrDefault("") + } + + fun pdfTtsChunksForPages(pageIndices: Iterable): List { + val chunks = mutableListOf() + pageIndices.forEach { targetPage -> + if (targetPage !in 0 until document.pageCount) return@forEach + val pageText = runCatching { document.textPageData(targetPage).text }.getOrDefault("") + ReaderTtsPlanner.chunksForText( + text = pageText, + pageIndex = targetPage, + chapterIndex = 0, + chapterTitle = pdfString("pdf_page_short", "Page %1\$d", targetPage + 1) + ).forEach { chunk -> + chunks += chunk.copy(index = chunks.size) + } + } + return chunks + } + + fun pdfTtsChunksForScope(readScope: ReaderTtsReadScope, startPageIndex: Int = pageIndex): List { + return when (readScope) { + ReaderTtsReadScope.PAGE -> pdfTtsChunksForPages(listOf(startPageIndex)) + ReaderTtsReadScope.CHAPTER, + ReaderTtsReadScope.BOOK -> pdfTtsChunksForPages(startPageIndex until document.pageCount) + } + } + + fun pdfCloudTtsStoppedState(statusMessage: String? = null, errorMessage: String? = null) = ReaderCloudTtsState( + isAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, + statusMessage = statusMessage, + errorMessage = errorMessage, + cacheSummary = currentPdfTtsCacheSummary() + ) + + fun pdfHubBookKey(): String { + val path = document.path.trim() + if (path.isNotBlank()) return path + val title = document.title.trim() + return if (title.isNotBlank()) title else document.handleId.toString() + } + + fun pdfHubBookTitle(): String { + val title = document.title.trim() + if (title.isNotBlank()) return title + val fileName = document.path.substringAfterLast('\\').substringAfterLast('/').trim() + return if (fileName.isNotBlank()) fileName else "PDF" + } + + fun clearPdfHubSummary() { + pdfHubSummaryResult = null + isPdfHubSummaryLoading = false + } + + fun generatePdfHubSummary(force: Boolean) { + val pageText = currentPdfPageText(16_000) + val bookKey = pdfHubBookKey() + val pageTitle = pdfString("pdf_page_short", "Page %1\$d", pageIndex + 1) + if (pageText.isBlank()) { + pdfHubSummaryResult = SummarizationResult(error = pdfString("desktop_no_text_to_summarize", "There is no text to summarize.")) + return + } + if (!force) { + summaryCacheStore.getSummary(bookKey, pageIndex)?.let { cached -> + pdfHubSummaryResult = SummarizationResult(summary = cached, isCacheHit = true) + return + } + } + if (onReaderAiEntitlementRequired(ReaderAiFeature.SUMMARIZE, pageText)) return + isPdfHubSummaryLoading = true + pdfHubSummaryResult = null + pdfScope.launch { + var streamedSummary = "" + var streamedCost: Double? = null + var streamedFreeRemaining: Int? = null + fun updateStreamingSummary(error: String? = null) { + pdfHubSummaryResult = SummarizationResult( + summary = streamedSummary.takeIf { it.isNotBlank() }, + error = error, + cost = streamedCost, + freeRemaining = streamedFreeRemaining + ) + } + val result = aiAdapter.summarizeStreaming( + text = pageText, + onUsageReceived = { cost, freeRemaining -> + cost?.let { streamedCost = it } + freeRemaining?.let { streamedFreeRemaining = it } + updateStreamingSummary() + }, + onUpdate = { chunk -> + streamedSummary += chunk + updateStreamingSummary() + } + ) + val finalSummary = result.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } + finalSummary?.let { summary -> + summaryCacheStore.saveSummary(bookKey, pageIndex, pageTitle, summary) + } + pdfHubSummaryResult = result.copy(summary = finalSummary) + isPdfHubSummaryLoading = false + onPaidFeatureError(result.error) + } + } + + fun isPdfAiResultVisible(requestId: Long): Boolean = + pdfAiResultRequestId == requestId && dismissedPdfAiResultRequestId != requestId + + fun updatePdfAiResult(requestId: Long, aiResult: ReaderAiResultState) { + if (isPdfAiResultVisible(requestId)) { + pdfExtrasState = pdfExtrasState.copy(aiResult = aiResult) + } + } + + fun runPdfAiAction(feature: ReaderAiFeature, text: String) { + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + if (!aiByokSettings.sanitized().areReaderAiFeaturesAvailable) return + if (onReaderAiEntitlementRequired(feature, normalizedText)) return + pdfAiResultRequestId += 1 + val aiResultRequestId = pdfAiResultRequestId + dismissedPdfAiResultRequestId = null + updatePdfAiResult( + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + isLoading = true + ) + ) + pdfScope.launch { + val result = when (feature) { + ReaderAiFeature.DEFINE -> { + var streamedDefinition = "" + val definition = aiAdapter.defineStreaming( + text = normalizedText.take(2400), + context = currentPdfPageText(), + onUpdate = { chunk -> + streamedDefinition += chunk + updatePdfAiResult( + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + text = streamedDefinition, + isLoading = true + ) + ) + } + ) + (definition.definition?.takeIf { it.isNotBlank() } ?: streamedDefinition) to definition.error + } + ReaderAiFeature.SUMMARIZE -> { + var streamedSummary = "" + var streamedCost: Double? = null + var streamedFreeRemaining: Int? = null + fun updateStreamingSummary() { + val partial = SummarizationResult( + summary = streamedSummary.takeIf { it.isNotBlank() }, + cost = streamedCost, + freeRemaining = streamedFreeRemaining + ) + pdfHubSummaryResult = partial + updatePdfAiResult( + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + text = streamedSummary, + isLoading = true + ) + ) + } + val summary = aiAdapter.summarizeStreaming( + text = normalizedText, + onUsageReceived = { cost, freeRemaining -> + cost?.let { streamedCost = it } + freeRemaining?.let { streamedFreeRemaining = it } + updateStreamingSummary() + }, + onUpdate = { chunk -> + streamedSummary += chunk + updateStreamingSummary() + } + ) + val finalSummary = summary.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } + finalSummary?.let { generated -> + summaryCacheStore.saveSummary(pdfHubBookKey(), pageIndex, "Page ${pageIndex + 1}", generated) + } + pdfHubSummaryResult = summary.copy(summary = finalSummary) + finalSummary to summary.error + } + ReaderAiFeature.RECAP -> aiAdapter.recap(normalizedText).let { it.recap to it.error } + } + updatePdfAiResult( + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + text = result.first.orEmpty(), + errorMessage = result.second, + isLoading = false + ) + ) + if (isPdfAiResultVisible(aiResultRequestId)) { + onPaidFeatureError(result.second) + } + } + } + + fun stopPdfCloudTts() { + logDesktopTts("pdf_stop_requested") + pdfTtsJob?.cancel() + pdfTtsJob = null + pdfScope.launch { + ttsAdapter.stop() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = "Stopped") + ) + } + } + + fun pauseResumePdfCloudTts() { + val current = pdfExtrasState.cloudTts + if (current.isPaused) { + pdfScope.launch { + ttsAdapter.resume() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + isPaused = false, + isPlaying = true, + statusMessage = pdfExtrasState.cloudTts.progress.currentPositionLabel ?: "Reading" + ) + ) + } + } else if (current.isPlaying) { + pdfScope.launch { + ttsAdapter.pause() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + isPlaying = false, + isPaused = true, + statusMessage = "Paused" + ) + ) + } + } + } + + fun clearPdfCloudTtsCache() { + ttsAdapter.clearBookCacheForSpeaker(document.title, aiByokSettings.sanitized().ttsSpeakerId) + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + statusMessage = pdfString("desktop_voice_cache_cleared", "Voice cache cleared"), + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + + fun pdfCloudTtsUnavailableMessage(): String { + return pdfString( + "desktop_cloud_tts_signed_in_credits_required_desc", + "Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app." + ) + } + + fun pdfReadScopeLabel(readScope: ReaderTtsReadScope): String { + return when (readScope) { + ReaderTtsReadScope.PAGE -> pdfString("desktop_page", "Page") + ReaderTtsReadScope.CHAPTER -> pdfString("chapter", "Chapter") + ReaderTtsReadScope.BOOK -> pdfString("desktop_from_here", "From here") + } + } + + fun startPdfCloudTts( + readScope: ReaderTtsReadScope, + startChunkIndex: Int = 0, + chunksOverride: List? = null, + restartActive: Boolean = false + ) { + val settings = aiByokSettings.sanitized() + logDesktopTts( + "pdf_sequence_toggle scope=${readScope.name} startPage=${pageIndex + 1} " + + "isPlaying=${pdfExtrasState.cloudTts.isPlaying} isLoading=${pdfExtrasState.cloudTts.isLoading} " + + "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + + "available=${ttsAdapter.isAvailable}" + ) + val ttsActive = pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused + if (ttsActive && !restartActive) { + stopPdfCloudTts() + return + } + if (ttsActive) { + pdfTtsJob?.cancel() + pdfTtsJob = null + } + if (!ttsAdapter.isAvailable) { + logDesktopTts("pdf_sequence_blocked reason=adapter_unavailable") + onCloudTtsEntitlementRequired() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = pdfCloudTtsUnavailableMessage(), + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + val ttsSessionId = System.currentTimeMillis() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isLoading = true, + statusMessage = pdfString( + "desktop_preparing_scope_format", + "Preparing %1\$s", + pdfReadScopeLabel(readScope) + ), + progress = ReaderTtsProgress(sessionId = ttsSessionId, scope = readScope), + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + fun updatePdfTtsSession(transform: (ReaderExtrasState) -> ReaderExtrasState) { + if (pdfExtrasState.cloudTts.progress.sessionId == ttsSessionId) { + pdfExtrasState = transform(pdfExtrasState) + } + } + val noTextMessage = pdfString("desktop_no_text_here_to_read", "There is no text here to read.") + pdfTtsJob = pdfScope.launch { + var completedChunkCount = 0 + runCatching { + val ttsChunks = chunksOverride + ?.filter { it.text.isNotBlank() } + ?: withContext(Dispatchers.IO) { + pdfTtsChunksForScope(readScope, pageIndex) + .filter { it.text.isNotBlank() } + .withTtsReplacements(ttsReplacementPreferences, document.path) + } + if (ttsChunks.isEmpty()) { + logDesktopTts("pdf_sequence_ignored reason=blank_text scope=${readScope.name}") + throw IllegalStateException(noTextMessage) + } + val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) + val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) + val initialProgress = ReaderTtsProgress( + sessionId = ttsSessionId, + scope = readScope, + chunks = ttsChunks, + currentChunkIndex = boundedStartChunkIndex - 1 + ) + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = extras.cloudTts.copy( + progress = initialProgress, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + logDesktopTts( + "pdf_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + + "startChunk=${boundedStartChunkIndex + 1}" + ) + ttsAdapter.speakChunks(document.title, readScope, playbackChunks) { relativeIndex -> + if (!isActive) throw kotlinx.coroutines.CancellationException("PDF cloud TTS stopped") + val index = boundedStartChunkIndex + relativeIndex + val chunk = ttsChunks[index] + val progress = initialProgress.copy(currentChunkIndex = index) + if (chunk.pageIndex != pdfState.pageIndex) { + goToPage(chunk.pageIndex, recordJump = false) + } + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isPlaying = true, + statusMessage = progress.currentPositionLabel ?: pdfString("label_reading", "Reading"), + progress = progress, + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + } + logDesktopTts( + "pdf_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + + "page=${chunk.pageIndex + 1} offsets=${chunk.startOffset}..${chunk.endOffset} chars=${chunk.text.length}" + ) + completedChunkCount = index + 1 + } + }.onFailure { error -> + logDesktopTts("pdf_sequence_failed error=\"${error.desktopTtsSummary()}\"") + updatePdfTtsSession { extras -> + if (error is kotlinx.coroutines.CancellationException) { + extras.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_stopped", "Stopped")) + ) + } else { + onPaidFeatureError(error.message) + extras.copy( + cloudTts = pdfCloudTtsStoppedState( + errorMessage = error.message ?: pdfString("desktop_cloud_tts_failed", "Cloud TTS failed.") + ) + ) + } + } + }.onSuccess { + logDesktopTts("pdf_sequence_success chunks=$completedChunkCount") + updatePdfTtsSession { extras -> + extras.copy( + cloudTts = pdfCloudTtsStoppedState(statusMessage = pdfString("desktop_finished", "Finished")) + ) + } + } + } + } + + fun skipPdfCloudTtsChunk(delta: Int) { + val progress = pdfExtrasState.cloudTts.progress + if (progress.chunks.isEmpty()) return + val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) + if (targetIndex == currentIndex) return + startPdfCloudTts( + readScope = progress.scope, + startChunkIndex = targetIndex, + chunksOverride = progress.chunks, + restartActive = true + ) + } + + fun locatePdfCloudTtsChunk() { + val chunk = pdfExtrasState.cloudTts.progress.currentChunk ?: return + goToPage(chunk.pageIndex, recordJump = false) + } + + fun togglePdfCloudTts(text: String) { + val normalizedText = text.trim() + val settings = aiByokSettings.sanitized() + logDesktopTts( + "pdf_toggle textChars=${normalizedText.length} isPlaying=${pdfExtrasState.cloudTts.isPlaying} " + + "isLoading=${pdfExtrasState.cloudTts.isLoading} keyPresent=${settings.geminiKey.isNotBlank()} " + + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" available=${ttsAdapter.isAvailable}" + ) + if (pdfExtrasState.cloudTts.isPlaying || pdfExtrasState.cloudTts.isLoading || pdfExtrasState.cloudTts.isPaused) { + stopPdfCloudTts() + return + } + if (normalizedText.isBlank()) { + logDesktopTts("pdf_toggle_ignored reason=blank_text") + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + errorMessage = pdfString("desktop_no_text_on_page_to_read", "There is no text on this page to read."), + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + if (!ttsAdapter.isAvailable) { + logDesktopTts("pdf_toggle_blocked reason=adapter_unavailable") + onCloudTtsEntitlementRequired() + pdfExtrasState = pdfExtrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = pdfCloudTtsUnavailableMessage(), + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + val selectionChunks = ReaderTtsPlanner.chunksForText( + text = normalizedText, + pageIndex = pageIndex, + chapterIndex = 0, + chapterTitle = pdfString("pdf_page_short", "Page %1\$d", pageIndex + 1) + ).withTtsReplacements(ttsReplacementPreferences, document.path) + if (selectionChunks.isEmpty()) { + pdfExtrasState = pdfExtrasState.copy( + cloudTts = pdfExtrasState.cloudTts.copy( + errorMessage = pdfString("desktop_no_text_on_page_to_read", "There is no text on this page to read."), + cacheSummary = currentPdfTtsCacheSummary() + ) + ) + return + } + startPdfCloudTts( + readScope = ReaderTtsReadScope.PAGE, + chunksOverride = selectionChunks + ) + } + + fun updateAnnotation(annotation: SharedPdfAnnotation) { + dispatchPdf(SharedPdfReaderAction.AnnotationUpdated(annotation)) + } + + fun deleteAnnotation(annotationId: String) { + dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotationId)) + } + + fun goToAnnotation(annotation: SharedPdfAnnotation) { + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + selectedEmbeddedAnnotationId = null + goToPage(annotation.pageIndex, recordJump = true) + } + + fun selectAnnotation(annotation: SharedPdfAnnotation?) { + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(annotation?.id)) + annotation?.let { goToPage(it.pageIndex, recordJump = true) } + } + + fun goToEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation) { + dispatchPdf(SharedPdfReaderAction.AnnotationSelected(null)) + selectedEmbeddedAnnotationId = null + goToPage(annotation.pageIndex, recordJump = true) + } + + fun selectEmbeddedAnnotation(annotation: SharedPdfEmbeddedAnnotation?) { + selectedEmbeddedAnnotationId = annotation?.id + annotation?.let { goToPage(it.pageIndex, recordJump = true) } + } + + fun dismissSelectedTextHighlightSheet() { + clearPdfInteractionState() + pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) + requestPdfReaderFocusRestore() + } + + fun deleteSelectedTextHighlight(annotation: SharedPdfAnnotation) { + clearPdfInteractionState() + pdfState = pdfState.withDesktopPdfTextHighlightSheetDismissed(zoomSpec) + dispatchPdf(SharedPdfReaderAction.AnnotationDeleted(annotation.id)) + requestPdfReaderFocusRestore() + } + + fun goToSearchResult(targetIndex: Int) { + if (searchResults.isEmpty()) return + val normalizedIndex = when { + targetIndex < 0 -> searchResults.lastIndex + targetIndex > searchResults.lastIndex -> 0 + else -> targetIndex + } + val targetPage = searchResults[normalizedIndex].pageIndex + jumpHistory = jumpHistory.record( + currentPageIndex = pdfState.pageIndex, + targetPageIndex = targetPage, + pageCount = document.pageCount + ) + if (targetPage != pdfState.pageIndex) { + commitActiveTextDraft() + } + dispatchPdf(SharedPdfReaderAction.GoToSearchResult(targetIndex, searchResults)) + if (displayMode == PdfDisplayMode.PAGINATION) { + val normalizedTarget = PdfSpreadLayout.normalizePageIndex(targetPage, document.pageCount, pdfReaderSettings) + if (normalizedTarget != pdfState.pageIndex) { + dispatchPdf(SharedPdfReaderAction.GoToPage(normalizedTarget)) + } + } else if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + pdfScope.launch { + verticalListState.scrollToItem(targetPage) + } + } + } + + LaunchedEffect(documentHandleId, document.pageCount) { + jumpHistory = jumpHistory.pruned(document.pageCount) + } + + LaunchedEffect(documentHandleId, document.pageCount) { + snapshotFlow { pdfViewportSnapshot() } + .distinctUntilChanged() + .collectLatest { viewport -> + latestPdfViewport = viewport + delay(DesktopPdfViewportPersistDebounceMillis) + persistPdfViewport(viewport) + } + } + + DisposableEffect(documentHandleId) { + onDispose { + persistPdfViewport() + } + } + + var pendingInitialViewportRestore by remember(documentHandleId) { mutableStateOf(restoredInitialViewport) } + LaunchedEffect(documentHandleId, displayMode) { + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL && pageIndex in 0 until document.pageCount) { + if (pendingInitialViewportRestore?.displayMode == PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect + verticalListState.scrollToItem(pageIndex) + } + } + + LaunchedEffect( + documentHandleId, + pendingInitialViewportRestore, + displayMode, + isPdfTwoPageSpread, + renderedPageIndex, + renderedPageScale + ) { + val viewport = pendingInitialViewportRestore ?: return@LaunchedEffect + if (viewport.displayMode != displayMode) { + pendingInitialViewportRestore = null + return@LaunchedEffect + } + when (viewport.displayMode) { + PdfDisplayMode.PAGINATION -> { + if (!isPdfTwoPageSpread && renderedPageIndex != viewport.pageIndex) return@LaunchedEffect + withFrameNanos { } + pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset) + pageVerticalScrollState.scrollTo(viewport.paginatedVerticalScrollOffset) + pendingInitialViewportRestore = null + latestPdfViewport = viewport + } + + PdfDisplayMode.VERTICAL_SCROLL -> { + withFrameNanos { } + verticalListState.scrollToItem( + viewport.verticalFirstPageIndex, + viewport.verticalFirstPageScrollOffset + ) + pageHorizontalScrollState.scrollTo(viewport.horizontalScrollOffset) + pendingInitialViewportRestore = null + latestPdfViewport = viewport + } + } + } + + LaunchedEffect( + documentHandleId, + pendingPdfNavigationScrollRestore?.requestId, + pageIndex, + scale, + displayMode + ) { + val restore = pendingPdfNavigationScrollRestore ?: return@LaunchedEffect + if ( + displayMode != PdfDisplayMode.PAGINATION || + restore.pageIndex != pageIndex || + abs(restore.zoom - scale) > 0.001f + ) { + return@LaunchedEffect + } + withFrameNanos { } + pageHorizontalScrollState.scrollTo(restore.horizontalScroll) + pageVerticalScrollState.scrollTo(restore.verticalScroll) + withFrameNanos { } + pageHorizontalScrollState.scrollTo(restore.horizontalScroll) + pageVerticalScrollState.scrollTo(restore.verticalScroll) + logPdfZoomSettle { + "preview_navigation_restore request=${restore.requestId} page=${pageIndex + 1} " + + "zoom=${scale.formatLogFloat()} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value}" + } + if (pendingPdfNavigationScrollRestore == restore) { + pendingPdfNavigationScrollRestore = null + } + } + + fun selectPdfPanMode() { + SharedPdfRichTextLog.d( + "desktop.tool.select tool=${PdfInkTool.NONE} richMode=$isRichTextMode page=${pdfState.pageIndex}" + ) + deactivateRichTextMode() + commitActiveTextDraft() + clearPdfInteractionState() + if (pdfState.isTextSelectionMode) { + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(false)) + } + dispatchPdf(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE)) + } + + fun togglePdfTextSelectionMode() { + val enabled = !isTextSelectionMode + if (enabled) { + deactivateRichTextMode() + commitActiveTextDraft() + } + dispatchPdf(SharedPdfReaderAction.TextSelectionModeChanged(enabled)) + if (!enabled) { + clearPdfInteractionState() + } + } + + @Composable + fun DesktopPdfBottomMarkupDock(modifier: Modifier = Modifier) { + SharedPdfInteractionDock( + isTextSelectionMode = isTextSelectionMode, + selectedTool = selectedTool, + selectedColor = selectedColor, + strokeWidth = strokeWidth, + toolConfigs = pdfState.toolConfigs, + penPalette = pdfState.penPalette, + highlighterPalette = pdfHighlighterColors, + lastActivePenTool = pdfState.lastActivePenTool, + lastActiveHighlighterTool = pdfState.lastActiveHighlighterTool, + onPanSelected = ::selectPdfPanMode, + onTextSelectionSelected = ::togglePdfTextSelectionMode, + onToolSelected = ::selectPdfAnnotationTool, + onColorSelected = { dispatchPdf(SharedPdfReaderAction.ColorSelected(it)) }, + onStrokeWidthChange = { dispatchPdf(SharedPdfReaderAction.StrokeWidthChanged(it)) }, + onUndo = { dispatchPdf(SharedPdfReaderAction.UndoAnnotationEdit) }, + onRedo = { dispatchPdf(SharedPdfReaderAction.RedoAnnotationEdit) }, + onClearPage = { dispatchPdf(SharedPdfReaderAction.ClearPageAnnotations(pageIndex)) }, + modifier = modifier, + allowExpandedSettings = !isPdfSearchActive && + activeTextDraft == null && + !isRichTextMode && + textSelection == null && + selectionMenuOffset == null && + !pdfSelectionSheetActive && + externalLinkDialogUrl == null && + !pdfExtrasState.aiResult.hasContent, + canUndo = pdfState.canUndoAnnotationEdit, + canRedo = pdfState.canRedoAnnotationEdit, + canClearPage = annotations.any { it.pageIndex == pageIndex }, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + onHighlighterSnapChange = { isHighlighterSnapEnabled = it }, + onHighlighterPaletteChange = { colors -> + onPdfHighlighterPaletteChange(SharedPdfHighlighterPalette(colors).sanitized()) + }, + onPenPaletteChange = { colors -> dispatchPdf(SharedPdfReaderAction.PenPaletteChanged(colors)) } + ) + } + + LaunchedEffect(documentHandleId, displayMode, verticalListState) { + if (displayMode != PdfDisplayMode.VERTICAL_SCROLL) return@LaunchedEffect + snapshotFlow { + val layoutInfo = verticalListState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) { + verticalListState.firstVisibleItemIndex + } else { + mostVisiblePdfPageIndex( + visiblePages = visibleItems.map { item -> + PdfVisiblePageLayout( + pageIndex = item.index, + top = item.offset.toFloat(), + bottom = (item.offset + item.size).toFloat() + ) + }, + viewportTop = layoutInfo.viewportStartOffset.toFloat(), + viewportBottom = layoutInfo.viewportEndOffset.toFloat(), + fallbackPageIndex = verticalListState.firstVisibleItemIndex + ) + } + } + .distinctUntilChanged() + .collect { visiblePage -> + if (visiblePage in 0 until document.pageCount && visiblePage != currentPdfPageIndex) { + goToPage(visiblePage, scrollVertical = false, commitPendingZoomPreview = false) + } + } + } + + LaunchedEffect(documentHandleId, pageIndex, scale, displayMode, isPdfTwoPageSpread) { + renderJob?.cancel() + if (displayMode != PdfDisplayMode.PAGINATION || isPdfTwoPageSpread) { + isRendering = false + renderError = null + renderedPage = null + renderedPageIndex = null + renderedPageScale = null + return@LaunchedEffect + } + logPdfZoomPerf { + "render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} " + + "existingPage=${renderedPageIndex?.let { it + 1 } ?: "none"} " + + "existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "searchIndexing=$isSearchIndexing indexed=$indexedSearchPageCount/${document.pageCount} " + + "cacheKeys=${paginatedRenderCache.keys.sorted().map { it + 1 }}" + } + logPdfZoomSettle { + "render_effect seq=$pdfZoomSettleSequence page=${pageIndex + 1} scale=${scale.formatLogFloat()} " + + "existingPage=${renderedPageIndex?.plus(1) ?: "none"} " + + "existingScale=${renderedPageScale?.formatLogFloat() ?: "none"} " + + "preview=${pdfZoomPreview != null} h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" + } + if (renderedPageIndex != pageIndex) { + paginatedRenderCache[pageIndex]?.let { cached -> + logPdfZoomPerf { + "cache_hit page=${pageIndex + 1} scale=${cached.scale.formatLogFloat()} " + + "bitmap=${cached.render.width}x${cached.render.height}" + } + logPdfZoomSettle { + "cache_hit seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "scale=${cached.scale.formatLogFloat()} bitmap=${cached.render.width}x${cached.render.height}" + } + renderedPage = cached.render + renderedPageIndex = pageIndex + renderedPageScale = cached.scale + renderError = null + isRendering = false + } + } + val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) + if (!hasPageRender) { + logPdfZoomPerf { + "cache_miss page=${pageIndex + 1}; stale=${renderedPageIndex?.let { it + 1 } ?: "none"}" + } + logPdfZoomSettle { + "cache_miss seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "stale=${renderedPageIndex?.plus(1) ?: "none"}" + } + renderedPage = null + renderedPageIndex = null + renderedPageScale = null + isRendering = true + } + renderJob = launch { + val pageSize = document.pageSizes.getOrNull(pageIndex) + if (pageSize == null) { + renderedPage = null + renderedPageIndex = null + renderedPageScale = null + renderError = pdfString("desktop_failed_render_page", "Failed to render page.") + isRendering = false + return@launch + } + val safeScale = zoomSpec.safeRenderScale( + pageSize.width, + pageSize.height, scale + ) + val isOpeningRender = paginatedRenderCache.isEmpty() && !hasPageRender + val firstRenderScale = desktopPdfPaginationFirstRenderScale( + requestedScale = safeScale, + hasPageRender = hasPageRender, + isOpeningRender = isOpeningRender + ) + logPdfZoomPerf { + "render_plan page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " + + "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + + "hasRender=$hasPageRender opening=$isOpeningRender" + } + logPdfZoomSettle { + "render_plan seq=$pdfZoomSettleSequence page=${pageIndex + 1} requestedScale=${scale.formatLogFloat()} " + + "safeScale=${safeScale.formatLogFloat()} firstScale=${firstRenderScale.formatLogFloat()} " + + "hasRender=$hasPageRender opening=$isOpeningRender existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}" + } + + suspend fun renderAt(renderScale: Float, delayMillis: Long, showSpinner: Boolean): Boolean { + logPdfZoomPerf { + "render_scheduled page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + + "requestedScale=${scale.formatLogFloat()} delayMs=$delayMillis showSpinner=$showSpinner " + + "hasPageRender=$hasPageRender" + } + logPdfZoomSettle { + "render_scheduled seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + + "delayMs=$delayMillis showSpinner=$showSpinner preview=${pdfZoomPreview != null}" + } + delay(delayMillis) + if (showSpinner) { + isRendering = true + } + renderError = null + val startedAt = System.currentTimeMillis() + val result = withContext(Dispatchers.IO) { + runCatching { + DesktopPdfium.renderPage(document, pageIndex, renderScale) + } + } + val elapsedMs = System.currentTimeMillis() - startedAt + if (currentPdfPageIndex != pageIndex || currentPdfScale != scale || + currentPdfDisplayMode != PdfDisplayMode.PAGINATION + ) { + logPdfZoomPerf { + "render_stale page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + + "elapsedMs=$elapsedMs currentPage=${currentPdfPageIndex + 1} " + + "currentScale=${currentPdfScale.formatLogFloat()} mode=$currentPdfDisplayMode" + } + logPdfZoomSettle { + "render_stale seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} elapsedMs=$elapsedMs " + + "currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " + + "mode=$currentPdfDisplayMode" + } + return false + } + result.getOrNull()?.let { render -> + cachePaginatedRender(pageIndex, renderScale, render) + renderedPage = render + renderedPageIndex = pageIndex + renderedPageScale = renderScale + } + renderError = result.exceptionOrNull()?.message + ?: if (renderedPage == null || renderedPageIndex != pageIndex) { + pdfString("desktop_failed_render_page", "Failed to render page.") + } else { + null + } + logPdfZoomPerf { + "render_end page=${pageIndex + 1} renderScale=${renderScale.formatLogFloat()} " + + "requestedScale=${scale.formatLogFloat()} elapsedMs=$elapsedMs success=${result.isSuccess} " + + "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" + } + logPdfZoomSettle { + "render_end seq=$pdfZoomSettleSequence page=${pageIndex + 1} " + + "renderScale=${renderScale.formatLogFloat()} requestedScale=${scale.formatLogFloat()} " + + "elapsedMs=$elapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " + + "h=${pageHorizontalScrollState.value} v=${pageVerticalScrollState.value} " + + "pageRoot=${paginatedPageRootOffset.formatLogOffset()} canvas=${pageCanvasSize.formatLogSize()}" + } + renderedPage?.let { render -> + logPdfSelection( + "render page=${pageIndex + 1} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${renderScale.formatLogFloat()} " + + "safeScale=${safeScale.formatLogFloat()} " + + "pageSize=${pageSize.width.formatLogFloat()}x${pageSize.height.formatLogFloat()} " + + "bitmap=${render.width}x${render.height} capped=${safeScale < zoomSpec.clamp( + scale + )}" + ) + } + isRendering = false + return result.isSuccess && renderedPageIndex == pageIndex + } + + suspend fun prefetchPage(pageToPrefetch: Int) { + if (pageToPrefetch !in 0 until document.pageCount) return + val cached = paginatedRenderCache[pageToPrefetch] + if ( + cached != null && + cached.scale >= DesktopPdfPaginationFastFirstRenderMaxScale - DesktopPdfRenderScaleTolerance + ) { + logPdfZoomPerf { + "prefetch_skip_cached page=${pageToPrefetch + 1} scale=${cached.scale.formatLogFloat()}" + } + return + } + val prefetchPageSize = document.pageSizes.getOrNull(pageToPrefetch) ?: return + val prefetchScale = zoomSpec.safeRenderScale( + prefetchPageSize.width, + prefetchPageSize.height, + DesktopPdfPaginationFastFirstRenderMaxScale + ) + logPdfZoomPerf { + "prefetch_start page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " + + "current=${pageIndex + 1}" + } + val startedAt = System.currentTimeMillis() + val result = withContext(Dispatchers.IO) { + runCatching { + DesktopPdfium.renderPage(document, pageToPrefetch, prefetchScale) + } + } + val elapsedMs = System.currentTimeMillis() - startedAt + if (currentPdfPageIndex != pageIndex || currentPdfScale != scale || + currentPdfDisplayMode != PdfDisplayMode.PAGINATION || + pdfZoomPreview != null + ) { + logPdfZoomPerf { + "prefetch_stale page=${pageToPrefetch + 1} elapsedMs=$elapsedMs " + + "currentPage=${currentPdfPageIndex + 1} currentScale=${currentPdfScale.formatLogFloat()} " + + "mode=$currentPdfDisplayMode preview=${pdfZoomPreview != null}" + } + return + } + result.getOrNull()?.let { render -> + cachePaginatedRender(pageToPrefetch, prefetchScale, render) + } + logPdfZoomPerf { + "prefetch_end page=${pageToPrefetch + 1} scale=${prefetchScale.formatLogFloat()} " + + "elapsedMs=$elapsedMs success=${result.isSuccess} " + + "error=${result.exceptionOrNull()?.message?.logPreview() ?: "none"}" + } + } + + val existingScale = renderedPageScale + val needsFirstRender = !hasPageRender || + desktopPdfRenderScaleNeedsUpgrade(existingScale, firstRenderScale) + if (needsFirstRender) { + renderAt( + renderScale = firstRenderScale, + delayMillis = if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L, + showSpinner = !hasPageRender + ) + } else { + logPdfZoomSettle { + "render_skip seq=$pdfZoomSettleSequence page=${pageIndex + 1} reason=no_scale_upgrade " + + "existingScale=${existingScale?.formatLogFloat() ?: "none"} firstScale=${firstRenderScale.formatLogFloat()}" + } + } + delay(DesktopPdfPaginationPrefetchDelayMillis) + if (currentPdfPageIndex == pageIndex && currentPdfScale == scale && + currentPdfDisplayMode == PdfDisplayMode.PAGINATION && + pdfZoomPreview == null + ) { + prefetchPage(pageIndex + 1) + prefetchPage(pageIndex - 1) + } + } + } + + val pdfWorkspaceModel = pdfReaderWorkspaceModel( + state = pdfState, + displayMode = displayMode, + hasContents = document.toc.isNotEmpty(), + hasBookmarks = bookmarks.isNotEmpty(), + hasAnnotations = sortedSidebarHighlights.isNotEmpty(), + hasEmbeddedComments = sortedEmbeddedAnnotations.isNotEmpty(), + searchActive = isPdfSearchActive || searchQuery.isNotBlank(), + annotationEditing = activeTextDraft != null || + selectedAnnotation != null || + selectedTool != PdfInkTool.NONE, + richTextEditing = isRichTextMode, + loading = isRendering || isSearchIndexing || isPdfFileActionLoading || isReflowingThisBook, + errorMessage = renderError, + extrasState = pdfExtrasState, + aiAvailable = featurePolicy.aiAndCloud && aiByokSettings.sanitized().areReaderAiFeaturesAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, + externalLookupAvailable = featurePolicy.externalLookup + ) + + fun runPdfKeyCommand(command: DesktopPdfKeyCommand): Boolean { + fun scrollVertically(delta: Float): Boolean { + pdfScope.launch { + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + verticalListState.scrollBy(delta) + } else { + pageVerticalScrollState.scrollBy(delta) + } + } + return true + } + + return when (command) { + DesktopPdfKeyCommand.EXIT_FULLSCREEN -> { + setPdfFullscreen(false) + true + } + DesktopPdfKeyCommand.PREVIOUS_PAGE -> { + goToPage(previousPdfPageTarget()) + true + } + DesktopPdfKeyCommand.NEXT_PAGE -> { + goToPage(nextPdfPageTarget()) + true + } + DesktopPdfKeyCommand.SCROLL_UP -> scrollVertically(-96f) + DesktopPdfKeyCommand.SCROLL_DOWN -> scrollVertically(96f) + DesktopPdfKeyCommand.FIRST_PAGE -> { + goToPage(0) + true + } + DesktopPdfKeyCommand.LAST_PAGE -> { + goToPage(document.pageCount - 1) + true + } + DesktopPdfKeyCommand.SEARCH -> { + dispatchPdf(SharedPdfReaderAction.SearchOpened) + true + } + DesktopPdfKeyCommand.ZOOM_IN -> { + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomBy(0.15f)) + true + } + DesktopPdfKeyCommand.ZOOM_OUT -> { + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomBy(-0.15f)) + true + } + } + } + + fun isPdfTextEditingActive(): Boolean { + return activeTextDraft != null || + (selectedTool == PdfInkTool.TEXT && selectedAnnotation?.kind == PdfAnnotationKind.TEXT) || + isRichTextMode + } + + fun handlePdfReaderKeyEvent(event: androidx.compose.ui.input.key.KeyEvent): Boolean { + val command = event.desktopPdfKeyCommandOrNull( + fullscreen = isFullscreen, + editingText = isPdfTextEditingActive(), + rightToLeftPagination = rightToLeftPdfPaginationActive + ) ?: return false + return runPdfKeyCommand(command) + } + + fun handlePdfReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { + val command = event.desktopPdfKeyCommandOrNull( + fullscreen = isFullscreen, + editingText = isPdfTextEditingActive(), + rightToLeftPagination = rightToLeftPdfPaginationActive + ) ?: return false + return runPdfKeyCommand(command) + } + + fun handlePdfReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (isPdfSearchActive) { + if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { + return runPdfKeyCommand(DesktopPdfKeyCommand.EXIT_FULLSCREEN) + } + return false + } + return handlePdfReaderAwtKeyEvent(event) + } + + fun handlePdfReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false + return when (event.keyCode) { + AwtKeyEvent.VK_F -> runPdfKeyCommand(DesktopPdfKeyCommand.SEARCH) + else -> false + } + } + + DesktopReaderKeyDispatcherEffect( + enabled = !pdfPopupActive, + allowChromeModalWindows = true, + onKeyPressed = { event -> handlePdfReaderGlobalShortcutAwtKeyEvent(event) } + ) + + DesktopReaderKeyDispatcherEffect( + enabled = !pdfPopupActive && !isPdfSearchActive, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false, + onKeyPressed = { event -> handlePdfReaderAwtKeyEvent(event) } + ) + + DesktopReaderFullscreenKeyEffect( + enabled = isFullscreen && !pdfPopupActive, + onKeyPressed = { event -> handlePdfReaderFullscreenAwtKeyEvent(event) } + ) + + ReaderWorkspaceShell( + model = pdfWorkspaceModel, + title = document.title, + subtitle = pdfString("desktop_label_pair_format", "%1\$s - %2\$s", document.formatLabel, pdfPageLabel), + progressLabel = "${progressPercent.toInt()}%", + onReturnToLibrary = onReturnToLibrary?.let { returnToLibrary -> + { + persistPdfViewport() + returnToLibrary() + } + }, + isFullscreen = isFullscreen, + onFullscreenChange = ::setPdfFullscreen, + isBookmarked = bookmarks.any { it.pageIndex == pageIndex }, + onToggleBookmark = { toggleBookmark(pageIndex) }, + onSearchAction = { dispatchPdf(SharedPdfReaderAction.SearchOpened) }, + onReadAloudAction = if (cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable) { + { startPdfCloudTts(ReaderTtsReadScope.BOOK) } + } else { + null + }, + onAiHubAction = if (aiByokSettings.sanitized().areReaderAiFeaturesAvailable) { + { showPdfAiHub = true } + } else { + null + }, + fileActions = pdfFileActions, + onSaveCopyAction = requestSaveCopy, + onPrintAction = requestPrint, + onTextViewAction = onReflowAction?.let { action -> { action(pdfState.pageIndex) } }, + topSearchBar = if (isPdfSearchActive) { + { + DesktopPdfSearchTopBar( + query = searchQuery, + showResultsPanel = showPdfSearchResultsPanel, + onQueryChange = { dispatchPdf(SharedPdfReaderAction.SearchChanged(it)) }, + onClose = { dispatchPdf(SharedPdfReaderAction.SearchClosed) }, + onToggleResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) } + ) + } + } else { + null + }, + modifier = Modifier + .focusRequester(pdfReaderFocusRequester) + .onPreviewKeyEvent(::handlePdfReaderKeyEvent) + .focusable(), + closeRightPanelOnReaderTap = true, + onReaderFocusRestoreRequest = ::requestPdfReaderFocusRestore, + leftSidebar = { _ -> + DesktopPdfNavigationSidebar( + document = document, + pageIndex = pageIndex, + sortedHighlights = sortedSidebarHighlights, + bookmarks = bookmarks, + onPageSelected = { page -> goToPage(page, recordJump = true) }, + onAnnotationOpened = ::goToAnnotation, + onAnnotationSelected = ::selectAnnotation, + onAnnotationDeleted = { annotation -> deleteAnnotation(annotation.id) } + ) + }, + rightInspector = { + DesktopPdfInspectorPanel( + document = document, + displayMode = displayMode, + pdfReaderSettings = pdfReaderSettings, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, + customTextureIds = customTextureIds, + onImportTexture = onImportTexture, + onReaderSettingsChange = ::updatePdfReaderSettings, + selectedTool = selectedTool, + isRichTextMode = isRichTextMode, + pdfHighlighterPalette = pdfHighlighterPalette, + effectiveTextStyleConfig = effectiveTextStyleConfig, + richTextController = richTextController, + pdfExtrasState = pdfExtrasState, + aiByokSettings = aiByokSettings, + cloudTtsFeatureAvailable = cloudTtsControlsAvailable, + ttsReplacementPreferences = ttsReplacementPreferences, + onDisplayModeSelected = { mode -> + commitActiveTextDraft() + updatePdfReaderSettings( + pdfReaderSettings.copy(readingMode = mode.toDesktopReaderReadingMode()) + ) + dispatchPdf(SharedPdfReaderAction.DisplayModeChanged(mode)) + }, + onRichTextModeToggle = { + if (isRichTextMode) { + deactivateRichTextMode() + } else { + activateRichTextMode() + } + }, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, + onTextStyleChange = ::updateTextStyleConfig, + onCloudTtsClearCache = ::clearPdfCloudTtsCache, + onCloudTtsVoiceChange = { voiceId -> + onAiByokSettingsChange(aiByokSettings.sanitized().copy(ttsSpeakerId = voiceId)) + }, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange + ) + }, + bottomBar = { + DesktopPdfBottomChrome( + pageIndex = pageIndex, + pageCount = document.pageCount, + pageLabel = pdfPageLabel, + progressPercent = progressPercent, + canGoPrevious = canGoPrevious, + canGoNext = canGoNext, + showJumpHistory = !isPdfSearchActive, + jumpBackPage = jumpHistory.backPage, + jumpForwardPage = jumpHistory.forwardPage, + onPrevious = { goToPage(previousPdfPageTarget()) }, + onNext = { goToPage(nextPdfPageTarget()) }, + onPageScrub = ::updatePdfPageScrub, + onPageScrubFinished = ::finishPdfPageScrub, + onJumpBack = ::goBackInJumpHistory, + onJumpForward = ::goForwardInJumpHistory, + onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, + extraContent = { + if (cloudTtsControlsAvailable) { + val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( + settings = aiByokSettings, + cloudTts = pdfExtrasState.cloudTts, + credits = credits, + showCredits = showPaidCredits, + isCollapsed = isPdfTtsOverlayCollapsed, + onCollapseChange = { isPdfTtsOverlayCollapsed = it }, + onPauseResume = ::pauseResumePdfCloudTts, + onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, + onSkipNext = { skipPdfCloudTtsChunk(1) }, + onLocateCurrentChunk = ::locatePdfCloudTtsChunk, + onClose = ::stopPdfCloudTts, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) + ) + } + } + DesktopPdfBottomMarkupDock( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) + ) + } + ) + }, + fullscreenBottomBar = { + DesktopPdfFullscreenBottomChrome( + pageIndex = pageIndex, + pageCount = document.pageCount, + pageLabel = pdfPageLabel, + canGoPrevious = canGoPrevious, + canGoNext = canGoNext, + showJumpHistory = !isPdfSearchActive, + jumpBackPage = jumpHistory.backPage, + jumpForwardPage = jumpHistory.forwardPage, + onPrevious = { goToPage(previousPdfPageTarget()) }, + onNext = { goToPage(nextPdfPageTarget()) }, + onPageScrub = ::updatePdfPageScrub, + onPageScrubFinished = ::finishPdfPageScrub, + onJumpBack = ::goBackInJumpHistory, + onJumpForward = ::goForwardInJumpHistory, + onClearJumpHistory = { jumpHistory = jumpHistory.clear() }, + extraContent = { + if (cloudTtsControlsAvailable) { + val ttsControls = readerCloudTtsControlsModel(pdfExtrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( + settings = aiByokSettings, + cloudTts = pdfExtrasState.cloudTts, + credits = credits, + showCredits = showPaidCredits, + isCollapsed = isPdfTtsOverlayCollapsed, + onCollapseChange = { isPdfTtsOverlayCollapsed = it }, + onPauseResume = ::pauseResumePdfCloudTts, + onSkipPrevious = { skipPdfCloudTtsChunk(-1) }, + onSkipNext = { skipPdfCloudTtsChunk(1) }, + onLocateCurrentChunk = ::locatePdfCloudTtsChunk, + onClose = ::stopPdfCloudTts, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) + ) + } + } + DesktopPdfBottomMarkupDock( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) + ) + } + ) + } + ) { _ -> + SharedPdfRichTextHiddenInput( + controller = richTextController, + enabled = isRichTextMode, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, bottom = 24.dp) + .zIndex(10f) + ) + DesktopPdfSearchOverlay( + isSearchActive = isPdfSearchActive, + showResultsPanel = showPdfSearchResultsPanel, + query = searchQuery, + results = searchResults, + activeSearchIndex = activeSearchIndex, + highlightMode = searchHighlightMode, + isIndexing = isSearchIndexing, + indexedPageCount = indexedSearchPageCount, + pageCount = document.pageCount, + onResultClick = { index -> + goToSearchResult(index) + dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) + }, + onShowResults = { dispatchPdf(SharedPdfReaderAction.SearchResultsPanelToggled) }, + onPrevious = { goToSearchResult(activeSearchIndex - 1) }, + onNext = { goToSearchResult(activeSearchIndex + 1) }, + onToggleHighlightMode = { dispatchPdf(SharedPdfReaderAction.SearchHighlightModeToggled) } + ) + val pdfViewportBackground = desktopPdfViewportBackgroundColor( + displayMode = displayMode, + pageBackgroundColor = pdfThemeStyle.pageBackgroundColor, + appBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, + isVerticalPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible + ) + if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) { + val verticalPageGap = pdfVerticalPageGapDp( + isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible, + defaultGap = DesktopDefaultPdfVerticalPageGap + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=vertical " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset + } + .desktopPdfZoomGestures( + currentZoom = scale, + zoomSpec = zoomSpec, + onZoomChanged = ::previewAnchoredPdfZoom + ) + ) { + LazyColumn( + state = verticalListState, + modifier = Modifier + .fillMaxSize() + .horizontalScroll(pageHorizontalScrollState) + .padding(horizontal = 24.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(verticalPageGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + items((0 until document.pageCount).toList(), key = { it }) { verticalPageIndex -> + val verticalZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.VERTICAL_SCROLL + } + DesktopVerticalPdfPage( + document = document, + pageIndex = verticalPageIndex, + scale = scale, + zoomSpec = zoomSpec, + annotations = annotations, + searchResults = searchResults, + activeSearchIndex = activeSearchIndex, + searchHighlightMode = searchHighlightMode, + activeTtsChunk = activePdfTtsChunk, + searchQuery = searchQuery, + isTextSelectionMode = isTextSelectionMode, + selectedAnnotationId = selectedAnnotationId, + selectedEmbeddedAnnotationId = selectedEmbeddedAnnotationId, + selectedTool = selectedTool, + selectedColor = selectedColor, + highlighterPalette = pdfHighlighterColors, + strokeWidth = strokeWidth, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + activeTextDraft = activeTextDraft, + richTextController = richTextController, + isRichTextMode = isRichTextMode, + readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, + externalLookupAvailable = featurePolicy.externalLookup, + themeStyle = pdfThemeStyle, + shouldRender = verticalPageIndex in verticalRenderWindow, + zoomPreview = verticalZoomPreview, + zoomPreviewAnchorPageRootOffset = verticalZoomPreview + ?.pageIndex + ?.let { verticalPageRootOffsets[it] }, + zoomPreviewScrollBounds = verticalZoomPreview?.let { + desktopPdfZoomScrollBoundsWithCommitTargets( + preview = it, + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageHorizontalScrollState.maxValue + ) + }, + zoomViewportRootOffset = pdfZoomViewportRootOffset, + showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, + onSelectPage = { + goToPage( + target = it, + scrollVertical = false, + saveRichTextBeforePageChange = !isRichTextMode + ) + }, + onCopySelection = ::copySelection, + onHighlightSelection = ::highlightSelection, + onExternalSearchSelection = { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) }, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, + onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }, + onSpeakSelection = { togglePdfCloudTts(it.text) }, + onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation, + onAnnotationSelected = ::selectAnnotation, + onLinkActivated = ::activatePdfLink, + onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) }, + onAnnotationUpdated = ::updateAnnotation, + onAnnotationsChanged = { dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(it)) }, + onTextAnnotationSelected = ::selectTextAnnotation, + onTextDraftStarted = ::startActiveTextDraft, + onTextDraftChanged = ::updateActiveTextDraft, + onTextDraftBoundsChanged = ::updateActiveTextDraftBounds, + onPan = { delta -> + pdfScope.launch { + pageHorizontalScrollState.scrollBy(-delta.x) + verticalListState.scrollBy(-delta.y) + } + }, + onPagePositioned = { page, offset -> + val previousOffset = verticalPageRootOffsets[page] + if (previousOffset != offset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=vertical page=${page + 1} " + + "prevRoot=${previousOffset.formatLogOffset()} nextRoot=${offset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${verticalZoomPreview != null} " + + "h=${pageHorizontalScrollState.value} list=${verticalListState.firstVisibleItemIndex}:" + + verticalListState.firstVisibleItemScrollOffset + } + } + verticalPageRootOffsets[page] = offset + } + ) + } + } + SharedPdfVerticalScrollbar( + listState = verticalListState, + pageCount = document.pageCount, + currentPage = pageIndex, + isDarkMode = pdfViewportBackground.luminance() < 0.5f, + modifier = Modifier.align(Alignment.CenterEnd) + ) + DesktopPdfPageScrubOverlay( + pageIndex = pageScrubPreview, + pageCount = document.pageCount, + pageLabel = pdfPageScrubPreviewLabel + ) + } + } else { + if (isPdfTwoPageSpread) { + Box( + modifier = Modifier + .fillMaxSize() + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=spread " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset + } + .desktopPdfZoomGestures( + currentZoom = scale, + zoomSpec = zoomSpec, + onZoomChanged = ::previewAnchoredPdfZoom + ) + .horizontalScroll(pageHorizontalScrollState) + .verticalScroll(pageVerticalScrollState) + .padding(24.dp), + contentAlignment = Alignment.TopCenter + ) { + val spreadPageGap = desktopPdfSpreadPageGapDp( + isPageGapVisible = pdfReaderSettings.pdfVerticalPageGapVisible + ) + Row( + horizontalArrangement = Arrangement.spacedBy(spreadPageGap, Alignment.CenterHorizontally), + verticalAlignment = Alignment.Top + ) { + val spreadZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.PAGINATION + } + val spreadPredictedPageCanvasSizes = paginatedVisiblePageIndices.mapNotNull { visiblePageIndex -> + document.pageSizes.getOrNull(visiblePageIndex)?.let { pageSize -> + val pageDisplayScale = zoomSpec.clamp(scale) + visiblePageIndex to IntSize( + width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) + ) + } + }.toMap() + val spreadLayoutPrediction = spreadZoomPreview?.let { + desktopPdfSpreadLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + visiblePageIndices = paginatedVisiblePageIndices, + pageCanvasSizes = spreadPredictedPageCanvasSizes, + horizontalScroll = pageHorizontalScrollState.value, + verticalScroll = pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() }, + pageGapPx = with(density) { spreadPageGap.toPx() } + ) + } + val spreadZoomAnchorPageRootOffset = spreadZoomPreview + ?.pageIndex + ?.let { spreadLayoutPrediction?.pageRootOffsets?.get(it) ?: paginatedZoomPageRoot(it) } + val spreadZoomScrollBounds = spreadZoomPreview?.let { + DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = spreadLayoutPrediction?.maxHorizontalScroll + ?: pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = spreadLayoutPrediction?.maxVerticalScroll + ?: pageVerticalScrollState.maxValue + ) + } + paginatedVisiblePageIndices.forEach { spreadPageIndex -> + DesktopVerticalPdfPage( + document = document, + pageIndex = spreadPageIndex, + scale = scale, + zoomSpec = zoomSpec, + annotations = annotations, + searchResults = searchResults, + activeSearchIndex = activeSearchIndex, + searchHighlightMode = searchHighlightMode, + activeTtsChunk = activePdfTtsChunk, + searchQuery = searchQuery, + isTextSelectionMode = isTextSelectionMode, + selectedAnnotationId = selectedAnnotationId, + selectedEmbeddedAnnotationId = selectedEmbeddedAnnotationId, + selectedTool = selectedTool, + selectedColor = selectedColor, + highlighterPalette = pdfHighlighterColors, + strokeWidth = strokeWidth, + isHighlighterSnapEnabled = isHighlighterSnapEnabled, + activeTextDraft = activeTextDraft, + richTextController = richTextController, + isRichTextMode = isRichTextMode, + readerAiFeaturesAvailable = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, + cloudTtsAvailable = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, + externalLookupAvailable = featurePolicy.externalLookup, + themeStyle = pdfThemeStyle, + shouldRender = true, + zoomPreview = spreadZoomPreview, + zoomPreviewAnchorPageRootOffset = spreadZoomAnchorPageRootOffset, + zoomPreviewScrollBounds = spreadZoomScrollBounds, + zoomViewportRootOffset = pdfZoomViewportRootOffset, + showPageNumberOverlay = pdfReaderSettings.pdfPageNumberOverlayVisible, + onSelectPage = { + goToPage( + target = it, + saveRichTextBeforePageChange = !isRichTextMode + ) + }, + onCopySelection = ::copySelection, + onHighlightSelection = ::highlightSelection, + onExternalSearchSelection = { + openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) + }, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, + onDefineSelection = { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) }, + onSpeakSelection = { togglePdfCloudTts(it.text) }, + onEmbeddedAnnotationSelected = ::selectEmbeddedAnnotation, + onAnnotationSelected = ::selectAnnotation, + onLinkActivated = ::activatePdfLink, + onAnnotationAdded = { dispatchPdf(SharedPdfReaderAction.AnnotationAdded(it)) }, + onAnnotationUpdated = ::updateAnnotation, + onAnnotationsChanged = { dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(it)) }, + onTextAnnotationSelected = ::selectTextAnnotation, + onTextDraftStarted = ::startActiveTextDraft, + onTextDraftChanged = ::updateActiveTextDraft, + onTextDraftBoundsChanged = ::updateActiveTextDraftBounds, + onPan = { delta -> + pdfScope.launch { + pageHorizontalScrollState.scrollBy(-delta.x) + pageVerticalScrollState.scrollBy(-delta.y) + } + }, + onPageSizeChanged = { page, size -> + paginatedPageCanvasSizes[page] = size + }, + onPagePositioned = { page, offset -> + paginatedPageRootOffsets[page] = offset + if (page == paginatedSpreadPageIndices.firstOrNull()) { + if (offset != paginatedPageRootOffset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=spread page=${page + 1} " + + "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + + "nextRoot=${offset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${spreadZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value}" + } + } + paginatedPageRootOffset = offset + } + } + ) + } + } + DesktopPdfPageScrubOverlay( + pageIndex = pageScrubPreview, + pageCount = document.pageCount, + pageLabel = pdfPageScrubPreviewLabel + ) + } + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(pdfViewportBackground, RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .onSizeChanged { size -> pdfZoomViewportSize = size } + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + if (rootOffset != pdfZoomViewportRootOffset) { + logPdfZoomSettle { + "viewport_layout seq=$pdfZoomSettleSequence mode=pagination " + + "prev=${pdfZoomViewportRootOffset.formatLogOffset()} next=${rootOffset.formatLogOffset()} " + + "scale=${scale.formatLogFloat()} preview=${pdfZoomPreview != null}" + } + } + pdfZoomViewportRootOffset = rootOffset + } + .desktopPdfZoomGestures( + currentZoom = scale, + zoomSpec = zoomSpec, + onZoomChanged = ::previewAnchoredPdfZoom + ) + .horizontalScroll(pageHorizontalScrollState) + .verticalScroll(pageVerticalScrollState) + .padding(24.dp), + contentAlignment = Alignment.TopCenter + ) { + val paginatedPageDisplay = renderedPageIndex + ?.takeIf { displayPageIndex -> + renderedPage != null && desktopPdfRenderBelongsToPage(displayPageIndex, pageIndex) + } + ?.let { displayPageIndex -> + renderedPage?.let { render -> + DesktopPdfPaginatedPageDisplay( + pageIndex = displayPageIndex, + render = render + ) + } + } + when { + renderError != null && paginatedPageDisplay?.pageIndex != pageIndex -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + paginatedPageDisplay != null -> { + Crossfade( + targetState = paginatedPageDisplay.pageIndex, + animationSpec = tween(DesktopPdfPaginationPageTurnAnimationMillis), + label = "DesktopPdfPaginatedPage" + ) { displayPageIndex -> + val displayPageIsCurrent = displayPageIndex == currentPdfPageIndex + val pageIndex = displayPageIndex + val currentPageRender = if (displayPageIndex == paginatedPageDisplay.pageIndex) { + paginatedPageDisplay.render + } else { + paginatedRenderCache[displayPageIndex]?.render ?: paginatedPageDisplay.render + } + val pageSize = document.pageSizes.getOrNull(pageIndex) + if (pageSize == null) { + Text(readerString("desktop_failed_render_page", "Failed to render page."), color = MaterialTheme.colorScheme.error) + return@Crossfade + } + val pageDisplayScale = zoomSpec.clamp(scale) + val pageWidthDp = with(density) { (pageSize.width * pageDisplayScale).toDp() } + val pageHeightDp = with(density) { (pageSize.height * pageDisplayScale).toDp() } + val predictedPageCanvasSize = IntSize( + width = (pageSize.width * pageDisplayScale).roundToInt().coerceAtLeast(1), + height = (pageSize.height * pageDisplayScale).roundToInt().coerceAtLeast(1) + ) + val pageRenderScale = currentPageRender.width / pageSize.width + val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) { + annotations + .filter { it.pageIndex == pageIndex } + .flatMap { annotation -> + annotation.toRenderablePdfAnnotations(document, pageIndex, pageCanvasSize) + } + } + val selectedTextAnnotationForPage = selectedAnnotation?.takeIf { + selectedTool == PdfInkTool.TEXT && + !isTextSelectionMode && + it.kind == PdfAnnotationKind.TEXT && + it.pageIndex == pageIndex + } + val visiblePageAnnotations = remember(pageAnnotations, selectedTextAnnotationForPage?.id) { + pageAnnotations.filterNot { + it.kind == PdfAnnotationKind.TEXT && it.id == selectedTextAnnotationForPage?.id + } + } + val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) { + document.embeddedAnnotations.filter { it.pageIndex == pageIndex } + } + val searchHighlightBounds: List = remember( + document.path, + searchResults, + pageIndex, + activeSearchIndex, + searchHighlightMode, + pageCanvasSize, + searchQuery + ) { + val queryLength = searchQuery.trim().length + if (queryLength <= 0 || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) { + emptyList() + } else { + SharedPdfSearchEngine.highlightsForPage( + results = searchResults, + pageIndex = pageIndex, + activeResultIndex = activeSearchIndex, + mode = searchHighlightMode + ).flatMap { result -> + val matchLength = result.matchLength.takeIf { it > 0 } ?: queryLength + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = result.matchIndex, + endIndex = result.matchIndex + matchLength - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } + } + val ttsHighlightBounds: List = remember( + document.path, + activePdfTtsChunk, + pageIndex, + pageCanvasSize + ) { + val chunk = activePdfTtsChunk?.takeIf { it.pageIndex == pageIndex } + if (chunk == null || pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0 || chunk.endOffset <= chunk.startOffset) { + emptyList() + } else { + DesktopPdfium.textRectsForRange( + document = document, + pageIndex = pageIndex, + startIndex = chunk.startOffset, + endIndex = chunk.endOffset - 1, + viewportWidth = pageCanvasSize.width, + viewportHeight = pageCanvasSize.height + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + } + val pageZoomPreview = pdfZoomPreview?.takeIf { + it.displayMode == PdfDisplayMode.PAGINATION && + it.pageIndex == pageIndex + } + val pageLayoutPrediction = pageZoomPreview?.let { + desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = pdfZoomViewportRootOffset, + viewportSize = pdfZoomViewportSize, + pageCanvasSize = predictedPageCanvasSize, + horizontalScroll = pageHorizontalScrollState.value, + verticalScroll = pageVerticalScrollState.value, + paddingPx = with(density) { 24.dp.toPx() } + ) + } + Box( + modifier = Modifier + .size(pageWidthDp, pageHeightDp) + .onGloballyPositioned { coordinates -> + val rootOffset = coordinates.positionInRoot() + if (rootOffset != paginatedPageRootOffset) { + logPdfZoomSettle { + "page_layout seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + + "prevRoot=${paginatedPageRootOffset.formatLogOffset()} " + + "nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " + + "preview=${pageZoomPreview != null} h=${pageHorizontalScrollState.value} " + + "v=${pageVerticalScrollState.value} canvas=${pageCanvasSize.formatLogSize()}" + } + } + paginatedPageRootOffset = rootOffset + paginatedPageRootOffsets[pageIndex] = rootOffset + } + .onSizeChanged { size -> + if (pageCanvasSize != size) { + logPdfZoomSettle { + "page_size seq=$pdfZoomSettleSequence mode=pagination page=${pageIndex + 1} " + + "prev=${pageCanvasSize.formatLogSize()} next=${size.formatLogSize()} " + + "scale=${scale.formatLogFloat()} preview=${pageZoomPreview != null} " + + "bitmap=${currentPageRender.width}x${currentPageRender.height}" + } + logPdfSelection( + "layout page=${pageIndex + 1} " + + "canvas=${size.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + + "requestedScale=${scale.formatLogFloat()} displayScale=${pageDisplayScale.formatLogFloat()} " + + "renderScale=${pageRenderScale.formatLogFloat()}" + ) + } + pageCanvasSize = size + paginatedPageCanvasSizes[pageIndex] = size + } + .desktopPdfZoomPreviewLayer( + preview = pageZoomPreview, + currentZoom = scale, + viewportRootOffset = pdfZoomViewportRootOffset, + pageRootOffset = paginatedPageRootOffset, + pageCanvasSize = pageCanvasSize, + commitPageRootOffset = pageLayoutPrediction?.rootOffset, + scrollBounds = pageZoomPreview?.let { + DesktopPdfZoomScrollBounds( + currentHorizontalScroll = pageHorizontalScrollState.value, + maxHorizontalScroll = pageLayoutPrediction?.maxHorizontalScroll + ?: pageHorizontalScrollState.maxValue, + currentVerticalScroll = pageVerticalScrollState.value, + maxVerticalScroll = pageLayoutPrediction?.maxVerticalScroll + ?: pageVerticalScrollState.maxValue + ) + } + ) + .background(pdfThemeStyle.pageBackgroundColor, RoundedCornerShape(2.dp)) + .pointerInput( + pageIndex, + pageCanvasSize, + isTextSelectionMode, + selectedTool, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val point = event.changes.firstOrNull()?.position ?: continue + if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { + if (isTextSelectionMode) { + logPdfChromeTap { + "page_press source=paginated_inline_page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "consumedBefore=${event.changes.any { it.isConsumed }} " + + "selectionActive=${currentTextSelection != null} " + + "selectionMenuOpen=${selectionMenuOffset != null} " + + "selectedTool=$selectedTool richText=$isRichTextMode" + } + } + val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) { + currentPdfAnnotations.asReversed().firstOrNull { + it.isDesktopTextSelectionHighlight && + it.pageIndex == pageIndex && + it.sharedPdfHitTest(point, pageCanvasSize) + } + } else { + null + } + if (highlightHit != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=text_selection_highlight annotation=${highlightHit.id}" + } + selectAnnotation(highlightHit) + clearPdfInteractionState() + event.changes.forEach { it.consume() } + continue + } + if (selectedTool != PdfInkTool.TEXT) { + val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize) + if (linkTarget != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=link target=${linkTarget.formatLogTarget()}" + } + logPdfLink( + "tap_hit mode=page page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "textSelection=$isTextSelectionMode target=${linkTarget.formatLogTarget()}" + ) + activatePdfLink(linkTarget) + event.changes.forEach { it.consume() } + continue + } + } + val embeddedHit = pageEmbeddedAnnotations.findLast { + it.sharedPdfEmbeddedHitTest(point, pageCanvasSize) + } + if (embeddedHit != null) { + logPdfChromeTap { + "page_press_consume source=paginated_inline_page page=${pageIndex + 1} " + + "reason=embedded_annotation annotation=${embeddedHit.id}" + } + selectEmbeddedAnnotation(embeddedHit) + clearPdfInteractionState() + event.changes.forEach { it.consume() } + } else if ( + currentTextSelection != null && + selectionMenuOffset == null + ) { + logPdfChromeTap { + "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + + "action=clear_selection consumed=false" + } + selectionMenuOffset = null + textSelection = null + selectionStartHit = null + selectionEndHit = null + } else if (isTextSelectionMode) { + logPdfChromeTap { + "page_press_passthrough source=paginated_inline_page page=${pageIndex + 1} " + + "action=none consumed=false" + } + } + } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { + val selection = currentTextSelection + if (selection != null) { + selectionMenuOffset = point + logPdfSelection( + "menu_open page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length}" + ) + event.changes.forEach { it.consume() } + } + } + } + } + } + .pointerInput( + pageIndex, + pageCanvasSize, + isTextSelectionMode, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput + detectDesktopPdfTextSelectionLongPress( + source = "paginated_inline_page", + pageIndex = pageIndex + ) { point -> + val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize) + logPdfChromeTap { + "long_press_selection source=paginated_inline_page page=${pageIndex + 1} " + + "selectionFound=${selection != null} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}" + } + if (selection != null) { + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + textSelection = selection + selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point) + logPdfSelection( + "long_press page=${pageIndex + 1} " + + "x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " + + "range=${selection.startIndex}..${selection.endIndex} " + + "chars=${selection.text.length} " + + "text=\"${selection.text.logPreview()}\"" + ) + } + } + } + .pointerInput( + pageIndex, + selectedTool, + isTextSelectionMode, + isRichTextMode, + displayPageIsCurrent + ) { + if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val pointerId = down.id + var dragStarted = false + var dragDistance = 0f + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + ?: return@awaitEachGesture + if (change.changedToUp()) { + return@awaitEachGesture + } + if (!change.positionChanged()) continue + val delta = change.positionChange() + if (!dragStarted) { + dragDistance += delta.getDistance() + if (dragDistance <= viewConfiguration.touchSlop) { + continue + } + dragStarted = true + change.consume() + continue + } + pdfScope.launch { + pageHorizontalScrollState.scrollBy(-delta.x) + pageVerticalScrollState.scrollBy(-delta.y) + } + change.consume() + } + } + } + .pointerInput( + pageIndex, + isTextSelectionMode, + selectedTool, + selectedColor, + strokeWidth, + isHighlighterSnapEnabled, + textStyleConfig, + activeTextDraft?.id, + isRichTextMode, + displayPageIsCurrent, + pageCanvasSize, currentPageRender.width, + currentPageRender.height + ) { + if (!displayPageIsCurrent || isRichTextMode) return@pointerInput + if (isTextSelectionMode) { + var latestSelectionDragPoint: Offset? = null + var lastSelectionPreviewAt = 0L + detectDragGestures( + onDragStart = { start -> + latestSelectionDragPoint = start + lastSelectionPreviewAt = 0L + selectionMenuOffset = null + val existingSelection = textSelection + val handle = existingSelection?.handleAt(start, pageCanvasSize) + activeSelectionHandle = handle + val hit = document.charHitAt(pageIndex, start, pageCanvasSize) + if (handle != null && existingSelection != null) { + selectionStartHit = null + selectionStartIndex = when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.endIndex + DesktopPdfSelectionHandle.END -> existingSelection.startIndex + } + selectionEndHit = hit + selectionEndIndex = hit?.index ?: when (handle) { + DesktopPdfSelectionHandle.START -> existingSelection.startIndex + DesktopPdfSelectionHandle.END -> existingSelection.endIndex + } + } else { + selectionStartHit = hit + selectionStartIndex = hit?.index + selectionEndHit = null + selectionEndIndex = null + textSelection = null + } + logPdfSelection( + "drag_start page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + "handle=${handle?.name ?: "none"} " + + hit.formatLogHit("start") + ) + }, + onDrag = { change, _ -> + latestSelectionDragPoint = change.position + val now = System.currentTimeMillis() + if (lastSelectionPreviewAt == 0L || + now - lastSelectionPreviewAt >= DesktopPdfSelectionPreviewThrottleMillis + ) { + lastSelectionPreviewAt = now + val startIndex = selectionStartIndex + val hit = document.charHitAt(pageIndex, change.position, pageCanvasSize) + selectionEndHit = hit + val endIndex = hit?.index + val previousEndIndex = selectionEndIndex + selectionEndIndex = endIndex + if (endIndex != previousEndIndex || textSelection == null) { + textSelection = if (startIndex != null && endIndex != null) { + document.selectionPreviewBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize + ) + } else { + null + } + } + } + change.consume() + }, + onDragEnd = { + val finalHit = latestSelectionDragPoint + ?.let { document.charHitAt(pageIndex, it, pageCanvasSize) } + ?: selectionEndHit + if (finalHit != null) { + selectionEndHit = finalHit + selectionEndIndex = finalHit.index + } + val startIndex = selectionStartIndex + val endIndex = selectionEndIndex + val selection = if (startIndex != null && endIndex != null) { + document.selectionBetweenIndexes( + pageIndex = pageIndex, + startIndex = startIndex, + endIndex = endIndex, + canvasSize = pageCanvasSize, + useNativeBounds = true + ) + } else { + textSelection?.takeIf { it.text.isNotBlank() } + } + textSelection = selection + selectionMenuOffset = selection?.menuAnchor( + pageCanvasSize, + finalHit?.point ?: selectionEndHit?.point ?: selectionStartHit?.point + ) + logPdfSelection( + "drag_end page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + " " + + "range=${selection?.startIndex}..${selection?.endIndex} " + + "chars=${selection?.text?.length ?: 0} " + + "lines=${selection?.lineBounds?.size ?: 0} " + + "text=\"${selection?.text.orEmpty().logPreview()}\"" + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L + }, + onDragCancel = { + logPdfSelection( + "drag_cancel page=${pageIndex + 1} " + + "canvas=${pageCanvasSize.formatLogSize()} bitmap=${currentPageRender.width}x${currentPageRender.height} " + + "requestedScale=${scale.formatLogFloat()} renderScale=${pageRenderScale.formatLogFloat()} " + + selectionStartHit.formatLogHit("start") + " " + + selectionEndHit.formatLogHit("end") + ) + selectionStartIndex = null + selectionEndIndex = null + selectionStartHit = null + selectionEndHit = null + activeSelectionHandle = null + latestSelectionDragPoint = null + lastSelectionPreviewAt = 0L + } + ) + } else if (selectedTool == PdfInkTool.TEXT) { + detectTapGestures( + onTap = { start -> + when { + activeTextDraftContains(pageIndex, start, pageCanvasSize) -> Unit + else -> { + val textHit = currentPdfAnnotations.textAnnotationHitAt( + pageIndex = pageIndex, + point = start, + canvasSize = pageCanvasSize + ) + if (textHit != null) { + selectTextAnnotation(textHit) + } else { + startActiveTextDraft(pageIndex, start, pageCanvasSize) + } + } + } + } + ) + } else if (selectedTool != PdfInkTool.NONE) { + var eraserPreviousPoint: Offset? = null + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val start = down.position + if (selectedTool == PdfInkTool.ERASER) { + eraserPosition = start + val annotationSnapshot = currentPdfAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = start, + size = pageCanvasSize, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations)) + } + eraserPreviousPoint = start + } else { + activeStroke = listOf(start.toSharedPdfPoint(pageCanvasSize, System.currentTimeMillis())) + } + + val pointerId = down.id + var dragStarted = false + while (true) { + val event = awaitPointerEvent() + if (event.changes.size > 1) { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + val change = event.changes.firstOrNull { it.id == pointerId } + ?: run { + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + if (change.changedToUp()) { + change.consume() + if (selectedTool != PdfInkTool.ERASER && activeStroke.isNotEmpty()) { + dispatchPdf( + SharedPdfReaderAction.AnnotationAdded( + SharedPdfAnnotation( + id = "ink_${System.currentTimeMillis()}", + pageIndex = pageIndex, + kind = PdfAnnotationKind.INK, + tool = selectedTool, + points = activeStroke, + colorArgb = selectedColor, + strokeWidth = strokeWidth, + createdAt = System.currentTimeMillis() + ) + ) + ) + } + eraserPreviousPoint = null + eraserPosition = null + activeStroke = emptyList() + return@awaitEachGesture + } + if (!change.positionChanged()) continue + val distance = (change.position - start).getDistance() + if (selectedTool != PdfInkTool.ERASER && !dragStarted && distance <= viewConfiguration.touchSlop) continue + dragStarted = true + if (selectedTool == PdfInkTool.ERASER) { + val point = change.position + eraserPosition = point + val previousPoint = eraserPreviousPoint + val annotationSnapshot = currentPdfAnnotations + val updatedAnnotations = annotationSnapshot.filterNot { + it.pageIndex == pageIndex && it.sharedPdfHitTest( + point = point, + size = pageCanvasSize, + lastPoint = previousPoint, + eraserStrokeWidth = strokeWidth + ) + } + if (updatedAnnotations.size != annotationSnapshot.size) { + dispatchPdf(SharedPdfReaderAction.AnnotationsChanged(updatedAnnotations)) + } + eraserPreviousPoint = point + } else { + activeStroke = activeStroke.withDesktopPdfDragPoint( + point = change.position, + canvasSize = pageCanvasSize, + tool = selectedTool, + snapHighlighter = isHighlighterSnapEnabled, + timestamp = System.currentTimeMillis() + ) + } + change.consume() + } + } + } + } + ) { + DesktopPdfThemedPageImage( + bitmap = currentPageRender.image, + contentDescription = readerString("desktop_pdf_page_content_desc", "PDF page %1\$d", pageIndex + 1), + themeStyle = pdfThemeStyle, + modifier = Modifier.fillMaxSize() + ) + SharedPdfRichTextLayer( + pageIndex = pageIndex, + controller = richTextController, + pageWidth = pageCanvasSize.width.toFloat(), + pageHeight = pageCanvasSize.height.toFloat(), + isTextEditingEnabled = isRichTextMode, + onPageTapped = {} + ) + PdfSearchHighlightOverlay( + bounds = searchHighlightBounds, + canvasSize = pageCanvasSize, + color = when (searchHighlightMode) { + SearchHighlightMode.ALL -> Color(0x55FDD835) + SearchHighlightMode.FOCUSED -> Color(0x88FF9800) + } + ) + PdfSearchHighlightOverlay( + bounds = ttsHighlightBounds, + canvasSize = pageCanvasSize, + color = Color(0x887DD3FC) + ) + PdfTextSelectionOverlay( + selection = textSelection, + canvasSize = pageCanvasSize + ) + SharedPdfAnnotationOverlay( + annotations = visiblePageAnnotations, + activeStroke = activeStroke, + canvasSize = pageCanvasSize, + activeTool = selectedTool, + activeStrokeColorArgb = selectedColor, + activeStrokeWidth = strokeWidth, + selectedAnnotationId = selectedAnnotationId, + eraserPosition = eraserPosition, + showEraserIndicator = selectedTool == PdfInkTool.ERASER, + eraserStrokeWidth = strokeWidth + ) + PdfTextSelectionHandles( + selection = textSelection, + canvasSize = pageCanvasSize, + activeHandle = activeSelectionHandle + ) + SharedPdfInlineTextEditorOverlay( + draft = activeTextDraft?.takeIf { it.pageIndex == pageIndex }, + canvasSize = pageCanvasSize, + onTextChange = { updateActiveTextDraft(it, pageCanvasSize) }, + onBoundsChange = ::updateActiveTextDraftBounds + ) + selectedTextAnnotationForPage?.let { annotation -> + val bounds = annotation.bounds + if (bounds != null && activeTextDraft == null) { + SharedPdfTextBoxEditorOverlay( + id = annotation.id, + text = annotation.text, + style = annotation.sharedPdfTextStyle(), + bounds = bounds, + canvasSize = pageCanvasSize, + onTextChange = { text -> + updateAnnotation(annotation.copy(text = text)) + }, + onBoundsChange = { nextBounds -> + updateAnnotation(annotation.copy(bounds = nextBounds)) + } + ) + } + } + SharedPdfEmbeddedAnnotationOverlay( + annotations = pageEmbeddedAnnotations, + canvasSize = pageCanvasSize, + selectedAnnotationId = selectedEmbeddedAnnotationId + ) + if (pdfReaderSettings.pdfPageNumberOverlayVisible) { + SharedPdfPageNumberOverlay( + pageIndex = pageIndex, + pageCount = document.pageCount + ) + } + if (textSelection != null && selectionMenuOffset != null) { + Box( + modifier = Modifier + .matchParentSize() + .pointerInput(pageIndex, selectionMenuOffset) { + detectTapGestures { + logPdfChromeTap { + "selection_menu_scrim_tap source=paginated_inline_page page=${pageIndex + 1} " + + "consumedByScrim=true" + } + selectionMenuOffset = null + textSelection = null + selectionStartHit = null + selectionEndHit = null + } + } + ) + } + PdfSelectionMenu( + selection = textSelection, + menuOffset = selectionMenuOffset, + canvasSize = pageCanvasSize, + highlighterPalette = pdfHighlighterColors, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, + onCopy = { + textSelection?.let(::copySelection) + clearSelection() + }, + onHighlight = { colorArgb -> + textSelection?.let { selection -> + highlightSelection(pageIndex, selection, pageCanvasSize, colorArgb) + } + clearSelection() + }, + onSearch = { + textSelection?.let { openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, it.text) } + clearSelection() + }, + onDefine = { + textSelection?.let { runPdfAiAction(ReaderAiFeature.DEFINE, it.text) } + clearSelection() + }, + onSpeak = { + textSelection?.let { togglePdfCloudTts(it.text) } + clearSelection() + }, + showDefine = aiByokSettings.sanitized().areReaderAiFeaturesAvailable, + showSpeak = cloudTtsControlsAvailable && aiByokSettings.sanitized().isCloudTtsAvailable, + showSearch = featurePolicy.externalLookup, + onClear = ::clearSelection + ) + } + } + } + isRendering -> CircularProgressIndicator(modifier = Modifier.padding(48.dp)) + renderError != null -> Text( + renderError ?: readerString("desktop_failed_render_page", "Failed to render page."), + color = MaterialTheme.colorScheme.error + ) + } + DesktopPdfPageScrubOverlay( + pageIndex = pageScrubPreview, + pageCount = document.pageCount, + pageLabel = pdfPageScrubPreviewLabel + ) + } + } + } + AnimatedVisibility( + visible = showPdfZoomIndicator, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp), + enter = fadeIn(), + exit = fadeOut() + ) { + DesktopPdfZoomPercentageIndicator( + percentage = (zoomControlScale * 100).roundToInt(), + onResetZoomClick = { + cancelPendingPdfZoomPreview() + dispatchPdf(SharedPdfReaderAction.ZoomChanged(1f)) + } + ) + } + when { + showPdfAiHub -> { + DesktopAiHubSheet( + bookKey = pdfHubBookKey(), + bookTitle = pdfHubBookTitle(), + itemIndex = pageIndex, + itemTitle = readerString("pdf_page_short", "Page %1\$d", pageIndex + 1), + summaryCacheStore = summaryCacheStore, + summaryResult = pdfHubSummaryResult, + isSummaryLoading = isPdfHubSummaryLoading, + recapResult = null, + isRecapLoading = false, + recapProgressMessage = null, + onGenerateSummary = ::generatePdfHubSummary, + onClearSummary = ::clearPdfHubSummary, + onGenerateRecap = null, + onClearRecap = {}, + onDismiss = { showPdfAiHub = false }, + credits = credits, + showCredits = showPaidCredits + ) + } + selectedTextHighlight != null -> { + DesktopReaderBottomSheet( + title = selectedTextHighlight.desktopSheetTitle(), + onDismiss = ::dismissSelectedTextHighlightSheet + ) { + DesktopPdfAnnotationEditor( + annotation = selectedTextHighlight, + onUpdate = ::updateAnnotation, + onDelete = { deleteSelectedTextHighlight(selectedTextHighlight) }, + onClose = ::dismissSelectedTextHighlightSheet, + onCopy = { + clipboardManager.setText(AnnotatedString(selectedTextHighlight.text)) + dismissSelectedTextHighlightSheet() + }, + showSearch = featurePolicy.externalLookup, + highlighterPalette = pdfHighlighterColors, + onHighlighterPaletteChange = ::updatePdfHighlighterPalette, + onSearch = { + openPdfExternalLookup(ReaderExternalLookupAction.SEARCH, selectedTextHighlight.text) + dismissSelectedTextHighlightSheet() + } + ) + } + } + selectedEmbeddedAnnotation != null -> { + DesktopReaderBottomSheet( + title = readerString("desktop_pdf_comment", "PDF comment"), + onDismiss = { selectedEmbeddedAnnotationId = null } + ) { + DesktopPdfEmbeddedAnnotationPanel( + annotation = selectedEmbeddedAnnotation, + onCopy = { clipboardManager.setText(AnnotatedString(selectedEmbeddedAnnotation.threadText())) }, + onClose = { selectedEmbeddedAnnotationId = null } + ) + } + } + pdfExtrasState.aiResult.hasContent -> { + DesktopReaderAiResultSheet( + result = pdfExtrasState.aiResult, + onDismiss = { + dismissedPdfAiResultRequestId = pdfAiResultRequestId + pdfExtrasState = pdfExtrasState.copy(aiResult = ReaderAiResultState()) + } + ) + } + } + if (isPdfFileActionLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.12f)) + .zIndex(20_000f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + + if (showPdfSaveDialog) { + DesktopPdfExportChoiceDialog( + title = readerString("title_save_to_device", "Save to device"), + message = readerString("desktop_choose_pdf_to_save", "Choose which PDF to save."), + onOriginal = { + showPdfSaveDialog = false + savePdfCopy(SaveMode.ORIGINAL) + }, + onAnnotated = { + showPdfSaveDialog = false + savePdfCopy(SaveMode.ANNOTATED) + }, + onDismiss = { showPdfSaveDialog = false } + ) + } + + pdfFileActionNotice?.let { notice -> + AlertDialog( + onDismissRequest = { pdfFileActionNotice = null }, + title = { Text(notice.title) }, + text = { + Text( + text = notice.message, + color = if (notice.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + ) + }, + confirmButton = { + TextButton(onClick = { pdfFileActionNotice = null }) { + Text(readerString("action_ok", "OK")) + } + } + ) + } +} + +@Composable +private fun desktopPdfPageLabel( + pageIndex: Int, + pageCount: Int, + displayMode: PdfDisplayMode, + settings: ReaderSettings +): String { + val pageRange = if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings) + } else { + "${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}" + } + return if ('-' in pageRange) { + readerString("desktop_pdf_pages_of_count", "Pages %1\$s of %2\$d", pageRange, pageCount) + } else { + readerString("desktop_pdf_page_of_count", "Page %1\$s of %2\$d", pageRange, pageCount) + } +} + +@Composable +private fun DesktopPdfExportChoiceDialog( + title: String, + message: String, + onOriginal: () -> Unit, + onAnnotated: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onAnnotated) { + Text(readerString("action_with_annotations", "With annotations")) + } + }, + dismissButton = { + TextButton(onClick = onOriginal) { + Text(readerString("action_original", "Original")) + } + TextButton(onClick = onDismiss) { + Text(readerString("action_cancel", "Cancel")) + } + } + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt new file mode 100644 index 0000000..2e57abc --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflow.kt @@ -0,0 +1,95 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowHtml +import java.io.File + +private const val DesktopPdfReflowSuffix = "_reflow" + +internal object DesktopPdfReflowGenerator { + fun generateHtmlFile( + document: DesktopPdfDocument, + destFile: File, + startPage: Int = 1, + onProgress: (Float) -> Unit + ): Boolean { + require(document.formatLabel == "PDF") { "Only PDF documents can be converted to text view." } + if (document.pageCount <= 0) return false + + val firstPageIndex = (startPage - 1).coerceIn(0, document.pageCount - 1) + val totalPagesToGenerate = (document.pageCount - firstPageIndex).coerceAtLeast(1) + val headerFooterStrings = detectRepeatingHeaderFooter(document) + destFile.parentFile?.mkdirs() + + return runCatching { + destFile.bufferedWriter(Charsets.UTF_8).use { writer -> + writer.write(SharedPdfReflowHtml.buildGlobalHtmlHeader()) + for (pageIndex in firstPageIndex until document.pageCount) { + if (pageIndex > firstPageIndex) { + writer.write("\n\n") + } + val page = DesktopPdfium.loadReflowPage(document, pageIndex) + writer.write(SharedPdfReflowHtml.buildPageHtml(page, headerFooterStrings)) + if (pageIndex % 5 == 0 || pageIndex == document.pageCount - 1) { + val completedPages = pageIndex - firstPageIndex + 1 + onProgress(completedPages.toFloat() / totalPagesToGenerate.toFloat()) + } + } + writer.write(SharedPdfReflowHtml.buildGlobalHtmlFooter()) + } + true + }.getOrDefault(false) + } + + private fun detectRepeatingHeaderFooter(document: DesktopPdfDocument): Set { + if (document.pageCount < 5) return emptySet() + val step = maxOf(1, document.pageCount / 8) + val samplePageLines = (0 until document.pageCount) + .filter { it % step == 0 } + .take(8) + .map { pageIndex -> DesktopPdfium.loadReflowEdgeLines(document, pageIndex) } + return SharedPdfReflowHtml.detectRepeatingHeaderFooter(samplePageLines) + } +} + +internal fun desktopPdfReflowBookId(pdfBookId: String): String = "${pdfBookId}$DesktopPdfReflowSuffix" + +internal fun isDesktopPdfReflowBookId(bookId: String): Boolean = bookId.endsWith(DesktopPdfReflowSuffix) + +internal fun desktopPdfReflowDisplayName(originalTitle: String): String = "$originalTitle (Text View)" + +internal fun desktopPdfReflowTitle(originalTitle: String): String = "$originalTitle (Reflow)" + +internal fun desktopPdfReflowGeneratedAuthor(): String = "Generated" + +internal fun desktopPdfReflowFileName(pdfBookId: String, originalTitle: String): String { + val stem = (pdfBookId.ifBlank { originalTitle }) + .toDesktopSafeFileName() + return "${stem}$DesktopPdfReflowSuffix.html" +} + +internal fun desktopPdfReflowBookItem( + sourceBook: BookItem, + generatedFile: File, + nowMillis: Long, + initialPageIndex: Int? = null +): BookItem { + val originalTitle = sourceBook.title?.takeIf { it.isNotBlank() } + ?: sourceBook.displayName.substringBeforeLast('.', sourceBook.displayName) + .takeIf { it.isNotBlank() } + ?: "Document" + return BookItem( + id = desktopPdfReflowBookId(sourceBook.id), + path = generatedFile.absolutePath, + type = FileType.HTML, + displayName = desktopPdfReflowDisplayName(originalTitle), + timestamp = nowMillis, + title = desktopPdfReflowTitle(originalTitle), + author = desktopPdfReflowGeneratedAuthor(), + isRecent = true, + fileSize = generatedFile.length(), + fileContentModifiedTimestamp = generatedFile.lastModified(), + lastPageIndex = initialPageIndex + ) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt new file mode 100644 index 0000000..f77e5fa --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbing.kt @@ -0,0 +1,28 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.pdf.PdfSpreadLayout +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import kotlin.math.roundToInt + +internal fun desktopPdfPageScrubTarget( + value: Float, + pageCount: Int, + displayMode: PdfDisplayMode, + settings: ReaderSettings +): Int { + val clampedPage = value.roundToInt().coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + return if (displayMode == PdfDisplayMode.PAGINATION) { + PdfSpreadLayout.normalizePageIndex(clampedPage, pageCount, settings) + } else { + clampedPage + } +} + +internal fun desktopPdfPageScrubCommitTarget( + previewPage: Int?, + currentPage: Int, + pageCount: Int +): Int { + return (previewPage ?: currentPage).coerceIn(0, (pageCount - 1).coerceAtLeast(0)) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt new file mode 100644 index 0000000..f1faa3f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSelectionUi.kt @@ -0,0 +1,543 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette +import org.dueattendant149.bookreader.shared.ui.SharedHsvColorPickerDialog +import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuRect +import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuSize +import org.dueattendant149.bookreader.shared.ui.SharedSelectionMenuViewport +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedSelectionMenuPlacement +import kotlin.math.roundToInt + +internal data class DesktopPdfTextSelection( + val text: String, + val lineBounds: List, + val startIndex: Int, + val endIndex: Int +) + +private data class DesktopPdfSelectionCanvasBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) { + val centerX: Float get() = (left + right) / 2f +} + +private fun DesktopPdfTextSelection.canvasBounds(canvasSize: IntSize): DesktopPdfSelectionCanvasBounds? { + val validBounds = lineBounds.filter { it.right > it.left && it.bottom > it.top } + if (validBounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null + return DesktopPdfSelectionCanvasBounds( + left = validBounds.minOf { it.left } * canvasSize.width, + top = validBounds.minOf { it.top } * canvasSize.height, + right = validBounds.maxOf { it.right } * canvasSize.width, + bottom = validBounds.maxOf { it.bottom } * canvasSize.height + ) +} + +internal fun DesktopPdfTextSelection.menuAnchor( + canvasSize: IntSize, + fallback: Offset? +): Offset { + val bounds = canvasBounds(canvasSize) ?: return fallback ?: Offset.Zero + return Offset(x = bounds.centerX, y = bounds.top) +} + +private fun DesktopPdfTextSelection.startHandleOffset(canvasSize: IntSize): Offset? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + val first = lineBounds.firstOrNull { it.right > it.left && it.bottom > it.top } ?: return null + return Offset( + x = first.left * canvasSize.width, + y = first.bottom * canvasSize.height + ) +} + +private fun DesktopPdfTextSelection.endHandleOffset(canvasSize: IntSize): Offset? { + if (canvasSize.width <= 0 || canvasSize.height <= 0) return null + val last = lineBounds.lastOrNull { it.right > it.left && it.bottom > it.top } ?: return null + return Offset( + x = last.right * canvasSize.width, + y = last.bottom * canvasSize.height + ) +} + +internal fun DesktopPdfTextSelection.handleAt( + point: Offset, + canvasSize: IntSize +): DesktopPdfSelectionHandle? { + val start = startHandleOffset(canvasSize) + val end = endHandleOffset(canvasSize) + + fun Offset.containsHandlePoint(): Boolean { + val halfWidth = DesktopPdfSelectionHandleTouchWidthPx / 2f + return point.x in (x - halfWidth)..(x + halfWidth) && + point.y in (y - DesktopPdfSelectionHandleTouchTopPx)..(y + DesktopPdfSelectionHandleTouchBottomPx) + } + + return when { + start != null && start.containsHandlePoint() -> DesktopPdfSelectionHandle.START + end != null && end.containsHandlePoint() -> DesktopPdfSelectionHandle.END + else -> null + } +} + +@Composable +internal fun PdfSearchHighlightOverlay( + bounds: List, + canvasSize: IntSize, + color: Color +) { + if (bounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return + Canvas(Modifier.fillMaxSize()) { + bounds.forEach { rect -> + drawRect( + color = color, + topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), + size = androidx.compose.ui.geometry.Size( + (rect.right - rect.left) * canvasSize.width, + (rect.bottom - rect.top) * canvasSize.height + ) + ) + } + } +} + +@Composable +internal fun PdfTextSelectionOverlay( + selection: DesktopPdfTextSelection?, + canvasSize: IntSize +) { + val bounds = selection?.lineBounds.orEmpty() + if (bounds.isEmpty()) return + Canvas(Modifier.fillMaxSize()) { + bounds.forEach { rect -> + drawRect( + color = Color(0x663B82F6), + topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height), + size = androidx.compose.ui.geometry.Size( + (rect.right - rect.left) * canvasSize.width, + (rect.bottom - rect.top) * canvasSize.height + ) + ) + } + } +} + +@Composable +internal fun PdfTextSelectionHandles( + selection: DesktopPdfTextSelection?, + canvasSize: IntSize, + activeHandle: DesktopPdfSelectionHandle? +) { + selection ?: return + if (canvasSize.width <= 0 || canvasSize.height <= 0) return + val density = LocalDensity.current + val handleSize = 24.dp + val handleWidthPx = with(density) { handleSize.toPx() } + val start = selection.startHandleOffset(canvasSize) + val end = selection.endHandleOffset(canvasSize) + val handleColor = MaterialTheme.colorScheme.primary + + fun Modifier.handleOffset(position: Offset): Modifier = offset { + IntOffset( + x = (position.x - handleWidthPx / 2f).roundToInt(), + y = position.y.roundToInt() + ) + } + + Box(Modifier.fillMaxSize()) { + start?.let { position -> + Icon( + imageVector = DesktopPdfSelectionMenuIcons.Teardrop, + contentDescription = readerString("desktop_selection_start_handle", "Selection start handle"), + tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.END) 0.72f else 1f), + modifier = Modifier + .handleOffset(position) + .size(handleSize) + .graphicsLayer { + rotationZ = 30f + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) + } + end?.let { position -> + Icon( + imageVector = DesktopPdfSelectionMenuIcons.Teardrop, + contentDescription = readerString("desktop_selection_end_handle", "Selection end handle"), + tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.START) 0.72f else 1f), + modifier = Modifier + .handleOffset(position) + .size(handleSize) + .graphicsLayer { + rotationZ = -30f + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) + } + } +} + +private object DesktopPdfSelectionMenuIcons { + val Copy = vector( + name = "DesktopPdfSelectionCopy", + pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" + ) + val Dictionary = vector( + name = "DesktopPdfSelectionDictionary", + pathData = "M160,569L205,569L228,503L332,503L356,569L400,569L303,311L257,311L160,569ZM241,466L279,359L281,359L319,466L241,466ZM560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Z" + ) + val Search = vector( + name = "DesktopPdfSelectionSearch", + pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" + ) + val Teardrop = vector( + name = "DesktopPdfSelectionTeardrop", + pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z" + ) + + private fun vector(name: String, pathData: String): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black) + ) + }.build() + } +} + +internal enum class DesktopPdfSelectionHandle { + START, + END +} + +@Composable +internal fun PdfSelectionMenu( + selection: DesktopPdfTextSelection?, + menuOffset: Offset?, + canvasSize: IntSize, + highlighterPalette: List = SharedPdfHighlighterPalette.defaultColors, + onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit, + onCopy: () -> Unit, + onHighlight: (Int) -> Unit, + onSearch: () -> Unit, + onDefine: () -> Unit, + onSpeak: () -> Unit, + showDefine: Boolean, + showSpeak: Boolean, + showSearch: Boolean, + onClear: () -> Unit +) { + selection ?: return + val anchor = menuOffset ?: return + val selectionBounds = selection.canvasBounds(canvasSize) + val paletteColors = remember(highlighterPalette) { + SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors + } + val density = LocalDensity.current + var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) { + mutableStateOf(null) + } + var editingHighlighterDraftColors by remember(selection.startIndex, selection.endIndex, paletteColors) { + mutableStateOf>(emptyList()) + } + val actions = buildList { + add(PdfSelectionMenuAction(readerString("action_copy", "Copy"), DesktopPdfSelectionMenuIcons.Copy, onCopy)) + if (showDefine) add(PdfSelectionMenuAction(readerString("action_define", "Define"), DesktopPdfSelectionMenuIcons.Dictionary, onDefine)) + if (showSpeak) add(PdfSelectionMenuAction(readerString("label_speak", "Speak"), Icons.AutoMirrored.Filled.VolumeUp, onSpeak)) + if (showSearch) add(PdfSelectionMenuAction(readerString("action_search", "Search"), DesktopPdfSelectionMenuIcons.Search, onSearch)) + add(PdfSelectionMenuAction(readerString("action_clear", "Clear"), Icons.Default.Close, onClear, isDestructive = true)) + } + + fun highlighterDraftColors(): List { + return editingHighlighterDraftColors.ifEmpty { paletteColors } + } + + fun updateHighlighterDraft(slotIndex: Int, color: Color): List { + val nextColors = highlighterDraftColors().toMutableList() + if (slotIndex in nextColors.indices) { + nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb() + editingHighlighterDraftColors = nextColors + } + return nextColors + } + + fun openHighlighterEditor(slotIndex: Int) { + if (editingHighlighterSlot == null) { + editingHighlighterDraftColors = paletteColors + } + editingHighlighterSlot = slotIndex + } + + val actionRowCount = ((actions.size + 2) / 3).coerceAtLeast(1) + val popupWidthPx = with(density) { PdfSelectionMenuWidth.toPx() } + val estimatedHeightPx = with(density) { + PdfSelectionMenuPaletteHeight.toPx() + + (actionRowCount * PdfSelectionMenuActionRowHeight.toPx()) + } + val placement = sharedSelectionMenuPlacement( + viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height), + popup = SharedSelectionMenuSize( + width = popupWidthPx.roundToInt(), + height = estimatedHeightPx.roundToInt() + ), + selection = if (selectionBounds != null) { + SharedSelectionMenuRect( + left = selectionBounds.left, + top = selectionBounds.top, + right = selectionBounds.right, + bottom = selectionBounds.bottom + ) + } else { + SharedSelectionMenuRect( + left = anchor.x, + top = anchor.y, + right = anchor.x, + bottom = anchor.y + ) + }, + marginPx = with(density) { PdfSelectionMenuMargin.toPx() }, + gapPx = with(density) { PdfSelectionMenuAnchorGap.toPx() } + ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + shadowElevation = 10.dp, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.offset { + IntOffset(placement.x, placement.y) + } + ) { + Column( + modifier = Modifier + .widthIn(min = 180.dp, max = 220.dp) + .padding(bottom = 6.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + paletteColors.forEach { colorArgb -> + Surface( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(28.dp) + .clickable { onHighlight(colorArgb) }, + color = Color(colorArgb), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)), + content = {} + ) + } + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(28.dp) + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.sweepGradient( + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp)) + .clickable { openHighlighterEditor(0) } + ) + } + HorizontalDivider() + actions.chunked(3).forEach { rowActions -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + rowActions.forEach { action -> + val tint = if (action.isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + Column( + modifier = Modifier + .width(58.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { action.onClick() } + .padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = action.icon, + contentDescription = action.label, + tint = tint, + modifier = Modifier.size(22.dp) + ) + Text( + action.label, + style = MaterialTheme.typography.labelSmall, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + repeat(3 - rowActions.size) { + Spacer(modifier = Modifier.width(58.dp)) + } + } + } + } + } + } + editingHighlighterSlot?.let { requestedSlot -> + val draftColors = highlighterDraftColors() + val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors } + val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex) + val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) } + SharedHsvColorPickerDialog( + initialColor = initialColor, + title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1), + onDismiss = { editingHighlighterSlot = null }, + onSave = { color -> + val nextColors = updateHighlighterDraft(slot, color) + onHighlighterPaletteChange( + SharedPdfHighlighterPalette(nextColors).sanitized() + ) + editingHighlighterSlot = null + }, + resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) { + SharedPdfHighlighterPalette.defaultColors.first() + }).copy(alpha = 1f), + stateKey = slot, + onLiveColorChange = { color -> + updateHighlighterDraft(slot, color) + } + ) { liveColor -> + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + highlighterDraftColors().forEachIndexed { index, argb -> + val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f) + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(color) + .border( + width = if (index == slot) 3.dp else 1.dp, + color = if (index == slot) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + }, + shape = RoundedCornerShape(21.dp) + ) + .clickable { openHighlighterEditor(index) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", + color = if (color.luminance() > 0.5f) Color.Black else Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } +} + +private data class PdfSelectionMenuAction( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, + val isDestructive: Boolean = false +) + +private val PdfSelectionMenuWidth = 220.dp +private val PdfSelectionMenuPaletteHeight = 54.dp +private val PdfSelectionMenuActionRowHeight = 66.dp +private val PdfSelectionMenuAnchorGap = 16.dp +private val PdfSelectionMenuMargin = 6.dp +private const val DesktopPdfSelectionHandleTouchWidthPx = 44f +private const val DesktopPdfSelectionHandleTouchTopPx = 8f +private const val DesktopPdfSelectionHandleTouchBottomPx = 40f diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt new file mode 100644 index 0000000..a0976ce --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarEffects.kt @@ -0,0 +1,215 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmarkSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichDocument +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextController +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextLog +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext + +@Composable +internal fun DesktopPdfAnnotationSidecarEffect( + documentHandleId: Long, + annotationFile: File, + annotations: List, + annotationsLoaded: Boolean, + onAnnotationsLoadedChange: (Boolean) -> Unit, + onAnnotationsLoaded: (List) -> Unit, + onLocalSidecarsChanged: () -> Unit +) { + LaunchedEffect(documentHandleId) { + onAnnotationsLoadedChange(false) + val loadedAnnotations = if (annotationFile.exists()) { + withContext(Dispatchers.IO) { + SharedPdfAnnotationSerializer.decode(annotationFile.readText()) + } + } else { + emptyList() + } + onAnnotationsLoaded(loadedAnnotations) + logDesktopCloudAnnotations { + "desktop.local.load_annotations document=$documentHandleId count=${loadedAnnotations.size} " + + "exists=${annotationFile.exists()} bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()}" + } + onAnnotationsLoadedChange(true) + } + + LaunchedEffect(documentHandleId, annotations, annotationsLoaded) { + if (!annotationsLoaded) return@LaunchedEffect + val changed = withContext(Dispatchers.IO) { + runCatching { + val nextJson = SharedPdfAnnotationSerializer.encode(annotations) + when { + annotations.isEmpty() && annotationFile.isFile -> { + annotationFile.delete() + } + annotations.isEmpty() -> false + annotationFile.isFile && annotationFile.readText() == nextJson -> false + else -> { + annotationFile.parentFile?.mkdirs() + annotationFile.writeText(nextJson) + true + } + } + }.getOrDefault(false) + } + if (changed) { + logDesktopCloudAnnotations { + "desktop.local.save_annotations document=$documentHandleId count=${annotations.size} " + + "bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()} " + + "path=${annotationFile.absolutePath.logPreview(140)}" + } + onLocalSidecarsChanged() + } + } +} + +@Composable +internal fun DesktopPdfBookmarkSidecarEffect( + documentHandleId: Long, + bookmarkFile: File, + bookmarks: List, + bookmarksLoaded: Boolean, + onBookmarksLoadedChange: (Boolean) -> Unit, + onBookmarksLoaded: (List) -> Unit, + onLocalSidecarsChanged: () -> Unit +) { + LaunchedEffect(documentHandleId) { + onBookmarksLoadedChange(false) + val loadedBookmarks = if (bookmarkFile.exists()) { + withContext(Dispatchers.IO) { + SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()) + } + } else { + emptyList() + } + onBookmarksLoaded(loadedBookmarks) + onBookmarksLoadedChange(true) + } + + LaunchedEffect(documentHandleId, bookmarks, bookmarksLoaded) { + if (!bookmarksLoaded) return@LaunchedEffect + val changed = withContext(Dispatchers.IO) { + runCatching { + val nextJson = SharedPdfBookmarkSerializer.encode(bookmarks) + when { + bookmarks.isEmpty() && !bookmarkFile.isFile -> false + bookmarkFile.isFile && bookmarkFile.readText() == nextJson -> false + else -> { + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(nextJson) + true + } + } + }.getOrDefault(false) + } + if (changed) { + onLocalSidecarsChanged() + } + } +} + +@Composable +internal fun DesktopPdfRichTextSidecarEffect( + documentHandleId: Long, + richTextFile: File, + richTextController: SharedPdfRichTextController, + onRichTextLoadedChange: (Boolean) -> Unit +) { + LaunchedEffect(documentHandleId) { + onRichTextLoadedChange(false) + SharedPdfRichTextLog.d( + "desktop.loadRichText start path=\"${richTextFile.absolutePath.logPreview(160)}\" exists=${richTextFile.exists()}" + ) + val loadedRichText = withContext(Dispatchers.IO) { + if (richTextFile.exists()) { + val raw = richTextFile.readText() + SharedPdfRichTextLog.d( + "desktop.loadRichText read path=\"${richTextFile.absolutePath.logPreview(160)}\" rawLen=${raw.length}" + ) + SharedPdfRichTextSerializer.decode(raw) + } else { + SharedPdfRichDocument() + } + } + SharedPdfRichTextLog.d( + "desktop.loadRichText decoded textLen=${loadedRichText.text.length} spans=${loadedRichText.spans.size}" + ) + richTextController.replaceDocument(loadedRichText) + onRichTextLoadedChange(true) + SharedPdfRichTextLog.d("desktop.loadRichText ready") + } +} + +@Composable +internal fun DesktopPdfSearchIndexSidecarEffect( + documentHandleId: Long, + document: DesktopPdfDocument, + searchIndexFile: File, + onIndexedSearchPageCountChange: (Int) -> Unit, + onSearchIndexingChange: (Boolean) -> Unit +) { + LaunchedEffect(documentHandleId) { + val restoredPageCount = withContext(Dispatchers.IO) { + restoreDesktopPdfSearchIndex(document, searchIndexFile) + } + onIndexedSearchPageCountChange(restoredPageCount) + onSearchIndexingChange(restoredPageCount < document.pageCount) + logPdfZoomPerf { + "search_index_restore indexed=$restoredPageCount/${document.pageCount} " + + "active=${restoredPageCount < document.pageCount}" + } + withContext(Dispatchers.IO) { + DesktopPdfium.indexSearchPages( + document = document, + onProgress = { indexed, _ -> + onIndexedSearchPageCountChange(indexed) + logPdfZoomPerf { "search_index_progress indexed=$indexed/${document.pageCount}" } + }, + shouldContinue = { isActive } + ) + if (isActive) { + saveDesktopPdfSearchIndex(document, searchIndexFile) + } + } + if (!isActive) return@LaunchedEffect + val indexedPageCount = document.indexedSearchTextPageCount() + onIndexedSearchPageCountChange(indexedPageCount) + onSearchIndexingChange(false) + logPdfZoomPerf { "search_index_done indexed=$indexedPageCount/${document.pageCount}" } + } +} + +@Composable +internal fun DesktopPdfSearchResultsEffect( + documentHandleId: Long, + document: DesktopPdfDocument, + searchQuery: String, + indexedSearchPageCount: Int, + onSearchResultsChange: (List) -> Unit +) { + LaunchedEffect(documentHandleId, searchQuery, indexedSearchPageCount) { + val normalizedQuery = searchQuery.trim() + val results = if (normalizedQuery.isBlank()) { + emptyList() + } else { + withContext(Dispatchers.IO) { + DesktopPdfium.search(document, normalizedQuery) + } + } + onSearchResultsChange(results) + } +} + +private fun File.lastModifiedIfFileForCloudLog(): Long { + return if (isFile) lastModified() else 0L +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt new file mode 100644 index 0000000..433796c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecars.kt @@ -0,0 +1,132 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.security.MessageDigest +import java.util.Base64 + +private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t2" + +internal fun desktopPdfAnnotationFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_$safeName.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_$legacyName.json") + ) +} + +internal fun desktopPdfAnnotationDeletionFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_deleted_annotations.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_deleted_annotations.json") + ) +} + +internal fun desktopPdfBookmarkFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_bookmarks.json") + ) +} + +internal fun desktopPdfRichTextFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + val legacyName = desktopPdfLegacyDocumentKey(documentPath) + return sidecarFileWithLegacyMigration( + file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json"), + legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_rich_text.json") + ) +} + +internal fun desktopPdfSearchIndexFile(documentPath: String): File { + val safeName = desktopPdfDocumentKey(documentPath) + return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv") +} + +internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int { + val sourceFile = File(document.path) + val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount() + if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0 + val metadata = lines + .asSequence() + .drop(1) + .takeWhile { !it.startsWith("page\t") } + .mapNotNull { line -> + val parts = line.split('\t', limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + } + .toMap() + val isFresh = metadata["pathKey"] == desktopPdfDocumentKey(document.path) && + metadata["fileSize"] == sourceFile.length().toString() && + metadata["lastModified"] == sourceFile.lastModified().toString() && + metadata["pageCount"] == document.pageCount.toString() + if (!isFresh) return 0 + + val decoder = Base64.getDecoder() + lines.asSequence() + .filter { it.startsWith("page\t") } + .forEach { line -> + val parts = line.split('\t', limit = 3) + val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach + val text = runCatching { + String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8) + }.getOrDefault("") + document.cacheSearchTextPage(pageIndex, text) + } + return document.indexedSearchTextPageCount() +} + +internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) { + val sourceFile = File(document.path) + val pages = document.indexedSearchPages() + if (pages.isEmpty()) return + val encoder = Base64.getEncoder() + val payload = buildString { + appendLine(DesktopPdfSearchIndexHeader) + appendLine("pathKey\t${desktopPdfDocumentKey(document.path)}") + appendLine("fileSize\t${sourceFile.length()}") + appendLine("lastModified\t${sourceFile.lastModified()}") + appendLine("pageCount\t${document.pageCount}") + pages.forEach { page -> + append("page\t") + append(page.pageIndex) + append('\t') + appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8))) + } + } + runCatching { + indexFile.parentFile?.mkdirs() + indexFile.writeText(payload, Charsets.UTF_8) + } +} + +internal fun desktopPdfDocumentKey(documentPath: String): String { + val normalizedPath = runCatching { File(documentPath).canonicalPath } + .getOrElse { documentPath.trim() } + return sha256Hex(normalizedPath).take(32) +} + +private fun desktopPdfLegacyDocumentKey(documentPath: String): String { + return documentPath.hashCode().toString().replace("-", "n") +} + +private fun sidecarFileWithLegacyMigration(file: File, legacyFile: File): File { + if (!file.exists() && legacyFile.isFile && legacyFile != file) { + runCatching { + file.parentFile?.mkdirs() + if (!legacyFile.renameTo(file)) { + legacyFile.copyTo(file, overwrite = false) + } + } + } + return file +} + +private fun sha256Hex(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt new file mode 100644 index 0000000..d602e08 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSyncSidecars.kt @@ -0,0 +1,156 @@ +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.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmarkSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +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.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private val desktopPdfSyncJson = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true +} + +internal fun desktopPdfAnnotationElementForSync(rawJson: String): JsonElement? { + val annotations = SharedPdfAnnotationSerializer.decode(rawJson) + if (annotations.isEmpty()) return null + return SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations) +} + +internal fun desktopPdfRichTextElementForSync(rawJson: String): JsonElement? { + val element = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() + ?: return null + val document = SharedPdfRichTextSerializer.decodeElement(element) + if (document.text.isEmpty() && document.spans.isEmpty()) return null + return SharedPdfRichTextSerializer.encodeElement(document) +} + +internal fun desktopPdfBookmarksMetadataJson(book: BookItem): String? { + if (book.type != FileType.PDF) return null + val path = book.path?.takeIf { it.isNotBlank() } ?: return null + val bookmarkFile = desktopPdfBookmarkFile(path).takeIf { it.isFile } ?: return null + return desktopPdfBookmarksMetadataJson( + bookmarks = SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()), + lastPageIndex = book.lastPageIndex + ) +} + +internal fun desktopPdfBookmarksMetadataJson( + bookmarks: List, + lastPageIndex: Int? +): String { + val totalPages = maxOf( + (lastPageIndex ?: 0) + 1, + (bookmarks.maxOfOrNull { it.pageIndex } ?: 0) + 1 + ).coerceAtLeast(1) + return desktopPdfSyncJson.encodeToString( + JsonElement.serializer(), + JsonArray( + bookmarks.map { bookmark -> + JsonObject( + mapOf( + "pageIndex" to JsonPrimitive(bookmark.pageIndex.coerceAtLeast(0)), + "title" to JsonPrimitive(bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }), + "totalPages" to JsonPrimitive(totalPages) + ) + ) + } + ) + ) +} + +internal fun desktopPdfBookmarkMetadataTimestamp(book: BookItem): Long { + if (book.type != FileType.PDF) return 0L + val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L + return desktopPdfBookmarkFile(path).lastModifiedIfFile() +} + +internal fun importDesktopPdfBookmarksMetadata( + book: BookItem, + bookmarksJson: String?, + timestamp: Long +): Boolean { + if (book.type != FileType.PDF) return false + val path = book.path?.takeIf { it.isNotBlank() } ?: return false + val rawJson = bookmarksJson?.takeIf { it.isNotBlank() } ?: return false + val bookmarks = desktopPdfBookmarksFromMetadataJson(rawJson) + val bookmarkFile = desktopPdfBookmarkFile(path) + val localTimestamp = bookmarkFile.lastModifiedIfFile() + if (timestamp <= localTimestamp + 1000L) return false + + if (bookmarks.isEmpty()) { + if (bookmarkFile.isFile) bookmarkFile.delete() + return true + } + + bookmarkFile.parentFile?.mkdirs() + bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks)) + bookmarkFile.setLastModified(timestamp) + return true +} + +internal fun desktopPdfBookmarksFromMetadataJson(rawJson: String): List { + val root = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull() + ?: return emptyList() + + root.jsonArrayOrNull()?.let { androidBookmarks -> + return androidBookmarks.mapNotNull { element -> + val obj = element.jsonObjectOrNull() ?: return@mapNotNull null + val pageIndex = obj.int("pageIndex") ?: return@mapNotNull null + SharedPdfBookmark( + pageIndex = pageIndex.coerceAtLeast(0), + label = obj.string("title") ?: obj.string("label") ?: "Page ${pageIndex + 1}", + createdAt = obj.longString("createdAt") ?: 0L + ) + } + } + + return SharedPdfBookmarkSerializer.decode(rawJson) +} + +private fun File.lastModifiedIfFile(): Long { + return if (isFile()) lastModified() else 0L +} + +private fun JsonElement.jsonArrayOrNull(): JsonArray? { + if (this is JsonNull) return null + return runCatching { jsonArray }.getOrNull() +} + +private fun JsonElement.jsonObjectOrNull(): JsonObject? { + if (this is JsonNull) return null + return runCatching { jsonObject }.getOrNull() +} + +private fun JsonObject.string(name: String): String? { + return this[name] + ?.takeUnless { it is JsonNull } + ?.jsonPrimitive + ?.contentOrNull + ?.takeIf { it.isNotBlank() } +} + +private fun JsonObject.int(name: String): Int? { + return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull +} + +private fun JsonObject.longString(name: String): Long? { + return string(name)?.toLongOrNull() + ?: this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toLongOrNull() +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt new file mode 100644 index 0000000..bd23c2d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTheme.kt @@ -0,0 +1,56 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.pdf.pdfVerticalPageGapDp + +internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.PAGINATION +internal val DesktopDefaultPdfVerticalPageGap = 8.dp +internal val DesktopDefaultPdfSpreadPageGap = 18.dp + +internal fun desktopPdfPageBackgroundColor( + theme: ReaderTheme, + displayMode: PdfDisplayMode +): Color { + return when (theme.id) { + "no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + "reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White + else -> theme.backgroundColor.takeIf { it.isSpecified } + ?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black + } +} + +internal fun desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor: Color, + gapBackgroundColor: Color, + isPageGapVisible: Boolean +): Color { + return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor +} + +internal fun desktopPdfViewportBackgroundColor( + displayMode: PdfDisplayMode, + pageBackgroundColor: Color, + appBackgroundColor: Color, + isVerticalPageGapVisible: Boolean +): Color { + return when (displayMode) { + PdfDisplayMode.VERTICAL_SCROLL -> desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pageBackgroundColor, + gapBackgroundColor = appBackgroundColor, + isPageGapVisible = isVerticalPageGapVisible + ) + PdfDisplayMode.PAGINATION -> appBackgroundColor + } +} + +internal fun desktopPdfSpreadPageGapDp( + isPageGapVisible: Boolean +): Dp = pdfVerticalPageGapDp( + isPageGapVisible = isPageGapVisible, + defaultGap = DesktopDefaultPdfSpreadPageGap +) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt new file mode 100644 index 0000000..509c355 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfZoom.kt @@ -0,0 +1,644 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.roundToInt + +private const val DesktopPdfZoomGestureFrameMillis = 16L +private const val DesktopPdfZoomPreviewTolerance = 0.0001f +internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f + +internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float { + if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f + val normalizedDelta = scrollDelta.coerceIn(-8f, 8f) + return exp((-normalizedDelta * 0.12f).toDouble()).toFloat() +} + +internal fun desktopPdfZoomTarget( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + factor: Float +): Float { + val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default + val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f + return zoomSpec.clamp(baseZoom * safeFactor) +} + +internal fun desktopPdfAnchoredScrollTarget( + currentScroll: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return currentScroll.coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredLazyItemScrollOffset( + itemOffset: Int, + anchor: Float, + oldZoom: Float, + newZoom: Float +): Int { + if ( + !anchor.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return (-itemOffset).coerceAtLeast(0) + } + val zoomRatio = newZoom / oldZoom + val offsetWithinItem = anchor - itemOffset + return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0) +} + +internal fun desktopPdfAnchoredPageScrollDelta( + viewportRootOffset: Offset, + oldPageRootOffset: Offset, + currentPageRootOffset: Offset, + anchor: Offset, + oldZoom: Float, + newZoom: Float +): IntOffset? { + if ( + !anchor.x.isFinite() || + !anchor.y.isFinite() || + !oldZoom.isFinite() || + !newZoom.isFinite() || + oldZoom <= 0f || + newZoom <= 0f + ) { + return null + } + val rootAnchor = viewportRootOffset + anchor + val oldPageLocal = rootAnchor - oldPageRootOffset + val zoomRatio = newZoom / oldZoom + val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio) + val desiredPageRoot = rootAnchor - newPageLocal + val delta = currentPageRootOffset - desiredPageRoot + return IntOffset(delta.x.roundToInt(), delta.y.roundToInt()) +} + +internal fun desktopPdfPaginationFirstRenderScale( + requestedScale: Float, + hasPageRender: Boolean, + isOpeningRender: Boolean = false +): Float { + if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) { + return requestedScale + } + return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale) +} + +internal fun desktopPdfRenderBelongsToPage( + renderedPageIndex: Int?, + requestedPageIndex: Int +): Boolean { + return renderedPageIndex == requestedPageIndex +} + +internal fun desktopPdfRenderScaleNeedsUpgrade( + renderedScale: Float?, + requestedScale: Float +): Boolean { + if (renderedScale == null) return true + if (!requestedScale.isFinite() || requestedScale <= 0f) return false + if (!renderedScale.isFinite() || renderedScale <= 0f) return true + return requestedScale - renderedScale > DesktopPdfRenderScaleTolerance +} + +internal fun desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset: Offset, + anchor: Offset?, + visiblePageIndices: List, + pageRootOffsets: Map, + pageSizes: Map, + fallbackPageIndex: Int +): Int { + if (anchor == null || visiblePageIndices.isEmpty()) return fallbackPageIndex + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return fallbackPageIndex + val rootAnchor = viewportRootOffset + anchor + if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return fallbackPageIndex + val candidates = visiblePageIndices.mapNotNull { pageIndex -> + pageRootOffsets[pageIndex]?.let { root -> + pageIndex to root + } + } + if (candidates.isEmpty()) return fallbackPageIndex + candidates.firstOrNull { (pageIndex, root) -> + val size = pageSizes[pageIndex] ?: return@firstOrNull false + val width = size.width.toFloat() + val height = size.height.toFloat() + rootAnchor.x >= root.x && + rootAnchor.x <= root.x + width && + rootAnchor.y >= root.y && + rootAnchor.y <= root.y + height + }?.let { return it.first } + return candidates.minByOrNull { (pageIndex, root) -> + val size = pageSizes[pageIndex] + val dx: Float + val dy: Float + if (size == null) { + dx = rootAnchor.x - root.x + dy = rootAnchor.y - root.y + } else { + val right = root.x + size.width.toFloat() + val bottom = root.y + size.height.toFloat() + dx = when { + rootAnchor.x < root.x -> root.x - rootAnchor.x + rootAnchor.x > right -> rootAnchor.x - right + else -> 0f + } + dy = when { + rootAnchor.y < root.y -> root.y - rootAnchor.y + rootAnchor.y > bottom -> rootAnchor.y - bottom + else -> 0f + } + } + dx * dx + dy * dy + }?.first ?: fallbackPageIndex +} + +internal data class DesktopPdfZoomPreview( + val baseZoom: Float, + val zoom: Float, + val anchor: Offset?, + val displayMode: PdfDisplayMode, + val pageIndex: Int?, + val viewportRootOffset: Offset = Offset.Zero, + val pageRootOffset: Offset? = null, + val commitTargetHorizontalScroll: Int? = null, + val commitTargetVerticalScroll: Int? = null, + val diagnosticSequence: Int = 0 +) + +internal data class DesktopPdfZoomScrollBounds( + val currentHorizontalScroll: Int? = null, + val maxHorizontalScroll: Int? = null, + val currentVerticalScroll: Int? = null, + val maxVerticalScroll: Int? = null +) + +internal interface DesktopPdfLayoutScrollPrediction { + val maxHorizontalScroll: Int + val maxVerticalScroll: Int +} + +internal data class DesktopPdfSinglePageLayoutPrediction( + val rootOffset: Offset, + override val maxHorizontalScroll: Int, + override val maxVerticalScroll: Int +) : DesktopPdfLayoutScrollPrediction + +internal data class DesktopPdfSpreadLayoutPrediction( + val pageRootOffsets: Map, + override val maxHorizontalScroll: Int, + override val maxVerticalScroll: Int +) : DesktopPdfLayoutScrollPrediction + +internal data class DesktopPdfCachedPageRender( + val render: DesktopPdfPageRender, + val scale: Float +) + +internal data class DesktopPdfNavigationZoomSnapshot( + val zoom: Float, + val horizontalScroll: Int, + val verticalScroll: Int +) + +internal fun desktopPdfNavigationZoomSnapshot( + preview: DesktopPdfZoomPreview?, + currentHorizontalScroll: Int, + currentVerticalScroll: Int +): DesktopPdfNavigationZoomSnapshot? { + val activePreview = preview ?: return null + val baseZoom = activePreview.baseZoom.takeIf { it.isFinite() && it > 0f } ?: return null + val targetZoom = activePreview.zoom.takeIf { it.isFinite() && it > 0f } ?: return null + val anchor = activePreview.anchor + return DesktopPdfNavigationZoomSnapshot( + zoom = targetZoom, + horizontalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(currentHorizontalScroll, it.x, baseZoom, targetZoom) + } ?: currentHorizontalScroll.coerceAtLeast(0), + verticalScroll = anchor?.let { + desktopPdfAnchoredScrollTarget(currentVerticalScroll, it.y, baseZoom, targetZoom) + } ?: currentVerticalScroll.coerceAtLeast(0) + ) +} + +internal fun desktopPdfZoomPreviewMatchesScale( + preview: DesktopPdfZoomPreview, + scale: Float +): Boolean { + return abs(preview.baseZoom - scale) <= DesktopPdfZoomPreviewTolerance || + abs(preview.zoom - scale) <= DesktopPdfZoomPreviewTolerance +} + +internal fun desktopPdfReachableScrollDelta( + currentScroll: Int?, + maxScroll: Int?, + requestedDelta: Int +): Int { + if (currentScroll == null || maxScroll == null) return requestedDelta + val safeMax = maxScroll.coerceAtLeast(0) + val safeCurrent = currentScroll.coerceIn(0, safeMax) + val targetScroll = (safeCurrent + requestedDelta).coerceIn(0, safeMax) + return targetScroll - safeCurrent +} + +internal fun desktopPdfReachableScrollDelta( + requestedDelta: IntOffset, + scrollBounds: DesktopPdfZoomScrollBounds? +): IntOffset { + if (scrollBounds == null) return requestedDelta + return IntOffset( + x = desktopPdfReachableScrollDelta( + currentScroll = scrollBounds.currentHorizontalScroll, + maxScroll = scrollBounds.maxHorizontalScroll, + requestedDelta = requestedDelta.x + ), + y = desktopPdfReachableScrollDelta( + currentScroll = scrollBounds.currentVerticalScroll, + maxScroll = scrollBounds.maxVerticalScroll, + requestedDelta = requestedDelta.y + ) + ) +} + +internal fun desktopPdfZoomScrollBoundsWithCommitTargets( + preview: DesktopPdfZoomPreview?, + currentHorizontalScroll: Int, + maxHorizontalScroll: Int, + currentVerticalScroll: Int? = null, + maxVerticalScroll: Int? = null +): DesktopPdfZoomScrollBounds { + return DesktopPdfZoomScrollBounds( + currentHorizontalScroll = currentHorizontalScroll, + maxHorizontalScroll = maxOf(maxHorizontalScroll, preview?.commitTargetHorizontalScroll ?: 0), + currentVerticalScroll = currentVerticalScroll, + maxVerticalScroll = maxVerticalScroll?.let { + maxOf(it, preview?.commitTargetVerticalScroll ?: 0) + } + ) +} + +internal fun desktopPdfSinglePageLayoutPrediction( + viewportRootOffset: Offset, + viewportSize: IntSize, + pageCanvasSize: IntSize, + horizontalScroll: Int, + verticalScroll: Int, + paddingPx: Float +): DesktopPdfSinglePageLayoutPrediction? { + if (viewportSize.width <= 0 || viewportSize.height <= 0) return null + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null + if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null + if (!paddingPx.isFinite() || paddingPx < 0f) return null + val viewportWidth = viewportSize.width.toFloat() + val viewportHeight = viewportSize.height.toFloat() + val pageWidth = pageCanvasSize.width.toFloat() + val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) + val maxHorizontalScroll = (pageWidth + paddingPx * 2f - viewportWidth) + .roundToInt() + .coerceAtLeast(0) + val maxVerticalScroll = (pageCanvasSize.height.toFloat() + paddingPx * 2f - viewportHeight) + .roundToInt() + .coerceAtLeast(0) + val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) + val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) + val pageX = if (pageWidth <= contentWidth) { + ((viewportWidth - pageWidth) / 2f) - safeHorizontalScroll.toFloat() + } else { + paddingPx - safeHorizontalScroll.toFloat() + } + val pageY = paddingPx - safeVerticalScroll.toFloat() + return DesktopPdfSinglePageLayoutPrediction( + rootOffset = Offset( + x = viewportRootOffset.x + pageX, + y = viewportRootOffset.y + pageY + ), + maxHorizontalScroll = maxHorizontalScroll, + maxVerticalScroll = maxVerticalScroll + ) +} + +internal fun desktopPdfSpreadLayoutPrediction( + viewportRootOffset: Offset, + viewportSize: IntSize, + visiblePageIndices: List, + pageCanvasSizes: Map, + horizontalScroll: Int, + verticalScroll: Int, + paddingPx: Float, + pageGapPx: Float +): DesktopPdfSpreadLayoutPrediction? { + if (viewportSize.width <= 0 || viewportSize.height <= 0) return null + if (visiblePageIndices.isEmpty()) return null + if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null + if (!paddingPx.isFinite() || paddingPx < 0f) return null + if (!pageGapPx.isFinite() || pageGapPx < 0f) return null + val pageSizes = visiblePageIndices.map { pageIndex -> + val pageSize = pageCanvasSizes[pageIndex] ?: return null + if (pageSize.width <= 0 || pageSize.height <= 0) return null + pageSize + } + val viewportWidth = viewportSize.width.toFloat() + val viewportHeight = viewportSize.height.toFloat() + val rowWidth = pageSizes.sumOf { it.width }.toFloat() + + (pageGapPx * (pageSizes.size - 1).coerceAtLeast(0)) + val rowHeight = pageSizes.maxOf { it.height }.toFloat() + val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f) + val maxHorizontalScroll = (rowWidth + paddingPx * 2f - viewportWidth) + .roundToInt() + .coerceAtLeast(0) + val maxVerticalScroll = (rowHeight + paddingPx * 2f - viewportHeight) + .roundToInt() + .coerceAtLeast(0) + val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll) + val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll) + val rowX = if (rowWidth <= contentWidth) { + ((viewportWidth - rowWidth) / 2f) - safeHorizontalScroll.toFloat() + } else { + paddingPx - safeHorizontalScroll.toFloat() + } + val rowY = paddingPx - safeVerticalScroll.toFloat() + var pageX = viewportRootOffset.x + rowX + val pageY = viewportRootOffset.y + rowY + val roots = visiblePageIndices.mapIndexed { index, pageIndex -> + val root = Offset(pageX, pageY) + pageX += pageSizes[index].width.toFloat() + pageGapPx + pageIndex to root + }.toMap() + return DesktopPdfSpreadLayoutPrediction( + pageRootOffsets = roots, + maxHorizontalScroll = maxHorizontalScroll, + maxVerticalScroll = maxVerticalScroll + ) +} + +internal fun desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset: Offset, + oldPageRootOffset: Offset?, + currentAnchorPageRootOffset: Offset, + anchor: Offset?, + oldZoom: Float, + newZoom: Float, + currentZoom: Float, + scrollBounds: DesktopPdfZoomScrollBounds? = null +): Offset? { + if (oldPageRootOffset == null || anchor == null) return null + if (abs(currentZoom - newZoom) > DesktopPdfZoomPreviewTolerance) return null + val pageDelta = desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = viewportRootOffset, + oldPageRootOffset = oldPageRootOffset, + currentPageRootOffset = currentAnchorPageRootOffset, + anchor = anchor, + oldZoom = oldZoom, + newZoom = newZoom + ) ?: return null + val reachableDelta = desktopPdfReachableScrollDelta(pageDelta, scrollBounds) + return Offset( + x = if (reachableDelta.x == 0) 0f else -reachableDelta.x.toFloat(), + y = if (reachableDelta.y == 0) 0f else -reachableDelta.y.toFloat() + ) +} + +internal fun desktopPdfZoomPreviewPivotFraction( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + pageCanvasSize: IntSize +): Offset? { + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + val pageAnchor = viewportRootOffset + anchor - pageRootOffset + if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null + return Offset( + x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f), + y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f) + ) +} + +internal fun desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchor: Offset, + previewScale: Float +): Offset? { + if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null + if (!previewScale.isFinite() || previewScale <= 0f) return null + val rootAnchor = viewportRootOffset + anchor + if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null + return Offset( + x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f), + y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f) + ) +} + +internal fun Modifier.desktopPdfZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset, + pageCanvasSize: IntSize, + commitPageRootOffset: Offset? = null, + scrollBounds: DesktopPdfZoomScrollBounds? = null +): Modifier { + val activePreview = preview ?: return this + if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + val commitTranslation = desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = activePreview.viewportRootOffset, + oldPageRootOffset = activePreview.pageRootOffset, + currentAnchorPageRootOffset = commitPageRootOffset ?: pageRootOffset, + anchor = activePreview.anchor, + oldZoom = activePreview.baseZoom, + newZoom = activePreview.zoom, + currentZoom = currentZoom, + scrollBounds = scrollBounds + ) + if ( + !previewScale.isFinite() || + (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) + ) { + return this + } + logPdfZoomSettle { + "preview_layer seq=${activePreview.diagnosticSequence} kind=page currentZoom=${currentZoom.formatLogFloat()} " + + "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + + "pageRoot=${pageRootOffset.formatLogOffset()} commitRoot=${commitPageRootOffset.formatLogOffset()} " + + "commit=${commitTranslation.formatLogOffset()} " + + "h=${scrollBounds?.currentHorizontalScroll ?: "none"}/${scrollBounds?.maxHorizontalScroll ?: "none"} " + + "v=${scrollBounds?.currentVerticalScroll ?: "none"}/${scrollBounds?.maxVerticalScroll ?: "none"}" + } + val transformOrigin = activePreview.anchor?.let { anchor -> + desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + pageCanvasSize = pageCanvasSize + )?.let { pivot -> + TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y) + } ?: TransformOrigin.Center + } ?: TransformOrigin.Center + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + translationX = commitTranslation?.x ?: 0f + translationY = commitTranslation?.y ?: 0f + this.transformOrigin = transformOrigin + } +} + +internal fun Modifier.desktopPdfDocumentZoomPreviewLayer( + preview: DesktopPdfZoomPreview?, + currentZoom: Float, + viewportRootOffset: Offset, + pageRootOffset: Offset, + anchorPageRootOffset: Offset? = null, + scrollBounds: DesktopPdfZoomScrollBounds? = null +): Modifier { + val activePreview = preview ?: return this + if (!currentZoom.isFinite() || currentZoom <= 0f) return this + if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this + val previewScale = activePreview.zoom / currentZoom + val commitTranslation = desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = activePreview.viewportRootOffset, + oldPageRootOffset = activePreview.pageRootOffset, + currentAnchorPageRootOffset = anchorPageRootOffset ?: pageRootOffset, + anchor = activePreview.anchor, + oldZoom = activePreview.baseZoom, + newZoom = activePreview.zoom, + currentZoom = currentZoom, + scrollBounds = scrollBounds + ) + if ( + !previewScale.isFinite() || + (abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null) + ) { + return this + } + logPdfZoomSettle { + "preview_layer seq=${activePreview.diagnosticSequence} kind=document currentZoom=${currentZoom.formatLogFloat()} " + + "previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " + + "pageRoot=${pageRootOffset.formatLogOffset()} anchorRoot=${(anchorPageRootOffset ?: pageRootOffset).formatLogOffset()} " + + "commit=${commitTranslation.formatLogOffset()} h=${scrollBounds?.currentHorizontalScroll ?: "none"}/" + + "${scrollBounds?.maxHorizontalScroll ?: "none"} v=${scrollBounds?.currentVerticalScroll ?: "none"}/" + + "${scrollBounds?.maxVerticalScroll ?: "none"}" + } + val translation = activePreview.anchor?.let { anchor -> + desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = viewportRootOffset, + pageRootOffset = pageRootOffset, + anchor = anchor, + previewScale = previewScale + ) + } ?: Offset.Zero + return graphicsLayer { + scaleX = previewScale + scaleY = previewScale + translationX = translation.x + (commitTranslation?.x ?: 0f) + translationY = translation.y + (commitTranslation?.y ?: 0f) + transformOrigin = TransformOrigin(0f, 0f) + } +} + +@Composable +internal fun Modifier.desktopPdfZoomGestures( + currentZoom: Float, + zoomSpec: PdfZoomSpec, + onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit +): Modifier { + val latestZoom by rememberUpdatedState(currentZoom) + val latestOnZoomChanged by rememberUpdatedState(onZoomChanged) + return this.pointerInput(zoomSpec) { + var gestureZoom = latestZoom + var appliedGestureZoom = latestZoom + var lastZoomEventAt = 0L + var lastAppliedZoomAt = 0L + fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) { + if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) { + gestureZoom = latestZoom + appliedGestureZoom = latestZoom + lastAppliedZoomAt = 0L + } + val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor) + gestureZoom = newZoom + lastZoomEventAt = eventTime + val shouldApplyNow = lastAppliedZoomAt == 0L || + eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis || + newZoom == zoomSpec.min || + newZoom == zoomSpec.max + if (shouldApplyNow && newZoom != appliedGestureZoom) { + latestOnZoomChanged(appliedGestureZoom, newZoom, anchor) + appliedGestureZoom = newZoom + lastAppliedZoomAt = eventTime + } + } + + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L + if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) { + val scrollDelta = event.changes.fold(Offset.Zero) { total, change -> + total + change.scrollDelta + } + val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x + val factor = desktopPdfScrollZoomFactor(zoomDelta) + if (abs(factor - 1f) > 0.0001f) { + applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position) + event.changes.forEach { it.consume() } + } + continue + } + + val pressedPointers = event.changes.count { it.pressed } + if (pressedPointers > 1) { + val zoomChange = event.calculateZoom() + if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) { + val centroid = event.calculateCentroid(useCurrent = false) + val anchor = if (centroid == Offset.Unspecified) { + event.changes.firstOrNull { it.pressed }?.position + } else { + centroid + } + applyZoomFactor(zoomChange, eventTime, anchor) + } + event.changes.forEach { it.consume() } + } + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt new file mode 100644 index 0000000..f71a86d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfium.kt @@ -0,0 +1,2420 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.PdfTocEntry +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.pdf.PdfiumAnnotationSubtype +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationExportMapper +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfEmbeddedAnnotationThreads +import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlightAnnotationExport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfInkAnnotationExport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfIndexedPage +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowImageElement +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowPage +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowPageElement +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextElement +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextLine +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReflowTextSpan +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchIndex +import org.dueattendant149.bookreader.shared.pdf.SharedPdfSearchResult +import org.dueattendant149.bookreader.shared.pdf.pdfInkAppearancePoints +import com.sun.jna.Callback +import com.sun.jna.Library +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.ptr.PointerByReference +import java.io.ByteArrayOutputStream +import java.awt.image.BufferedImage +import java.io.File +import java.io.FileOutputStream +import java.nio.ByteOrder +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.imageio.ImageIO +import kotlin.math.roundToInt + +data class DesktopPdfDocument( + val path: String, + val title: String, + val pageCount: Int, + val pageSizes: List, + internal val handleId: Long, + val formatLabel: String = "PDF", + val toc: List = emptyList(), + private val initialEmbeddedAnnotations: List = emptyList() +) { + var embeddedAnnotations: List by mutableStateOf(initialEmbeddedAnnotations) + private set + + private val textPageCache = LinkedHashMap() + private val searchIndex = SharedPdfSearchIndex(pageCount) + + fun replaceEmbeddedAnnotations(annotations: List) { + embeddedAnnotations = annotations + } + + fun textPageData(pageIndex: Int): DesktopPdfTextPageData { + if (pageIndex !in 0 until pageCount) return DesktopPdfTextPageData() + val cached = synchronized(textPageCache) { textPageCache[pageIndex] } + if (cached != null) return cached + val loaded = DesktopPdfium.loadTextPageData(this, pageIndex) + return cacheTextPageData(pageIndex, loaded) + } + + fun cacheTextPageData(pageIndex: Int, data: DesktopPdfTextPageData): DesktopPdfTextPageData { + if (pageIndex !in 0 until pageCount) return data + synchronized(textPageCache) { + textPageCache[pageIndex] = data + } + cacheSearchTextPage(pageIndex, data.text) + return data + } + + fun cacheSearchTextPage(pageIndex: Int, text: String) { + if (pageIndex !in 0 until pageCount) return + synchronized(searchIndex) { + searchIndex.putPage(pageIndex, text) + } + } + + fun isSearchTextPageIndexed(pageIndex: Int): Boolean { + return synchronized(searchIndex) { searchIndex.hasPage(pageIndex) } + } + + fun indexedSearchTextPageCount(): Int { + return synchronized(searchIndex) { searchIndex.indexedPageCount } + } + + fun indexedSearchPages(): List { + return synchronized(searchIndex) { searchIndex.indexedPages() } + } + + fun searchIndexed(query: String): List { + return synchronized(searchIndex) { searchIndex.search(query) } + } + + fun close() { + DesktopPdfium.closeDocument(this) + } +} + +data class DesktopPdfPageSize( + val width: Float, + val height: Float +) + +data class DesktopPdfPageRender( + val image: ImageBitmap, + val width: Int, + val height: Int +) + +data class DesktopPdfMetadata( + val title: String? = null, + val author: String? = null, + val description: String? = null +) + +internal val DesktopPdfZoomSpec = PdfZoomSpec( + max = 8.0f, + maxRenderPixels = 64_000_000 +) + +data class DesktopPdfTextChar( + val index: Int, + val char: Char, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) { + val hasBounds: Boolean + get() = right > left && bottom > top +} + +data class DesktopPdfTextRect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) + +data class DesktopPdfLinkTarget( + val uri: String? = null, + val destPageIndex: Int? = null +) + +data class DesktopPdfTextPageData( + val text: String = "", + val chars: List = emptyList() +) + +internal class DesktopPdfPasswordException(fileName: String) : IllegalStateException( + "A password is required or the supplied password is incorrect for $fileName." +) + +internal fun Throwable.isDesktopPdfPasswordException(): Boolean { + return this is DesktopPdfPasswordException || cause?.isDesktopPdfPasswordException() == true +} + +object DesktopPdfium { + private const val FPDF_ANNOT = 0x01 + private const val FPDF_LCD_TEXT = 0x02 + private const val FPDF_RENDER_NO_SMOOTHTEXT = 0x1000 + private const val FPDF_BITMAP_BGRA = 4 + private const val FPDF_ANNOT_COLOR = 0 + private const val FPDF_ANNOT_TEXT = 1 + private const val FPDF_ANNOT_HIGHLIGHT = 9 + private const val FPDF_ANNOT_INK = 15 + private const val FPDF_ANNOT_FLAG_PRINT = 1 shl 2 + private const val FPDF_NO_INCREMENTAL = 1L shl 1 + private const val FPDF_PAGEOBJ_IMAGE = 3 + private const val FPDF_TEXT_FONT_FLAG_ITALIC = 64 + + private val textUrlRegex = Regex("""\b(?:https?://|www\.)[^\s<>"']+""", RegexOption.IGNORE_CASE) + private val pdfiumDll: File by lazy(::resolvePdfiumDll) + private val zoomSpec = DesktopPdfZoomSpec + private val api: PdfiumLibrary by lazy { + require(pdfiumDll.exists()) { + missingPdfiumLibraryMessage(pdfiumDll) + } + Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java) + } + + private var initialized = false + private var nextHandleId = 0L + private val openDocuments = LinkedHashMap() + private val openComicDocuments = LinkedHashMap() + private val openPptxDocuments = LinkedHashMap() + + fun isAvailable(): Boolean = pdfiumDll.exists() + + private fun loadDocument(file: File, password: String?): DesktopOpenPdfDocument { + val pathHasNonAscii = file.absolutePath.any { it.code > 0x7F } + logPdfiumOpen( + "open_start path=\"${file.absolutePath}\" exists=${file.exists()} " + + "canRead=${file.canRead()} size=${runCatching { file.length() }.getOrDefault(-1L)} " + + "nonAsciiPath=$pathHasNonAscii dll=\"${pdfiumDll.absolutePath}\"" + ) + val pathError = if (pathHasNonAscii) { + logPdfiumOpen("path_load_skipped reason=non_ascii_path path=\"${file.absolutePath}\"") + null + } else { + val pathDocument = api.FPDF_LoadDocument(file.absolutePath, password) + if (pathDocument != null) { + logPdfiumOpen("path_load_success path=\"${file.absolutePath}\"") + return DesktopOpenPdfDocument(pointer = pathDocument, password = password) + } + + api.FPDF_GetLastError().also { errorCode -> + logPdfiumOpen( + "path_load_failed code=$errorCode message=\"${pdfiumLoadErrorMessage(errorCode)}\" " + + "path=\"${file.absolutePath}\"" + ) + } + } + + val bytes = runCatching { file.readBytes() } + .onFailure { throwable -> + logPdfiumOpen("read_bytes_failed path=\"${file.absolutePath}\" error=\"${throwable.message.orEmpty()}\"") + } + .getOrNull() + if (bytes != null && bytes.size > 0) { + logPdfiumOpen("memory_load_start bytes=${bytes.size} path=\"${file.absolutePath}\"") + val memory = Memory(bytes.size.toLong()) + memory.write(0, bytes, 0, bytes.size) + val memoryDocument = api.FPDF_LoadMemDocument(memory, bytes.size, password) + if (memoryDocument != null) { + logPdfiumOpen("memory_load_success bytes=${bytes.size} path=\"${file.absolutePath}\"") + return DesktopOpenPdfDocument(pointer = memoryDocument, backingMemory = memory, password = password) + } + val memoryError = api.FPDF_GetLastError() + logPdfiumOpen( + "memory_load_failed code=$memoryError message=\"${pdfiumLoadErrorMessage(memoryError)}\" " + + "bytes=${bytes.size} path=\"${file.absolutePath}\"" + ) + if (memoryError == 4 || pathError == 4) { + throw DesktopPdfPasswordException(file.name) + } + val pathMessage = pathError?.let { "path load: ${pdfiumLoadErrorMessage(it)}" } + ?: "path load skipped for non-ASCII path" + error( + "Pdfium could not open ${file.name}. ${pdfiumLoadErrorMessage(memoryError)} " + + "($pathMessage)." + ) + } + + logPdfiumOpen("memory_load_skipped reason=empty_or_unreadable path=\"${file.absolutePath}\"") + val pathMessage = pathError?.let(::pdfiumLoadErrorMessage) ?: "path load skipped for non-ASCII path" + if (pathError == 4) { + throw DesktopPdfPasswordException(file.name) + } + error("Pdfium could not open ${file.name}. $pathMessage") + } + + @Synchronized + fun load(file: File, password: String? = null, loadEmbeddedAnnotations: Boolean = true): DesktopPdfDocument { + initLibrary() + val startedAt = System.currentTimeMillis() + val loadedDocument = loadDocument(file, password) + val document = loadedDocument.pointer + val handleId = nextDocumentHandleId() + closeDocument(file.absolutePath) + openDocuments[handleId] = loadedDocument.copy(path = file.absolutePath, password = password) + + try { + val pageCount = api.FPDF_GetPageCount(document) + logPdfiumOpen("metadata_loaded pageCount=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") + val pageSizes = (0 until pageCount).map { pageIndex -> + pageSizeByIndex(document, pageIndex) + ?: loadPage(document, pageIndex).usePointer { page -> + DesktopPdfPageSize( + width = api.FPDF_GetPageWidthF(page), + height = api.FPDF_GetPageHeightF(page) + ) + } + } + logPdfiumOpen("page_sizes_loaded pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") + + val metadata = extractDocumentMetadata(document) + logPdfiumOpen("text_index_deferred pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}") + val toc = extractTableOfContents(document, pageCount) + logPdfiumOpen("toc_extracted entries=${toc.size} elapsedMs=${System.currentTimeMillis() - startedAt}") + val embeddedAnnotations = if (loadEmbeddedAnnotations) { + extractEmbeddedAnnotations(document, pageSizes).also { annotations -> + logPdfiumOpen( + "embedded_annotations_extracted count=${annotations.size} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + } + } else { + logPdfiumOpen("embedded_annotations_deferred elapsedMs=${System.currentTimeMillis() - startedAt}") + emptyList() + } + + val result = DesktopPdfDocument( + path = file.absolutePath, + title = metadata.title ?: file.nameWithoutExtension, + pageCount = pageCount, + pageSizes = pageSizes, + toc = toc, + handleId = handleId, + initialEmbeddedAnnotations = embeddedAnnotations + ) + logPdfiumOpen("open_complete elapsedMs=${System.currentTimeMillis() - startedAt}") + return result + } catch (throwable: Throwable) { + openDocuments.remove(handleId) + api.FPDF_CloseDocument(document) + throw throwable + } + } + + @Synchronized + fun loadComic(file: File, type: FileType): DesktopPdfDocument { + val startedAt = System.currentTimeMillis() + val comic = DesktopComicArchive.load(file, type) + val handleId = nextDocumentHandleId() + closeDocument(file.absolutePath) + openComicDocuments[handleId] = comic + logPdfiumOpen( + "comic_open_complete type=${type.name} pages=${comic.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return DesktopPdfDocument( + path = file.absolutePath, + title = comic.title, + pageCount = comic.pageCount, + pageSizes = comic.pageSizes, + formatLabel = type.name, + handleId = handleId + ) + } + + @Synchronized + fun loadPptx(file: File): DesktopPdfDocument { + val startedAt = System.currentTimeMillis() + val pptx = DesktopPptxDocuments.load(file) + val handleId = nextDocumentHandleId() + closeDocument(file.absolutePath) + openPptxDocuments[handleId] = pptx + logPdfiumOpen( + "pptx_open_complete pages=${pptx.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return DesktopPdfDocument( + path = file.absolutePath, + title = pptx.title, + pageCount = pptx.pageCount, + pageSizes = pptx.pageSizes, + formatLabel = "PPTX", + handleId = handleId + ) + } + + @Synchronized + fun loadOpdsStream( + path: String, + title: String, + reference: OpdsStreamReference, + catalog: OpdsCatalog? + ): DesktopPdfDocument { + val startedAt = System.currentTimeMillis() + val comic = DesktopComicArchive.loadOpdsStream(path, title, reference, catalog) + val handleId = nextDocumentHandleId() + closeDocument(path) + openComicDocuments[handleId] = comic + logPdfiumOpen( + "opds_stream_open_complete pages=${comic.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return DesktopPdfDocument( + path = path, + title = title, + pageCount = comic.pageCount, + pageSizes = comic.pageSizes, + formatLabel = "OPDS", + handleId = handleId + ) + } + + fun loadEmbeddedAnnotations(document: DesktopPdfDocument): List { + if (synchronized(this) { + openComicDocuments.containsKey(document.handleId) || openPptxDocuments.containsKey(document.handleId) + }) return emptyList() + val startedAt = System.currentTimeMillis() + val annotations = mutableListOf() + for ((pageIndex, pageSize) in document.pageSizes.withIndex()) { + val pageAnnotations = synchronized(this) { + if (openComicDocuments.containsKey(document.handleId) || openPptxDocuments.containsKey(document.handleId)) { + return annotations + } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: return annotations + extractEmbeddedAnnotationsForPage(nativeDocument, pageIndex, pageSize) + } + annotations += pageAnnotations + } + logPdfiumOpen( + "embedded_annotations_loaded_async count=${annotations.size} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return annotations + } + + @Synchronized + fun extractMetadata(file: File, password: String? = null): DesktopPdfMetadata { + initLibrary() + val loadedDocument = loadDocument(file, password) + return try { + extractDocumentMetadata(loadedDocument.pointer) + } finally { + api.FPDF_CloseDocument(loadedDocument.pointer) + } + } + + @Synchronized + fun exportAnnotatedPdf( + document: DesktopPdfDocument, + destination: File, + annotations: List, + richTextPageLayouts: List = emptyList() + ) { + initLibrary() + require(document.formatLabel == "PDF") { "Only PDF files can be exported with PDF annotations." } + val source = File(document.path) + require(source.isFile) { "The original PDF is not available as a local file." } + val activeDocument = openDocuments[document.handleId] + ?: error("PDF document is not open.") + val exportDocument = loadDocument(source, activeDocument.password) + val rasterOverlays = buildDesktopPdfRasterOverlays( + annotations = annotations, + richTextPageLayouts = richTextPageLayouts, + pageSizes = document.pageSizes + ) + val exportPayload = SharedPdfAnnotationExportMapper.build( + annotations = annotations, + resolveHighlightBounds = resolver@ { annotation -> + val startIndex = annotation.rangeStartIndex ?: return@resolver emptyList() + val endIndex = annotation.rangeEndIndex ?: return@resolver emptyList() + val pageSize = document.pageSizes.getOrNull(annotation.pageIndex) ?: return@resolver emptyList() + if (endIndex < startIndex) return@resolver emptyList() + textRectsForRange( + document = document, + pageIndex = annotation.pageIndex, + startIndex = startIndex, + endIndex = endIndex, + viewportWidth = pageSize.width.roundToInt().coerceAtLeast(1), + viewportHeight = pageSize.height.roundToInt().coerceAtLeast(1) + ).map { it.toPdfPageBounds() } + .filter { it.right > it.left && it.bottom > it.top } + .mergePdfBoundsByLine() + } + ) + val rasterResources = mutableListOf() + try { + val nativeDocument = exportDocument.pointer + val pageCount = api.FPDF_GetPageCount(nativeDocument) + var insertedAny = false + exportPayload.inkAnnotations.forEach { annotation -> + insertedAny = insertInkAnnotation(nativeDocument, pageCount, annotation) || insertedAny + } + exportPayload.highlightAnnotations.forEach { annotation -> + insertedAny = insertHighlightAnnotation(nativeDocument, pageCount, annotation) || insertedAny + } + rasterOverlays.forEach { overlay -> + insertedAny = insertRasterOverlay(nativeDocument, pageCount, overlay, rasterResources) || insertedAny + } + + if (!insertedAny) { + destination.parentFile?.mkdirs() + source.copyTo(destination, overwrite = true) + return + } + savePdfDocument(nativeDocument, destination) + } finally { + rasterResources.forEach { resource -> + runCatching { api.FPDFBitmap_Destroy(resource.bitmap) } + } + api.FPDF_CloseDocument(exportDocument.pointer) + } + } + + @Synchronized + fun closeDocument(path: String) { + val pdfHandleIds = openDocuments + .filterValues { it.path == path } + .keys + .toList() + val comicHandleIds = openComicDocuments + .filterValues { it.path == path } + .keys + .toList() + val pptxHandleIds = openPptxDocuments + .filterValues { it.path == path } + .keys + .toList() + pdfHandleIds.forEach(::closePdfDocumentHandle) + comicHandleIds.forEach(::closeComicDocumentHandle) + pptxHandleIds.forEach(::closePptxDocumentHandle) + } + + @Synchronized + fun closeDocument(document: DesktopPdfDocument) { + closePdfDocumentHandle(document.handleId) + closeComicDocumentHandle(document.handleId) + closePptxDocumentHandle(document.handleId) + } + + private fun nextDocumentHandleId(): Long { + nextHandleId += 1 + return nextHandleId + } + + private fun closePdfDocumentHandle(handleId: Long) { + openDocuments.remove(handleId)?.let { api.FPDF_CloseDocument(it.pointer) } + } + + private fun closeComicDocumentHandle(handleId: Long) { + openComicDocuments.remove(handleId)?.close() + } + + private fun closePptxDocumentHandle(handleId: Long) { + openPptxDocuments.remove(handleId)?.close() + } + + fun indexSearchPages( + document: DesktopPdfDocument, + onProgress: (indexedPageCount: Int, pageCount: Int) -> Unit = { _, _ -> }, + shouldContinue: () -> Boolean = { true } + ) { + val startedAt = System.currentTimeMillis() + onProgress(document.indexedSearchTextPageCount(), document.pageCount) + for (pageIndex in 0 until document.pageCount) { + if (!shouldContinue()) { + logPdfiumOpen( + "search_index_cancelled pages=${document.indexedSearchTextPageCount()}/${document.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + return + } + val wasIndexed = document.isSearchTextPageIndexed(pageIndex) + if (!wasIndexed) { + val text = loadTextOnlyPage(document, pageIndex) + document.cacheSearchTextPage(pageIndex, text) + } + val indexed = document.indexedSearchTextPageCount() + if (pageIndex == document.pageCount - 1 || (!wasIndexed && indexed % 25 == 0)) { + onProgress(indexed, document.pageCount) + } + } + logPdfiumOpen( + "search_index_complete pages=${document.indexedSearchTextPageCount()}/${document.pageCount} " + + "elapsedMs=${System.currentTimeMillis() - startedAt}" + ) + } + + @Synchronized + fun loadTextOnlyPage(document: DesktopPdfDocument, pageIndex: Int): String { + if (openComicDocuments.containsKey(document.handleId)) return "" + openPptxDocuments[document.handleId]?.let { return it.textOnlyPage(pageIndex) } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: return "" + if (document.pageSizes.getOrNull(pageIndex) == null) return "" + return extractPageText(nativeDocument, pageIndex) + } + + @Synchronized + fun loadTextPageData(document: DesktopPdfDocument, pageIndex: Int): DesktopPdfTextPageData { + if (openComicDocuments.containsKey(document.handleId)) return DesktopPdfTextPageData() + openPptxDocuments[document.handleId]?.let { return it.textPageData(pageIndex) } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: return DesktopPdfTextPageData() + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return DesktopPdfTextPageData() + return extractPageTextData(nativeDocument, pageIndex, pageSize) + } + + @Synchronized + fun loadReflowPage(document: DesktopPdfDocument, pageIndex: Int): SharedPdfReflowPage { + if (document.formatLabel != "PDF") { + return SharedPdfReflowPage(pageNumber = pageIndex + 1, elements = emptyList()) + } + val nativeDocument = openDocuments[document.handleId]?.pointer + ?: return SharedPdfReflowPage(pageNumber = pageIndex + 1, elements = emptyList()) + return extractReflowPage(nativeDocument, pageIndex, pageIndex + 1) + } + + @Synchronized + fun loadReflowEdgeLines(document: DesktopPdfDocument, pageIndex: Int): List { + if (document.formatLabel != "PDF") return emptyList() + val nativeDocument = openDocuments[document.handleId]?.pointer ?: return emptyList() + return extractPageText(nativeDocument, pageIndex) + .split('\n') + .map { it.trim() } + .filter { it.length > 2 } + } + + fun search(document: DesktopPdfDocument, query: String): List { + return document.searchIndexed(query) + } + + @Synchronized + fun linkAt( + document: DesktopPdfDocument, + pageIndex: Int, + normalizedX: Float, + normalizedY: Float, + viewportWidth: Int? = null, + viewportHeight: Int? = null + ): DesktopPdfLinkTarget? { + if (openComicDocuments.containsKey(document.handleId)) return null + openPptxDocuments[document.handleId]?.let { pptx -> + return pptx.linkAt(pageIndex = pageIndex, normalizedX = normalizedX, normalizedY = normalizedY) + } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: run { + logPdfiumLink("hit_test_skipped reason=document_not_open page=${pageIndex + 1}") + return null + } + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: run { + logPdfiumLink("hit_test_skipped reason=invalid_page page=${pageIndex + 1}") + return null + } + val viewport = pageSize.normalizedViewport(viewportWidth, viewportHeight) + logPdfiumLink( + "hit_test_start page=${pageIndex + 1} nx=${normalizedX.formatLogFloat()} ny=${normalizedY.formatLogFloat()} " + + "viewport=${viewport.width}x${viewport.height}" + ) + return runCatching { + loadPage(nativeDocument, pageIndex).usePointer { page -> + val pagePoint = deviceToPagePoint( + page = page, + viewport = viewport, + normalizedX = normalizedX, + normalizedY = normalizedY + ) + logPdfiumLink( + "hit_test_page_point page=${pageIndex + 1} " + + "x=${pagePoint.first.formatLogDouble()} y=${pagePoint.second.formatLogDouble()}" + ) + linkAnnotationAt(nativeDocument, page, pageIndex, pagePoint.first, pagePoint.second) + ?: webLinkAt(page, pageIndex, pagePoint.first, pagePoint.second, pageSize) + ?: textUrlAt(page, pageIndex, pagePoint.first, pagePoint.second, pageSize) + } + }.onFailure { throwable -> + logPdfiumLink("hit_test_failed page=${pageIndex + 1} error=\"${throwable.message.orEmpty().logPreview()}\"") + }.getOrNull() + } + + @Synchronized + fun renderPage( + document: DesktopPdfDocument, + pageIndex: Int, + scale: Float, + renderAnnotations: Boolean = true + ): DesktopPdfPageRender { + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + openComicDocuments[document.handleId]?.let { comic -> + val image = comic.renderPageBufferedImage(pageIndex, safeScale) + return DesktopPdfPageRender( + image = image.toComposeImageBitmap(), + width = image.width, + height = image.height + ) + } + openPptxDocuments[document.handleId]?.let { pptx -> + val image = pptx.renderPageBufferedImage(pageIndex, safeScale) + return DesktopPdfPageRender( + image = image.toComposeImageBitmap(), + width = image.width, + height = image.height + ) + } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: error("PDF document is not open.") + val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1) + val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1) + val stride = width * 4 + val memory = Memory((stride * height).toLong()) + memory.clear(memory.size()) + + val bitmap = api.FPDFBitmap_CreateEx(width, height, FPDF_BITMAP_BGRA, memory, stride) + ?: error("Pdfium could not allocate render bitmap.") + + try { + api.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, -1) + loadPage(nativeDocument, pageIndex).usePointer { page -> + val flags = FPDF_LCD_TEXT or + (if (renderAnnotations) FPDF_ANNOT else FPDF_RENDER_NO_SMOOTHTEXT) + api.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, flags) + } + return DesktopPdfPageRender( + image = memory.toBufferedImage(width, height, stride).toComposeImageBitmap(), + width = width, + height = height + ) + } finally { + api.FPDFBitmap_Destroy(bitmap) + } + } + + @Synchronized + fun renderPageBufferedImage( + document: DesktopPdfDocument, + pageIndex: Int, + scale: Float, + renderAnnotations: Boolean = true + ): BufferedImage { + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.") + val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale) + openComicDocuments[document.handleId]?.let { comic -> + return comic.renderPageBufferedImage(pageIndex, safeScale) + } + openPptxDocuments[document.handleId]?.let { pptx -> + return pptx.renderPageBufferedImage(pageIndex, safeScale) + } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: error("PDF document is not open.") + val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1) + val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1) + val stride = width * 4 + val memory = Memory((stride * height).toLong()) + memory.clear(memory.size()) + + val bitmap = api.FPDFBitmap_CreateEx(width, height, FPDF_BITMAP_BGRA, memory, stride) + ?: error("Pdfium could not allocate render bitmap.") + + try { + api.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, -1) + loadPage(nativeDocument, pageIndex).usePointer { page -> + val flags = FPDF_LCD_TEXT or + (if (renderAnnotations) FPDF_ANNOT else FPDF_RENDER_NO_SMOOTHTEXT) + api.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, flags) + } + return memory.toBufferedImage(width, height, stride) + } finally { + api.FPDFBitmap_Destroy(bitmap) + } + } + + @Synchronized + fun charIndexAt( + document: DesktopPdfDocument, + pageIndex: Int, + normalizedX: Float, + normalizedY: Float, + viewportWidth: Int? = null, + viewportHeight: Int? = null, + tolerance: Float = 0.006f + ): Int? { + openPptxDocuments[document.handleId]?.let { pptx -> + return pptx.charIndexAt(pageIndex, normalizedX, normalizedY, tolerance) + } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: return null + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return null + val viewport = pageSize.normalizedViewport(viewportWidth, viewportHeight) + return runCatching { + loadPage(nativeDocument, pageIndex).usePointer { page -> + val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer null + try { + val pagePoint = deviceToPagePoint( + page = page, + viewport = viewport, + normalizedX = normalizedX, + normalizedY = normalizedY + ) + api.FPDFText_GetCharIndexAtPos( + textPage, + pagePoint.first, + pagePoint.second, + (pageSize.width * tolerance).toDouble(), + (pageSize.height * tolerance).toDouble() + ).takeIf { it >= 0 } + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrNull() + } + + @Synchronized + fun textRectsForRange( + document: DesktopPdfDocument, + pageIndex: Int, + startIndex: Int, + endIndex: Int, + viewportWidth: Int? = null, + viewportHeight: Int? = null + ): List { + openPptxDocuments[document.handleId]?.let { pptx -> + return pptx.textRectsForRange(pageIndex, startIndex, endIndex) + } + val nativeDocument = openDocuments[document.handleId]?.pointer ?: return emptyList() + val pageSize = document.pageSizes.getOrNull(pageIndex) ?: return emptyList() + val viewport = pageSize.normalizedViewport(viewportWidth, viewportHeight) + val first = minOf(startIndex, endIndex).coerceAtLeast(0) + val count = (maxOf(startIndex, endIndex) - first + 1).coerceAtLeast(1) + return runCatching { + loadPage(nativeDocument, pageIndex).usePointer { page -> + val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer emptyList() + try { + val rectCount = api.FPDFText_CountRects(textPage, first, count) + (0 until rectCount).mapNotNull { rectIndex -> + val left = DoubleArray(1) + val top = DoubleArray(1) + val right = DoubleArray(1) + val bottom = DoubleArray(1) + val hasRect = api.FPDFText_GetRect(textPage, rectIndex, left, top, right, bottom) != 0 + if (!hasRect || right[0] <= left[0] || top[0] <= bottom[0]) { + null + } else { + val bounds = pageToNormalizedBounds( + page = page, + pageSize = pageSize, + viewport = viewport, + left = left[0], + top = top[0], + right = right[0], + bottom = bottom[0] + ) + DesktopPdfTextRect( + left = bounds.left, + top = bounds.top, + right = bounds.right, + bottom = bounds.bottom + ) + } + } + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrDefault(emptyList()) + } + + private fun linkAnnotationAt( + document: Pointer, + page: Pointer, + pageIndex: Int, + pageX: Double, + pageY: Double + ): DesktopPdfLinkTarget? { + val link = runCatching { api.FPDFLink_GetLinkAtPoint(page, pageX, pageY) }.getOrNull() + ?: return null + + val action = runCatching { api.FPDFLink_GetAction(link) }.getOrNull() + if (action != null) { + when (val actionType = runCatching { api.FPDFAction_GetType(action) }.getOrDefault(0)) { + 1 -> actionDestinationPage(document, action)?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=goto targetPage=${it + 1}") + return DesktopPdfLinkTarget(destPageIndex = it) + } + 2, 4 -> actionFilePath(action)?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=file uri=\"${it.logPreview()}\"") + return DesktopPdfLinkTarget(uri = it) + } + 3 -> actionUri(document, action)?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=uri uri=\"${it.logPreview()}\"") + return DesktopPdfLinkTarget(uri = it) + } + else -> logPdfiumLink("annotation_hit_unsupported page=${pageIndex + 1} actionType=$actionType") + } + } + + val dest = runCatching { api.FPDFLink_GetDest(document, link) }.getOrNull() + val targetPageIndex = dest?.let { runCatching { api.FPDFDest_GetDestPageIndex(document, it) }.getOrNull() } + return targetPageIndex + ?.takeIf { it >= 0 } + ?.let { + logPdfiumLink("annotation_hit page=${pageIndex + 1} action=dest targetPage=${it + 1}") + DesktopPdfLinkTarget(destPageIndex = it) + } + } + + private fun webLinkAt( + page: Pointer, + pageIndex: Int, + pageX: Double, + pageY: Double, + pageSize: DesktopPdfPageSize + ): DesktopPdfLinkTarget? { + val textPage = api.FPDFText_LoadPage(page) ?: run { + logPdfiumLink("web_link_skipped page=${pageIndex + 1} reason=text_page_unavailable") + return null + } + try { + val linkPage = runCatching { api.FPDFText_LoadWebLinks(textPage) }.getOrNull() + ?: run { + logPdfiumLink("web_link_skipped page=${pageIndex + 1} reason=web_links_unavailable") + return null + } + try { + val count = runCatching { api.FPDFLink_CountWebLinks(linkPage) }.getOrDefault(0) + logPdfiumLink("web_link_scan page=${pageIndex + 1} count=$count") + val toleranceX = pageSize.width.toDouble() * 0.006 + val toleranceY = pageSize.height.toDouble() * 0.006 + for (linkIndex in 0 until count) { + val rectCount = runCatching { api.FPDFLink_CountRects(linkPage, linkIndex) }.getOrDefault(0) + for (rectIndex in 0 until rectCount) { + val left = DoubleArray(1) + val top = DoubleArray(1) + val right = DoubleArray(1) + val bottom = DoubleArray(1) + val hasRect = runCatching { + api.FPDFLink_GetRect(linkPage, linkIndex, rectIndex, left, top, right, bottom) + }.getOrDefault(0) != 0 + if (!hasRect) continue + val minX = minOf(left[0], right[0]) - toleranceX + val maxX = maxOf(left[0], right[0]) + toleranceX + val minY = minOf(top[0], bottom[0]) - toleranceY + val maxY = maxOf(top[0], bottom[0]) + toleranceY + if (pageX in minX..maxX && pageY in minY..maxY) { + webLinkUrl(linkPage, linkIndex)?.let { + val url = it.normalizedDetectedTextUrl() + logPdfiumLink( + "web_link_hit page=${pageIndex + 1} link=$linkIndex rect=$rectIndex " + + "uri=\"${url.logPreview()}\"" + ) + return DesktopPdfLinkTarget(uri = url) + } + } + } + } + logPdfiumLink("web_link_miss page=${pageIndex + 1} count=$count") + } finally { + runCatching { api.FPDFLink_CloseWebLinks(linkPage) } + } + } finally { + api.FPDFText_ClosePage(textPage) + } + return null + } + + private fun textUrlAt( + page: Pointer, + pageIndex: Int, + pageX: Double, + pageY: Double, + pageSize: DesktopPdfPageSize + ): DesktopPdfLinkTarget? { + val textPage = api.FPDFText_LoadPage(page) ?: run { + logPdfiumLink("text_url_skipped page=${pageIndex + 1} reason=text_page_unavailable") + return null + } + try { + val charIndex = runCatching { + api.FPDFText_GetCharIndexAtPos( + textPage, + pageX, + pageY, + pageSize.width.toDouble() * 0.012, + pageSize.height.toDouble() * 0.012 + ) + }.getOrDefault(-1) + if (charIndex < 0) { + logPdfiumLink("text_url_miss page=${pageIndex + 1} reason=no_char") + return null + } + val charCount = api.FPDFText_CountChars(textPage) + if (charCount <= 0) { + logPdfiumLink("text_url_miss page=${pageIndex + 1} reason=no_text charIndex=$charIndex") + return null + } + val text = extractText(textPage, charCount) + val match = textUrlRegex.findAll(text).firstOrNull { result -> + val start = (result.range.first - 2).coerceAtLeast(0) + val end = (result.range.last + 2).coerceAtMost(text.lastIndex) + charIndex in start..end + } + if (match == null) { + logPdfiumLink("text_url_miss page=${pageIndex + 1} reason=no_url_at_char charIndex=$charIndex") + return null + } + val url = match.value.normalizedDetectedTextUrl() + logPdfiumLink( + "text_url_hit page=${pageIndex + 1} charIndex=$charIndex " + + "range=${match.range.first}..${match.range.last} uri=\"${url.logPreview()}\"" + ) + return DesktopPdfLinkTarget(uri = url) + } finally { + api.FPDFText_ClosePage(textPage) + } + } + + private fun actionDestinationPage(document: Pointer, action: Pointer): Int? { + val dest = runCatching { api.FPDFAction_GetDest(document, action) }.getOrNull() ?: return null + return runCatching { api.FPDFDest_GetDestPageIndex(document, dest) } + .getOrNull() + ?.takeIf { it >= 0 } + } + + private fun actionUri(document: Pointer, action: Pointer): String? { + val length = runCatching { api.FPDFAction_GetURIPath(document, action, null, 0) }.getOrDefault(0) + if (length <= 0) return null + val buffer = Memory(length.toLong()) + val written = runCatching { api.FPDFAction_GetURIPath(document, action, buffer, length) }.getOrDefault(0) + return if (written <= 0) null else buffer.getString(0).trimEnd('\u0000').takeIf { it.isNotBlank() } + } + + private fun actionFilePath(action: Pointer): String? { + val length = runCatching { api.FPDFAction_GetFilePath(action, null, 0) }.getOrDefault(0) + if (length <= 0) return null + val buffer = Memory(length.toLong()) + val written = runCatching { api.FPDFAction_GetFilePath(action, buffer, length) }.getOrDefault(0) + return if (written <= 0) null else buffer.getString(0).trimEnd('\u0000').takeIf { it.isNotBlank() } + } + + private fun webLinkUrl(linkPage: Pointer, linkIndex: Int): String? { + val maxChars = 2048 + val buffer = Memory(maxChars * 2L) + val written = runCatching { api.FPDFLink_GetURL(linkPage, linkIndex, buffer, maxChars) }.getOrDefault(0) + return if (written <= 0) { + null + } else { + buffer.getCharArray(0, written.coerceAtMost(maxChars)) + .concatToString() + .trimEnd('\u0000') + .takeIf { it.isNotBlank() } + } + } + + private fun extractPageText(document: Pointer, pageIndex: Int): String { + return runCatching { + loadPage(document, pageIndex).usePointer { page -> + val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer "" + try { + val charCount = api.FPDFText_CountChars(textPage) + extractText(textPage, charCount) + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrDefault("") + } + + private fun extractPageTextData(document: Pointer, pageIndex: Int, pageSize: DesktopPdfPageSize): DesktopPdfTextPageData { + return runCatching { + loadPage(document, pageIndex).usePointer { page -> + val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer DesktopPdfTextPageData() + try { + val charCount = api.FPDFText_CountChars(textPage) + if (charCount <= 0) return@usePointer DesktopPdfTextPageData() + val text = extractText(textPage, charCount) + val chars = (0 until charCount).mapNotNull { index -> + val unicode = api.FPDFText_GetUnicode(textPage, index) + if (unicode <= 0) return@mapNotNull null + val left = DoubleArray(1) + val right = DoubleArray(1) + val bottom = DoubleArray(1) + val top = DoubleArray(1) + val hasBox = api.FPDFText_GetCharBox(textPage, index, left, right, bottom, top) != 0 + if (!hasBox) { + DesktopPdfTextChar(index, unicode.toChar(), 0f, 0f, 0f, 0f) + } else { + val bounds = pageToNormalizedBounds( + page = page, + pageSize = pageSize, + viewport = pageSize.normalizedViewport(), + left = left[0], + top = top[0], + right = right[0], + bottom = bottom[0] + ) + DesktopPdfTextChar( + index = index, + char = unicode.toChar(), + left = bounds.left, + top = bounds.top, + right = bounds.right, + bottom = bounds.bottom + ) + } + } + DesktopPdfTextPageData(text = text, chars = chars) + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrDefault(DesktopPdfTextPageData()) + } + + private fun extractReflowPage(document: Pointer, pageIndex: Int, pageNumber: Int): SharedPdfReflowPage { + return runCatching { + loadPage(document, pageIndex).usePointer { page -> + val imageElements = extractReflowImageElements(page) + val textPage = api.FPDFText_LoadPage(page) + ?: return@usePointer SharedPdfReflowPage( + pageNumber = pageNumber, + elements = imageElements.sortedByDescending { it.yPos } + ) + try { + val charCount = api.FPDFText_CountChars(textPage) + if (charCount <= 0) { + return@usePointer SharedPdfReflowPage( + pageNumber = pageNumber, + elements = imageElements.sortedByDescending { it.yPos } + ) + } + + val rawText = extractText(textPage, charCount) + val actualCount = minOf(charCount, rawText.length) + if (actualCount <= 0) { + return@usePointer SharedPdfReflowPage( + pageNumber = pageNumber, + elements = imageElements.sortedByDescending { it.yPos } + ) + } + + val sizes = reflowFontSizes(textPage, actualCount) + val weights = reflowFontWeights(textPage, actualCount) + val flags = reflowFontFlags(textPage, actualCount) + val charBoxes = reflowCharBoxes(textPage, actualCount) + + val textLines = buildReflowTextLines( + rawText = rawText, + actualCount = actualCount, + sizes = sizes, + weights = weights, + flags = flags, + charBoxes = charBoxes + ) + SharedPdfReflowPage( + pageNumber = pageNumber, + elements = mergeReflowElements(textLines, imageElements) + ) + } finally { + api.FPDFText_ClosePage(textPage) + } + } + }.getOrDefault(SharedPdfReflowPage(pageNumber = pageNumber, elements = emptyList())) + } + + private fun buildReflowTextLines( + rawText: String, + actualCount: Int, + sizes: FloatArray, + weights: IntArray, + flags: IntArray, + charBoxes: FloatArray + ): List { + val textLines = mutableListOf() + val currentSpans = mutableListOf() + val currentSpanBuf = StringBuilder() + var curSize = -1f + var curBold = false + var curItalic = false + var lineBaseline = 0f + + fun commitSpan() { + if (currentSpanBuf.isNotEmpty()) { + currentSpans.add( + SharedPdfReflowTextSpan( + text = currentSpanBuf.toString(), + size = curSize, + isBold = curBold, + isItalic = curItalic + ) + ) + currentSpanBuf.clear() + } + } + + fun commitLine() { + commitSpan() + if (currentSpans.isNotEmpty()) { + val text = currentSpans.joinToString("") { it.text } + if (text.isNotBlank()) { + textLines += SharedPdfReflowTextLine( + spans = currentSpans.toList(), + yPos = lineBaseline, + charCount = text.length + ) + } + currentSpans.clear() + } + lineBaseline = 0f + } + + for (index in 0 until actualCount) { + val char = rawText[index] + if (char.code == 0) continue + + if (char == '\r') { + commitLine() + continue + } + if (char == '\n') { + if (index > 0 && rawText[index - 1] == '\r') continue + commitLine() + continue + } + + val charToProcess = when (char) { + '\u00A0' -> ' ' + '\u00AD' -> '-' + '\t' -> ' ' + else -> char + } + if (char.isDesktopPdfReflowJunk()) continue + + val size = sizes.getOrElse(index) { 12f }.coerceAtLeast(0f) + val isBold = weights.getOrElse(index) { 0 } > 600 + val isItalic = (flags.getOrElse(index) { 0 } and FPDF_TEXT_FONT_FLAG_ITALIC) != 0 + + if (currentSpanBuf.isEmpty() && currentSpans.isEmpty() && !charToProcess.isWhitespace()) { + lineBaseline = if (index * 4 + 1 < charBoxes.size) charBoxes[index * 4 + 1] else 0f + } + + if (currentSpanBuf.isEmpty()) { + curSize = size + curBold = isBold + curItalic = isItalic + currentSpanBuf.append(charToProcess) + } else if (!charToProcess.isWhitespace() && (size != curSize || isBold != curBold || isItalic != curItalic)) { + commitSpan() + curSize = size + curBold = isBold + curItalic = isItalic + currentSpanBuf.append(charToProcess) + } else { + currentSpanBuf.append(charToProcess) + } + } + commitLine() + return textLines + } + + private fun mergeReflowElements( + textLines: List, + imageElements: List + ): List { + val finalElements = mutableListOf() + val sortedImages = imageElements.sortedByDescending { it.yPos } + var imageIndex = 0 + for (line in textLines) { + while (imageIndex < sortedImages.size && sortedImages[imageIndex].yPos >= line.yPos) { + finalElements += sortedImages[imageIndex] + imageIndex += 1 + } + finalElements += SharedPdfReflowTextElement(line) + } + while (imageIndex < sortedImages.size) { + finalElements += sortedImages[imageIndex] + imageIndex += 1 + } + return finalElements + } + + private fun reflowFontSizes(textPage: Pointer, count: Int): FloatArray { + return FloatArray(count) { index -> + runCatching { api.FPDFText_GetFontSize(textPage, index).toFloat() } + .getOrDefault(12f) + } + } + + private fun reflowFontWeights(textPage: Pointer, count: Int): IntArray { + return IntArray(count) { index -> + runCatching { api.FPDFText_GetFontWeight(textPage, index) } + .getOrDefault(0) + } + } + + private fun reflowFontFlags(textPage: Pointer, count: Int): IntArray { + return IntArray(count) { index -> + val flags = IntArray(1) + runCatching { + api.FPDFText_GetFontInfo(textPage, index, null, NativeLong(0), flags) + } + flags[0] + } + } + + private fun reflowCharBoxes(textPage: Pointer, count: Int): FloatArray { + val result = FloatArray(count * 4) + for (index in 0 until count) { + val left = DoubleArray(1) + val right = DoubleArray(1) + val bottom = DoubleArray(1) + val top = DoubleArray(1) + runCatching { + api.FPDFText_GetCharBox(textPage, index, left, right, bottom, top) + } + result[index * 4] = left[0].toFloat() + result[index * 4 + 1] = bottom[0].toFloat() + result[index * 4 + 2] = right[0].toFloat() + result[index * 4 + 3] = top[0].toFloat() + } + return result + } + + private fun extractReflowImageElements(page: Pointer): List { + val objectCount = runCatching { api.FPDFPage_CountObjects(page) }.getOrDefault(0) + if (objectCount <= 0) return emptyList() + + val images = mutableListOf() + for (index in 0 until objectCount) { + val pageObject = runCatching { api.FPDFPage_GetObject(page, index) }.getOrNull() ?: continue + val objectType = runCatching { api.FPDFPageObj_GetType(pageObject) }.getOrDefault(0) + if (objectType != FPDF_PAGEOBJ_IMAGE) continue + + val left = FloatArray(1) + val bottom = FloatArray(1) + val right = FloatArray(1) + val top = FloatArray(1) + val hasBounds = runCatching { + api.FPDFPageObj_GetBounds(pageObject, left, bottom, right, top) + }.getOrDefault(0) != 0 + if (!hasBounds) continue + + val bitmap = runCatching { api.FPDFImageObj_GetBitmap(pageObject) }.getOrNull() ?: continue + try { + val width = runCatching { api.FPDFBitmap_GetWidth(bitmap) }.getOrDefault(0) + val height = runCatching { api.FPDFBitmap_GetHeight(bitmap) }.getOrDefault(0) + val stride = runCatching { api.FPDFBitmap_GetStride(bitmap) }.getOrDefault(0) + val buffer = runCatching { api.FPDFBitmap_GetBuffer(bitmap) }.getOrNull() + if (width > 0 && height > 0 && stride > 0 && buffer != null) { + val image = buffer.toDesktopReflowImage(width, height, stride) + val output = ByteArrayOutputStream() + if (ImageIO.write(image, "jpg", output)) { + images += SharedPdfReflowImageElement( + base64Data = Base64.getEncoder().encodeToString(output.toByteArray()), + width = width, + height = height, + yPos = top[0], + mimeType = "image/jpeg" + ) + } + } + } finally { + runCatching { api.FPDFBitmap_Destroy(bitmap) } + } + } + return images + } + + private fun extractText(textPage: Pointer, charCount: Int): String { + if (charCount <= 0) return "" + val buffer = Memory(((charCount + 1) * 2L)) + val written = api.FPDFText_GetText(textPage, 0, charCount, buffer) + return if (written <= 0) { + "" + } else { + buffer.getCharArray(0, written).concatToString().trimEnd('\u0000') + } + } + + private fun extractDocumentMetadata(document: Pointer): DesktopPdfMetadata { + return DesktopPdfMetadata( + title = documentMetaText(document, "Title").cleanPdfMetadata(), + author = documentMetaText(document, "Author").cleanPdfMetadata(), + description = documentMetaText(document, "Subject").cleanPdfMetadata() + ) + } + + private fun documentMetaText(document: Pointer, tag: String): String { + val lengthBytes = runCatching { api.FPDF_GetMetaText(document, tag, null, 0) }.getOrDefault(0) + if (lengthBytes <= 2) return "" + val buffer = Memory(lengthBytes.toLong()) + val writtenBytes = runCatching { api.FPDF_GetMetaText(document, tag, buffer, lengthBytes) }.getOrDefault(0) + if (writtenBytes <= 2) return "" + return String(buffer.getByteArray(0, writtenBytes), Charsets.UTF_16LE) + .trimEnd('\u0000') + } + + private fun String.cleanPdfMetadata(): String? { + return trim() + .takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } + + private fun extractTableOfContents(document: Pointer, pageCount: Int): List { + val entries = mutableListOf() + + fun visit(parent: Pointer?, level: Int) { + var bookmark = api.FPDFBookmark_GetFirstChild(document, parent) + while (bookmark != null) { + val title = bookmarkTitle(bookmark) + val pageIndex = bookmarkPageIndex(document, bookmark, pageCount) + if (title.isNotBlank() && pageIndex != null) { + entries += PdfTocEntry( + title = title, + pageIndex = pageIndex, + nestLevel = level + ) + } + visit(bookmark, level + 1) + bookmark = api.FPDFBookmark_GetNextSibling(document, bookmark) + } + } + + runCatching { visit(null, 0) } + return entries + } + + private fun bookmarkTitle(bookmark: Pointer): String { + val lengthBytes = api.FPDFBookmark_GetTitle(bookmark, null, 0) + if (lengthBytes <= 2) return "" + val buffer = Memory(lengthBytes.toLong()) + val writtenBytes = api.FPDFBookmark_GetTitle(bookmark, buffer, lengthBytes) + if (writtenBytes <= 2) return "" + return String(buffer.getByteArray(0, writtenBytes), Charsets.UTF_16LE) + .trimEnd('\u0000') + } + + private fun bookmarkPageIndex(document: Pointer, bookmark: Pointer, pageCount: Int): Int? { + val dest = api.FPDFBookmark_GetDest(document, bookmark) ?: return null + return api.FPDFDest_GetDestPageIndex(document, dest) + .takeIf { it in 0 until pageCount } + } + + private fun extractEmbeddedAnnotations( + document: Pointer, + pageSizes: List + ): List { + return pageSizes.flatMapIndexed { pageIndex, pageSize -> + extractEmbeddedAnnotationsForPage(document, pageIndex, pageSize) + } + } + + private fun extractEmbeddedAnnotationsForPage( + document: Pointer, + pageIndex: Int, + pageSize: DesktopPdfPageSize + ): List { + return runCatching { + loadPage(document, pageIndex).usePointer { page -> + val count = api.FPDFPage_GetAnnotCount(page).coerceAtLeast(0) + val rawAnnotations = (0 until count).mapNotNull { index -> + extractEmbeddedAnnotation(page, pageIndex, index, pageSize) + } + SharedPdfEmbeddedAnnotationThreads.group(rawAnnotations) + } + }.getOrDefault(emptyList()) + } + + private fun extractEmbeddedAnnotation( + page: Pointer, + pageIndex: Int, + index: Int, + pageSize: DesktopPdfPageSize + ): SharedPdfEmbeddedAnnotation? { + val annotation = api.FPDFPage_GetAnnot(page, index) ?: return null + try { + val subtype = api.FPDFAnnot_GetSubtype(annotation) + if (subtype == PdfiumAnnotationSubtype.LINK) return null + val bounds = annotationBounds(page, annotation, pageSize) ?: return null + val contents = annotationStringValue(annotation, "Contents") + .ifBlank { annotationStringValue(annotation, "RC") } + val name = annotationStringValue(annotation, "NM") + return SharedPdfEmbeddedAnnotation( + id = "embedded_${pageIndex}_${name.ifBlank { index.toString() }}", + pageIndex = pageIndex, + index = index, + subtype = subtype, + bounds = bounds, + contents = contents, + author = annotationStringValue(annotation, "T"), + name = name, + inReplyTo = annotationReplyToValue(annotation) + ) + } finally { + api.FPDFPage_CloseAnnot(annotation) + } + } + + private fun annotationBounds( + page: Pointer, + annotation: Pointer, + pageSize: DesktopPdfPageSize + ): PdfPageBounds? { + val rect = Memory(16) + if (api.FPDFAnnot_GetRect(annotation, rect) == 0) return null + val left = rect.getFloat(0).toDouble() + val top = rect.getFloat(4).toDouble() + val right = rect.getFloat(8).toDouble() + val bottom = rect.getFloat(12).toDouble() + if (left == right || top == bottom) return null + val normalized = pageToNormalizedBounds( + page = page, + pageSize = pageSize, + left = minOf(left, right), + top = maxOf(top, bottom), + right = maxOf(left, right), + bottom = minOf(top, bottom) + ) + return PdfPageBounds( + left = normalized.left, + top = normalized.top, + right = normalized.right, + bottom = normalized.bottom + ).takeIf { it.right > it.left && it.bottom > it.top } + } + + private fun annotationStringValue(annotation: Pointer, key: String): String { + val lengthBytes = api.FPDFAnnot_GetStringValue(annotation, key, null, 0) + if (lengthBytes <= 2) return "" + val buffer = Memory(lengthBytes.toLong()) + val writtenBytes = api.FPDFAnnot_GetStringValue(annotation, key, buffer, lengthBytes) + if (writtenBytes <= 2) return "" + return String(buffer.getByteArray(0, writtenBytes), Charsets.UTF_16LE) + .trimEnd('\u0000') + .cleanEmbeddedAnnotationText() + } + + private fun annotationReplyToValue(annotation: Pointer): String { + val parent = runCatching { api.FPDFAnnot_GetLinkedAnnot(annotation, "IRT") }.getOrNull() + if (parent != null) { + return try { + annotationStringValue(parent, "NM") + } finally { + runCatching { api.FPDFPage_CloseAnnot(parent) } + } + } + return annotationStringValue(annotation, "IRT") + } + + private fun String.cleanEmbeddedAnnotationText(): String { + return replace(Regex("<[^>]+>"), "") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .trim() + } + + private fun insertInkAnnotation( + document: Pointer, + pageCount: Int, + annotation: SharedPdfInkAnnotationExport + ): Boolean { + if (annotation.pageIndex !in 0 until pageCount || + annotation.points.size < 2 + ) return false + + return runCatching { + loadPage(document, annotation.pageIndex).usePointer { page -> + val pageWidth = api.FPDF_GetPageWidthF(page).takeIf { it > 0f } ?: return@usePointer false + val pageHeight = api.FPDF_GetPageHeightF(page).takeIf { it > 0f } ?: return@usePointer false + val exportPoints = annotation.pdfInkAppearancePoints(pageWidth, pageHeight) + if (exportPoints.size < 2) return@usePointer false + val nativePoints = FsPointF().toArray(exportPoints.size) as Array + var minX = pageWidth + var maxX = 0f + var minY = pageHeight + var maxY = 0f + exportPoints.forEachIndexed { index, point -> + val x = point.x.coerceIn(0f, 1f) * pageWidth + val y = (1f - point.y.coerceIn(0f, 1f)) * pageHeight + nativePoints[index].x = x + nativePoints[index].y = y + nativePoints[index].write() + minX = minOf(minX, x) + maxX = maxOf(maxX, x) + minY = minOf(minY, y) + maxY = maxOf(maxY, y) + } + val strokeWidth = (annotation.strokeWidth * pageWidth).coerceAtLeast(0.25f) + val annot = api.FPDFPage_CreateAnnot(page, FPDF_ANNOT_INK) ?: return@usePointer false + try { + api.FPDFAnnot_SetRect(annot, pdfRect(minX, maxY, maxX, minY, strokeWidth * 1.5f)) + val color = annotation.colorArgb.toPdfiumRgba().let { rgba -> + if (annotation.tool == PdfInkTool.HIGHLIGHTER || annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND) { + rgba.withDefaultAlpha(102) + } else { + rgba + } + } + api.FPDFAnnot_SetColor(annot, FPDF_ANNOT_COLOR, color.r, color.g, color.b, color.a) + api.FPDFAnnot_SetBorder(annot, 0f, 0f, strokeWidth) + runCatching { api.FPDFAnnot_SetFlags(annot, FPDF_ANNOT_FLAG_PRINT) } + setPdfiumAnnotationString(annot, "NM", annotation.id) + annotation.contents.takeIf { it.isNotBlank() }?.let { contents -> + setPdfiumAnnotationString(annot, "Contents", contents) + } + val added = api.FPDFAnnot_AddInkStroke( + annot, + nativePoints.first(), + NativeLong(exportPoints.size.toLong()) + ) >= 0 + runCatching { api.FPDFPage_GenerateContent(page) } + added + } finally { + api.FPDFPage_CloseAnnot(annot) + } + } + }.getOrDefault(false) + } + + private fun insertHighlightAnnotation( + document: Pointer, + pageCount: Int, + annotation: SharedPdfHighlightAnnotationExport + ): Boolean { + if (annotation.pageIndex !in 0 until pageCount) return false + val bounds = annotation.boundsList + if (bounds.isEmpty()) return false + + return runCatching { + loadPage(document, annotation.pageIndex).usePointer { page -> + val pageWidth = api.FPDF_GetPageWidthF(page).takeIf { it > 0f } ?: return@usePointer false + val pageHeight = api.FPDF_GetPageHeightF(page).takeIf { it > 0f } ?: return@usePointer false + val quads = bounds.mapNotNull { bound -> + val left = minOf(bound.left, bound.right).coerceIn(0f, 1f) * pageWidth + val right = maxOf(bound.left, bound.right).coerceIn(0f, 1f) * pageWidth + val top = (1f - minOf(bound.top, bound.bottom).coerceIn(0f, 1f)) * pageHeight + val bottom = (1f - maxOf(bound.top, bound.bottom).coerceIn(0f, 1f)) * pageHeight + if (right <= left || top <= bottom) { + null + } else { + FsQuadPointsF(left, top, right, top, left, bottom, right, bottom) + } + } + if (quads.isEmpty()) return@usePointer false + + val annot = api.FPDFPage_CreateAnnot(page, FPDF_ANNOT_HIGHLIGHT) ?: return@usePointer false + try { + var unionLeft = quads.first().x1 + var unionRight = quads.first().x2 + var unionTop = quads.first().y1 + var unionBottom = quads.first().y3 + var appendedAll = true + quads.forEach { quad -> + unionLeft = minOf(unionLeft, quad.x1, quad.x3) + unionRight = maxOf(unionRight, quad.x2, quad.x4) + unionTop = maxOf(unionTop, quad.y1, quad.y2) + unionBottom = minOf(unionBottom, quad.y3, quad.y4) + quad.write() + appendedAll = api.FPDFAnnot_AppendAttachmentPoints(annot, quad) != 0 && appendedAll + } + api.FPDFAnnot_SetRect(annot, pdfRect(unionLeft, unionTop, unionRight, unionBottom, 1f)) + val color = annotation.colorArgb.toPdfiumRgba().withDefaultAlpha(102) + api.FPDFAnnot_SetColor(annot, FPDF_ANNOT_COLOR, color.r, color.g, color.b, color.a) + runCatching { api.FPDFAnnot_SetFlags(annot, FPDF_ANNOT_FLAG_PRINT) } + setPdfiumAnnotationString(annot, "NM", annotation.id) + annotation.contents.takeIf { it.isNotBlank() }?.let { contents -> + setPdfiumAnnotationString(annot, "Contents", contents) + } + val insertedComments = insertHighlightComments( + page = page, + highlightAnnot = annot, + annotation = annotation, + unionRight = unionRight, + unionTop = unionTop, + pageWidth = pageWidth, + pageHeight = pageHeight + ) + runCatching { api.FPDFPage_GenerateContent(page) } + appendedAll && insertedComments + } finally { + api.FPDFPage_CloseAnnot(annot) + } + } + }.getOrDefault(false) + } + + private fun insertHighlightComments( + page: Pointer, + highlightAnnot: Pointer, + annotation: SharedPdfHighlightAnnotationExport, + unionRight: Float, + unionTop: Float, + pageWidth: Float, + pageHeight: Float + ): Boolean { + if (annotation.comments.isEmpty()) return true + + val commentAnnots = mutableListOf() + val commentAnnotsById = mutableMapOf() + var insertedAll = true + try { + annotation.comments.forEach { comment -> + val commentAnnot = api.FPDFPage_CreateAnnot(page, FPDF_ANNOT_TEXT) + if (commentAnnot == null) { + insertedAll = false + return@forEach + } + commentAnnots += commentAnnot + commentAnnotsById[comment.id] = commentAnnot + + api.FPDFAnnot_SetRect( + commentAnnot, + pdfCommentRect( + anchorRight = unionRight, + anchorTop = unionTop, + pageWidth = pageWidth, + pageHeight = pageHeight, + commentIndex = 0 + ) + ) + val color = annotation.colorArgb.toPdfiumRgba().copy(a = 255) + api.FPDFAnnot_SetColor(commentAnnot, FPDF_ANNOT_COLOR, color.r, color.g, color.b, color.a) + runCatching { api.FPDFAnnot_SetFlags(commentAnnot, FPDF_ANNOT_FLAG_PRINT) } + setPdfiumAnnotationString(commentAnnot, "NM", comment.id) + comment.author.takeIf { it.isNotBlank() }?.let { setPdfiumAnnotationString(commentAnnot, "T", it) } + setPdfiumAnnotationString(commentAnnot, "Contents", comment.contents) + comment.createdAt.toPdfDateString().takeIf { it.isNotBlank() }?.let { + setPdfiumAnnotationString(commentAnnot, "CreationDate", it) + } + val modifiedDate = comment.modifiedAt.toPdfDateString() + .ifBlank { comment.createdAt.toPdfDateString() } + modifiedDate.takeIf { it.isNotBlank() }?.let { setPdfiumAnnotationString(commentAnnot, "M", it) } + + val parent = comment.parentId?.let(commentAnnotsById::get) ?: highlightAnnot + runCatching { api.FPDFAnnot_SetLinkedAnnot(commentAnnot, "IRT", parent) } + } + } finally { + commentAnnots.forEach { commentAnnot -> + runCatching { api.FPDFPage_CloseAnnot(commentAnnot) } + } + } + return insertedAll + } + + private fun insertRasterOverlay( + document: Pointer, + pageCount: Int, + overlay: DesktopPdfRasterOverlay, + resources: MutableList + ): Boolean { + if (overlay.pageIndex !in 0 until pageCount || + overlay.width <= 0 || + overlay.height <= 0 || + overlay.pixels.isEmpty() + ) return false + + return runCatching { + loadPage(document, overlay.pageIndex).usePointer { page -> + val pageWidth = api.FPDF_GetPageWidthF(page).takeIf { it > 0f } ?: return@usePointer false + val pageHeight = api.FPDF_GetPageHeightF(page).takeIf { it > 0f } ?: return@usePointer false + val left = overlay.left.coerceIn(0f, 1f) * pageWidth + val top = (1f - overlay.top.coerceIn(0f, 1f)) * pageHeight + val right = overlay.right.coerceIn(0f, 1f) * pageWidth + val bottom = (1f - overlay.bottom.coerceIn(0f, 1f)) * pageHeight + val rect = pdfRect(left, top, right, bottom, 0f) + val rectWidth = rect.right - rect.left + val rectHeight = rect.top - rect.bottom + if (rectWidth <= 0.5f || rectHeight <= 0.5f) return@usePointer false + + val pixelMemory = Memory(overlay.pixels.size * 4L) + pixelMemory.write(0, overlay.pixels, 0, overlay.pixels.size) + val bitmap = api.FPDFBitmap_CreateEx( + overlay.width, + overlay.height, + FPDF_BITMAP_BGRA, + pixelMemory, + overlay.width * 4 + ) ?: return@usePointer false + val imageObject = api.FPDFPageObj_NewImageObj(document) ?: run { + api.FPDFBitmap_Destroy(bitmap) + return@usePointer false + } + val pages = PointerByReference(page) + val assigned = api.FPDFImageObj_SetBitmap(pages, 1, imageObject, bitmap) != 0 + val positioned = assigned && positionRasterImageObject( + imageObject = imageObject, + width = rectWidth.toDouble(), + height = rectHeight.toDouble(), + left = rect.left.toDouble(), + bottom = rect.bottom.toDouble() + ) + if (!positioned) { + api.FPDFBitmap_Destroy(bitmap) + return@usePointer false + } + api.FPDFPage_InsertObject(page, imageObject) + resources += DesktopPdfRasterResource(bitmap = bitmap, memory = pixelMemory) + api.FPDFPage_GenerateContent(page) != 0 + } + }.getOrDefault(false) + } + + private fun positionRasterImageObject( + imageObject: Pointer, + width: Double, + height: Double, + left: Double, + bottom: Double + ): Boolean { + return runCatching { + api.FPDFImageObj_SetMatrix(imageObject, width, 0.0, 0.0, height, left, bottom) != 0 + }.getOrElse { + runCatching { + api.FPDFPageObj_Transform(imageObject, width, 0.0, 0.0, height, left, bottom) + true + }.getOrDefault(false) + } + } + + private fun savePdfDocument(document: Pointer, destination: File) { + destination.parentFile?.mkdirs() + FileOutputStream(destination).use { output -> + val callback = FpdfWriteBlockCallback { _, data, size -> + if (data == null) { + 0 + } else { + runCatching { + val byteCount = size.toLong().coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + output.write(data.getByteArray(0, byteCount)) + 1 + }.getOrDefault(0) + } + } + val writer = FpdfFileWrite().apply { + version = 1 + writeBlock = callback + write() + } + val saved = api.FPDF_SaveAsCopy(document, writer, NativeLong(FPDF_NO_INCREMENTAL)) + if (saved == 0) { + error("PDFium failed to write annotated PDF.") + } + } + } + + private fun setPdfiumAnnotationString(annotation: Pointer, key: String, value: String) { + val bytes = (value + "\u0000").toByteArray(Charsets.UTF_16LE) + val memory = Memory(bytes.size.toLong()) + memory.write(0, bytes, 0, bytes.size) + api.FPDFAnnot_SetStringValue(annotation, key, memory) + } + + private fun pdfRect(left: Float, top: Float, right: Float, bottom: Float, padding: Float): FsRectF { + return FsRectF( + left = minOf(left, right) - padding, + top = maxOf(top, bottom) + padding, + right = maxOf(left, right) + padding, + bottom = minOf(top, bottom) - padding + ).also { it.write() } + } + + private fun pdfCommentRect( + anchorRight: Float, + anchorTop: Float, + pageWidth: Float, + pageHeight: Float, + commentIndex: Int + ): FsRectF { + val iconSize = minOf(18f, maxOf(10f, pageWidth * 0.03f)) + val left = (anchorRight + 2f).coerceIn(0f, (pageWidth - iconSize).coerceAtLeast(0f)) + var top = anchorTop - commentIndex * (iconSize + 2f) + if (top > pageHeight) top = pageHeight + if (top - iconSize < 0f) top = minOf(pageHeight, iconSize) + return pdfRect(left, top, left + iconSize, top - iconSize, 0f) + } + + private fun Long.toPdfDateString(): String { + if (this <= 0L) return "" + return SimpleDateFormat("'D:'yyyyMMddHHmmss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(this)) + } + + private fun Int.toPdfiumRgba(): PdfiumRgba { + return PdfiumRgba( + r = (this ushr 16) and 0xFF, + g = (this ushr 8) and 0xFF, + b = this and 0xFF, + a = (this ushr 24) and 0xFF + ) + } + + private fun loadPage(document: Pointer, pageIndex: Int): PointerResource { + val page = api.FPDF_LoadPage(document, pageIndex) + ?: error("Pdfium could not open page ${pageIndex + 1}.") + return PointerResource(page, api::FPDF_ClosePage) + } + + private fun pageSizeByIndex(document: Pointer, pageIndex: Int): DesktopPdfPageSize? { + val width = DoubleArray(1) + val height = DoubleArray(1) + val loaded = runCatching { + api.FPDF_GetPageSizeByIndex(document, pageIndex, width, height) + }.getOrDefault(0) + return if (loaded != 0 && width[0] > 0.0 && height[0] > 0.0) { + DesktopPdfPageSize(width[0].toFloat(), height[0].toFloat()) + } else { + null + } + } + + private fun initLibrary() { + if (!initialized) { + api.FPDF_InitLibrary() + initialized = true + } + } + + private fun resolvePdfiumDll(): File { + val overridePath = System.getProperty("reader.pdfium.path") + ?: System.getenv("READER_PDFIUM_PATH") + ?: System.getProperty("reader.pdfium.dll") + ?: System.getenv("READER_PDFIUM_DLL") + if (!overridePath.isNullOrBlank()) { + return File(overridePath).absoluteFile + } + + val platform = currentDesktopPlatform() + val relativePath = desktopPdfiumRelativePath(platform) + val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty) + ?.takeIf { it.isNotBlank() } + ?.let(::File) + resourceDir?.resolve(relativePath)?.absoluteFile?.takeIf { it.exists() }?.let { return it } + + val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile } + .take(6) + .toList() + + return roots + .map { File(it, relativePath).absoluteFile } + .firstOrNull { it.exists() } + ?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile + } + + private fun desktopPdfiumRelativePath(platform: DesktopPlatform): String { + return listOf( + "third_party", + "pdfium", + platform.pdfiumDirectoryName, + platform.pdfiumLibraryDirectoryName, + platform.pdfiumLibraryFileName + ).joinToString(File.separator) + } + + private fun missingPdfiumLibraryMessage(expectedFile: File): String { + val platform = currentDesktopPlatform() + return "Missing Pdfium library for ${platform.os.name.lowercase()}-${platform.architecture.resourceName}. " + + "Expected ${expectedFile.absolutePath}. You can also set reader.pdfium.path or READER_PDFIUM_PATH." + } + + private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN) + val pixels = IntArray(width * height) + for (y in 0 until height) { + buffer.position(y * stride) + for (x in 0 until width) { + val b = buffer.get().toInt() and 0xFF + val g = buffer.get().toInt() and 0xFF + val r = buffer.get().toInt() and 0xFF + val a = buffer.get().toInt() and 0xFF + pixels[y * width + x] = (a shl 24) or (r shl 16) or (g shl 8) or b + } + } + image.setRGB(0, 0, width, height, pixels, 0, width) + return image + } + + private fun Pointer.toDesktopReflowImage(width: Int, height: Int, stride: Int): BufferedImage { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val buffer = getByteBuffer(0, stride.toLong() * height.toLong()).order(ByteOrder.LITTLE_ENDIAN) + val pixels = IntArray(width * height) + val bytesPerPixel = (stride / width.coerceAtLeast(1)).coerceAtLeast(1) + for (y in 0 until height) { + val rowStart = y * stride + for (x in 0 until width) { + val offset = x * bytesPerPixel + val b: Int + val g: Int + val r: Int + val a: Int + if (bytesPerPixel >= 3) { + b = buffer.get(rowStart + offset).toInt() and 0xFF + g = buffer.get(rowStart + offset + 1).toInt() and 0xFF + r = buffer.get(rowStart + offset + 2).toInt() and 0xFF + a = if (bytesPerPixel >= 4) buffer.get(rowStart + offset + 3).toInt() and 0xFF else 255 + } else { + val gray = buffer.get(rowStart + offset).toInt() and 0xFF + b = gray + g = gray + r = gray + a = 255 + } + val alpha = a / 255f + val outR = (r * alpha + 255f * (1f - alpha)).roundToInt().coerceIn(0, 255) + val outG = (g * alpha + 255f * (1f - alpha)).roundToInt().coerceIn(0, 255) + val outB = (b * alpha + 255f * (1f - alpha)).roundToInt().coerceIn(0, 255) + pixels[y * width + x] = (outR shl 16) or (outG shl 8) or outB + } + } + image.setRGB(0, 0, width, height, pixels, 0, width) + return image + } + + private fun Char.isDesktopPdfReflowJunk(): Boolean { + val type = Character.getType(this) + return code == 0xFFFE || + code == 0xFFFF || + code == 0xFFFD || + type == Character.PRIVATE_USE.toInt() || + type == Character.SURROGATE.toInt() || + type == Character.UNASSIGNED.toInt() || + (type == Character.CONTROL.toInt() && code > 31) + } + + private fun pdfiumLoadErrorMessage(errorCode: Int): String { + return when (errorCode) { + 0 -> "No Pdfium error detail was reported." + 1 -> "Pdfium reported an unknown load error." + 2 -> "The file was not found or could not be opened." + 3 -> "The file is not in a PDF format supported by this Pdfium build, or Pdfium detected corruption." + 4 -> "A password is required or the supplied password is incorrect." + 5 -> "The PDF uses an unsupported security scheme." + 6 -> "Pdfium could not load the document page tree." + 7 -> "Pdfium could not load XFA data." + 8 -> "Pdfium could not lay out XFA data." + else -> "Pdfium reported load error code $errorCode." + } + } + + private fun logPdfiumOpen(message: String) { + logDesktopDiagnostic("DesktopPdfiumOpen") { message } + } + + private fun logPdfiumLink(message: String) { + logDesktopDiagnostic("DesktopPdfiumLink") { message } + } + + private fun Float.formatLogFloat(): String { + return String.format("%.3f", this) + } + + private fun Double.formatLogDouble(): String { + return String.format("%.3f", this) + } + + private fun String.logPreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") + } + + private fun String.normalizedDetectedTextUrl(): String { + val cleaned = trim() + .trimEnd('.', ',', ';', ':', ')', ']', '}') + return if (cleaned.startsWith("www.", ignoreCase = true)) { + "https://$cleaned" + } else { + cleaned + } + } + + private data class DesktopOpenPdfDocument( + val pointer: Pointer, + val backingMemory: Memory? = null, + val path: String = "", + val password: String? = null + ) + + private data class DesktopPdfRasterResource( + val bitmap: Pointer, + @Suppress("unused") val memory: Memory + ) + + private data class PdfiumRgba( + val r: Int, + val g: Int, + val b: Int, + val a: Int + ) { + fun withDefaultAlpha(defaultAlpha: Int): PdfiumRgba { + return if (a == 255) copy(a = defaultAlpha.coerceIn(0, 255)) else this + } + } + + @Suppress("MemberVisibilityCanBePrivate") + open class FsRectF() : Structure() { + @JvmField var left: Float = 0f + @JvmField var top: Float = 0f + @JvmField var right: Float = 0f + @JvmField var bottom: Float = 0f + + constructor(left: Float, top: Float, right: Float, bottom: Float) : this() { + this.left = left + this.top = top + this.right = right + this.bottom = bottom + } + + override fun getFieldOrder(): List = listOf("left", "top", "right", "bottom") + } + + @Suppress("MemberVisibilityCanBePrivate") + open class FsPointF() : Structure() { + @JvmField var x: Float = 0f + @JvmField var y: Float = 0f + + override fun getFieldOrder(): List = listOf("x", "y") + } + + @Suppress("MemberVisibilityCanBePrivate") + open class FsQuadPointsF() : Structure() { + @JvmField var x1: Float = 0f + @JvmField var y1: Float = 0f + @JvmField var x2: Float = 0f + @JvmField var y2: Float = 0f + @JvmField var x3: Float = 0f + @JvmField var y3: Float = 0f + @JvmField var x4: Float = 0f + @JvmField var y4: Float = 0f + + constructor( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + x3: Float, + y3: Float, + x4: Float, + y4: Float + ) : this() { + this.x1 = x1 + this.y1 = y1 + this.x2 = x2 + this.y2 = y2 + this.x3 = x3 + this.y3 = y3 + this.x4 = x4 + this.y4 = y4 + } + + override fun getFieldOrder(): List = listOf("x1", "y1", "x2", "y2", "x3", "y3", "x4", "y4") + } + + fun interface FpdfWriteBlockCallback : Callback { + fun invoke(fileWrite: Pointer?, data: Pointer?, size: NativeLong): Int + } + + @Suppress("MemberVisibilityCanBePrivate") + open class FpdfFileWrite : Structure() { + @JvmField var version: Int = 1 + @JvmField var writeBlock: FpdfWriteBlockCallback? = null + + override fun getFieldOrder(): List = listOf("version", "writeBlock") + } + + private class PointerResource( + private val pointer: Pointer, + private val closer: (Pointer) -> Unit + ) { + fun usePointer(block: (Pointer) -> T): T { + try { + return block(pointer) + } finally { + closer(pointer) + } + } + } + + private data class NormalizedViewport( + val width: Int, + val height: Int + ) + + private data class NormalizedBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float + ) + + private fun DesktopPdfPageSize.normalizedViewport(widthOverride: Int? = null, heightOverride: Int? = null): NormalizedViewport { + return NormalizedViewport( + width = widthOverride?.coerceAtLeast(1) ?: width.roundToInt().coerceAtLeast(1), + height = heightOverride?.coerceAtLeast(1) ?: height.roundToInt().coerceAtLeast(1) + ) + } + + private fun pageToNormalizedBounds( + page: Pointer, + pageSize: DesktopPdfPageSize, + viewport: NormalizedViewport = pageSize.normalizedViewport(), + left: Double, + top: Double, + right: Double, + bottom: Double + ): NormalizedBounds { + val topLeft = pageToDevicePoint(page, viewport, left, top) + val bottomRight = pageToDevicePoint(page, viewport, right, bottom) + val deviceLeft = minOf(topLeft.first, bottomRight.first).toFloat() + val deviceRight = maxOf(topLeft.first, bottomRight.first).toFloat() + val deviceTop = minOf(topLeft.second, bottomRight.second).toFloat() + val deviceBottom = maxOf(topLeft.second, bottomRight.second).toFloat() + return NormalizedBounds( + left = (deviceLeft / viewport.width).coerceIn(0f, 1f), + top = (deviceTop / viewport.height).coerceIn(0f, 1f), + right = (deviceRight / viewport.width).coerceIn(0f, 1f), + bottom = (deviceBottom / viewport.height).coerceIn(0f, 1f) + ) + } + + private fun pageToDevicePoint( + page: Pointer, + viewport: NormalizedViewport, + pageX: Double, + pageY: Double + ): Pair { + val deviceX = IntArray(1) + val deviceY = IntArray(1) + api.FPDF_PageToDevice( + page, + 0, + 0, + viewport.width, + viewport.height, + 0, + pageX, + pageY, + deviceX, + deviceY + ) + return deviceX[0] to deviceY[0] + } + + private fun deviceToPagePoint( + page: Pointer, + viewport: NormalizedViewport, + normalizedX: Float, + normalizedY: Float + ): Pair { + val pageX = DoubleArray(1) + val pageY = DoubleArray(1) + api.FPDF_DeviceToPage( + page, + 0, + 0, + viewport.width, + viewport.height, + 0, + (normalizedX.coerceIn(0f, 1f) * viewport.width).roundToInt(), + (normalizedY.coerceIn(0f, 1f) * viewport.height).roundToInt(), + pageX, + pageY + ) + return pageX[0] to pageY[0] + } + + @Suppress("FunctionName") + private interface PdfiumLibrary : Library { + fun FPDF_InitLibrary() + fun FPDF_LoadDocument(filePath: String, password: String?): Pointer? + fun FPDF_LoadMemDocument(dataBuf: Pointer, size: Int, password: String?): Pointer? + fun FPDF_CloseDocument(document: Pointer) + fun FPDF_GetLastError(): Int + fun FPDF_GetMetaText(document: Pointer, tag: String, buffer: Pointer?, buflen: Int): Int + fun FPDF_GetPageCount(document: Pointer): Int + fun FPDF_GetPageSizeByIndex(document: Pointer, pageIndex: Int, width: DoubleArray, height: DoubleArray): Int + fun FPDFBookmark_GetFirstChild(document: Pointer, bookmark: Pointer?): Pointer? + fun FPDFBookmark_GetNextSibling(document: Pointer, bookmark: Pointer): Pointer? + fun FPDFBookmark_GetTitle(bookmark: Pointer, buffer: Pointer?, buflen: Int): Int + fun FPDFBookmark_GetDest(document: Pointer, bookmark: Pointer): Pointer? + fun FPDFDest_GetDestPageIndex(document: Pointer, dest: Pointer): Int + fun FPDFLink_GetLinkAtPoint(page: Pointer, x: Double, y: Double): Pointer? + fun FPDFLink_GetAction(link: Pointer): Pointer? + fun FPDFAction_GetType(action: Pointer): Int + fun FPDFAction_GetURIPath(document: Pointer, action: Pointer, buffer: Pointer?, buflen: Int): Int + fun FPDFLink_GetDest(document: Pointer, link: Pointer): Pointer? + fun FPDFAction_GetDest(document: Pointer, action: Pointer): Pointer? + fun FPDFAction_GetFilePath(action: Pointer, buffer: Pointer?, buflen: Int): Int + fun FPDF_LoadPage(document: Pointer, pageIndex: Int): Pointer? + fun FPDF_ClosePage(page: Pointer) + fun FPDF_GetPageWidthF(page: Pointer): Float + fun FPDF_GetPageHeightF(page: Pointer): Float + fun FPDFPage_CountObjects(page: Pointer): Int + fun FPDFPage_GetObject(page: Pointer, index: Int): Pointer? + fun FPDFPageObj_GetType(pageObject: Pointer): Int + fun FPDFPageObj_GetBounds( + pageObject: Pointer, + left: FloatArray, + bottom: FloatArray, + right: FloatArray, + top: FloatArray + ): Int + fun FPDFPage_GetAnnotCount(page: Pointer): Int + fun FPDFPage_GetAnnot(page: Pointer, index: Int): Pointer? + fun FPDFPage_CreateAnnot(page: Pointer, subtype: Int): Pointer? + fun FPDFPage_CloseAnnot(annotation: Pointer) + fun FPDFAnnot_GetSubtype(annotation: Pointer): Int + fun FPDFAnnot_GetRect(annotation: Pointer, rect: Pointer): Int + fun FPDFAnnot_GetStringValue(annotation: Pointer, key: String, buffer: Pointer?, buflen: Int): Int + fun FPDFAnnot_GetLinkedAnnot(annotation: Pointer, key: String): Pointer? + fun FPDFAnnot_SetRect(annotation: Pointer, rect: FsRectF): Int + fun FPDFAnnot_SetColor(annotation: Pointer, type: Int, r: Int, g: Int, b: Int, a: Int): Int + fun FPDFAnnot_SetBorder(annotation: Pointer, horizontalRadius: Float, verticalRadius: Float, borderWidth: Float): Int + fun FPDFAnnot_SetStringValue(annotation: Pointer, key: String, value: Pointer): Int + fun FPDFAnnot_SetLinkedAnnot(annotation: Pointer, key: String, linkedAnnotation: Pointer): Int + fun FPDFAnnot_AddInkStroke(annotation: Pointer, points: FsPointF, pointCount: NativeLong): Int + fun FPDFAnnot_AppendAttachmentPoints(annotation: Pointer, quadPoints: FsQuadPointsF): Int + fun FPDFAnnot_SetFlags(annotation: Pointer, flags: Int): Int + fun FPDFPage_InsertObject(page: Pointer, pageObject: Pointer) + fun FPDFPageObj_NewImageObj(document: Pointer): Pointer? + fun FPDFImageObj_SetMatrix( + imageObject: Pointer, + a: Double, + b: Double, + c: Double, + d: Double, + e: Double, + f: Double + ): Int + fun FPDFPageObj_Transform( + pageObject: Pointer, + a: Double, + b: Double, + c: Double, + d: Double, + e: Double, + f: Double + ) + fun FPDFImageObj_SetBitmap(pages: PointerByReference, pageCount: Int, imageObject: Pointer, bitmap: Pointer): Int + fun FPDFPage_GenerateContent(page: Pointer): Int + fun FPDF_SaveAsCopy(document: Pointer, writer: FpdfFileWrite, flags: NativeLong): Int + fun FPDFImageObj_GetBitmap(imageObject: Pointer): Pointer? + fun FPDFBitmap_GetWidth(bitmap: Pointer): Int + fun FPDFBitmap_GetHeight(bitmap: Pointer): Int + fun FPDFBitmap_GetStride(bitmap: Pointer): Int + fun FPDFBitmap_GetBuffer(bitmap: Pointer): Pointer? + fun FPDFBitmap_CreateEx(width: Int, height: Int, format: Int, firstScan: Pointer, stride: Int): Pointer? + fun FPDFBitmap_FillRect(bitmap: Pointer, left: Int, top: Int, width: Int, height: Int, color: Int) + fun FPDFBitmap_Destroy(bitmap: Pointer) + fun FPDF_RenderPageBitmap( + bitmap: Pointer, + page: Pointer, + startX: Int, + startY: Int, + sizeX: Int, + sizeY: Int, + rotate: Int, + flags: Int + ) + + fun FPDFText_LoadPage(page: Pointer): Pointer? + fun FPDFText_ClosePage(textPage: Pointer) + fun FPDFText_CountChars(textPage: Pointer): Int + fun FPDFText_GetText(textPage: Pointer, startIndex: Int, count: Int, result: Pointer): Int + fun FPDFText_GetUnicode(textPage: Pointer, index: Int): Int + fun FPDFText_GetFontSize(textPage: Pointer, index: Int): Double + fun FPDFText_GetFontWeight(textPage: Pointer, index: Int): Int + fun FPDFText_GetFontInfo( + textPage: Pointer, + index: Int, + buffer: Pointer?, + buflen: NativeLong, + flags: IntArray + ): NativeLong + fun FPDFText_GetCharBox( + textPage: Pointer, + index: Int, + left: DoubleArray, + right: DoubleArray, + bottom: DoubleArray, + top: DoubleArray + ): Int + fun FPDFText_GetCharIndexAtPos( + textPage: Pointer, + x: Double, + y: Double, + xTolerance: Double, + yTolerance: Double + ): Int + fun FPDFText_CountRects(textPage: Pointer, startIndex: Int, count: Int): Int + fun FPDFText_GetRect( + textPage: Pointer, + rectIndex: Int, + left: DoubleArray, + top: DoubleArray, + right: DoubleArray, + bottom: DoubleArray + ): Int + fun FPDFText_LoadWebLinks(textPage: Pointer): Pointer? + fun FPDFLink_CountWebLinks(linkPage: Pointer): Int + fun FPDFLink_GetURL(linkPage: Pointer, linkIndex: Int, buffer: Pointer, buflen: Int): Int + fun FPDFLink_CountRects(linkPage: Pointer, linkIndex: Int): Int + fun FPDFLink_GetRect( + linkPage: Pointer, + linkIndex: Int, + rectIndex: Int, + left: DoubleArray, + top: DoubleArray, + right: DoubleArray, + bottom: DoubleArray + ): Int + fun FPDFLink_CloseWebLinks(linkPage: Pointer) + fun FPDF_PageToDevice( + page: Pointer, + startX: Int, + startY: Int, + sizeX: Int, + sizeY: Int, + rotate: Int, + pageX: Double, + pageY: Double, + deviceX: IntArray, + deviceY: IntArray + ) + fun FPDF_DeviceToPage( + page: Pointer, + startX: Int, + startY: Int, + sizeX: Int, + sizeY: Int, + rotate: Int, + deviceX: Int, + deviceY: Int, + pageX: DoubleArray, + pageY: DoubleArray + ) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt new file mode 100644 index 0000000..fcb4b6f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPaths.kt @@ -0,0 +1,138 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.util.Locale + +internal enum class DesktopOperatingSystem { + WINDOWS, + LINUX, + MACOS, + OTHER +} + +internal enum class DesktopArchitecture(val resourceName: String) { + X64("x64"), + ARM64("arm64"), + X86("x86"), + OTHER("unknown") +} + +internal data class DesktopPlatform( + val os: DesktopOperatingSystem, + val architecture: DesktopArchitecture +) { + val isLinux: Boolean get() = os == DesktopOperatingSystem.LINUX + val isWindows: Boolean get() = os == DesktopOperatingSystem.WINDOWS + + val pdfiumDirectoryName: String + get() = when (os) { + DesktopOperatingSystem.WINDOWS -> "win-${architecture.resourceName}-v8" + DesktopOperatingSystem.LINUX -> "linux-${architecture.resourceName}-v8" + DesktopOperatingSystem.MACOS -> "mac-${architecture.resourceName}-v8" + DesktopOperatingSystem.OTHER -> "${architecture.resourceName}-v8" + } + + val pdfiumLibraryFileName: String + get() = when (os) { + DesktopOperatingSystem.WINDOWS -> "pdfium.dll" + DesktopOperatingSystem.LINUX -> "libpdfium.so" + DesktopOperatingSystem.MACOS -> "libpdfium.dylib" + DesktopOperatingSystem.OTHER -> "pdfium" + } + + val pdfiumLibraryDirectoryName: String + get() = when (os) { + DesktopOperatingSystem.WINDOWS -> "bin" + DesktopOperatingSystem.LINUX, + DesktopOperatingSystem.MACOS, + DesktopOperatingSystem.OTHER -> "lib" + } +} + +internal fun currentDesktopPlatform( + osName: String = System.getProperty("os.name").orEmpty(), + osArch: String = System.getProperty("os.arch").orEmpty() +): DesktopPlatform { + return DesktopPlatform( + os = desktopOperatingSystem(osName), + architecture = desktopArchitecture(osArch) + ) +} + +internal fun desktopOperatingSystem(osName: String): DesktopOperatingSystem { + val normalized = osName.trim().lowercase(Locale.ROOT) + return when { + normalized.startsWith("windows") -> DesktopOperatingSystem.WINDOWS + normalized == "linux" || normalized.contains("linux") -> DesktopOperatingSystem.LINUX + normalized.startsWith("mac") || normalized.contains("darwin") -> DesktopOperatingSystem.MACOS + else -> DesktopOperatingSystem.OTHER + } +} + +internal fun desktopArchitecture(osArch: String): DesktopArchitecture { + return when (osArch.trim().lowercase(Locale.ROOT)) { + "amd64", "x86_64", "x64" -> DesktopArchitecture.X64 + "aarch64", "arm64" -> DesktopArchitecture.ARM64 + "x86", "i386", "i686" -> DesktopArchitecture.X86 + else -> DesktopArchitecture.OTHER + } +} + +internal fun desktopUserDataRoot( + platform: DesktopPlatform = currentDesktopPlatform(), + env: (String) -> String? = System::getenv, + userHome: String = System.getProperty("user.home").orEmpty() +): File { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") + DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_DATA_HOME", ".local/share", env, userHome), "episteme") + DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme") + DesktopOperatingSystem.OTHER -> File(userHome, ".episteme") + } +} + +internal fun desktopUserConfigRoot( + platform: DesktopPlatform = currentDesktopPlatform(), + env: (String) -> String? = System::getenv, + userHome: String = System.getProperty("user.home").orEmpty() +): File { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") + DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CONFIG_HOME", ".config", env, userHome), "episteme") + DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme") + DesktopOperatingSystem.OTHER -> File(userHome, ".episteme") + } +} + +internal fun desktopUserCacheRoot( + platform: DesktopPlatform = currentDesktopPlatform(), + env: (String) -> String? = System::getenv, + userHome: String = System.getProperty("user.home").orEmpty() +): File { + return when (platform.os) { + DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme") + DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CACHE_HOME", ".cache", env, userHome), "episteme") + DesktopOperatingSystem.MACOS -> File(userHome, "Library/Caches/Episteme") + DesktopOperatingSystem.OTHER -> File(userHome, ".episteme/cache") + } +} + +private fun windowsRoamingBase(env: (String) -> String?, userHome: String): File { + return env("APPDATA") + ?.takeIf { it.isNotBlank() } + ?.let(::File) + ?: File(userHome, "AppData/Roaming") +} + +private fun xdgBase( + envName: String, + fallbackRelativePath: String, + env: (String) -> String?, + userHome: String +): File { + return env(envName) + ?.takeIf { it.isNotBlank() } + ?.takeIf { it.startsWith("/") } + ?.let(::File) + ?: File(userHome, fallbackRelativePath) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt new file mode 100644 index 0000000..14885d2 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocument.kt @@ -0,0 +1,648 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.pptx.SharedPptxCharBox as DesktopPptxCharBox +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeck as DesktopPptxDeck +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeckCache +import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageCrop as DesktopPptxImageCrop +import org.dueattendant149.bookreader.shared.pptx.SharedPptxImageElement as DesktopPptxImageElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxParagraph as DesktopPptxParagraph +import org.dueattendant149.bookreader.shared.pptx.SharedPptxRect as DesktopPptxRect +import org.dueattendant149.bookreader.shared.pptx.SharedPptxShapeElement as DesktopPptxShapeElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxSlide as DesktopPptxSlide +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableCell as DesktopPptxTableCell +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTableElement as DesktopPptxTableElement +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextAlign as DesktopPptxTextAlign +import org.dueattendant149.bookreader.shared.pptx.SharedPptxTextInsets as DesktopPptxTextInsets +import org.dueattendant149.bookreader.shared.pptx.SharedPptxVerticalAnchor as DesktopPptxVerticalAnchor +import java.awt.AlphaComposite +import java.awt.BasicStroke +import java.awt.Color +import java.awt.Font +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.Shape +import java.awt.geom.Ellipse2D +import java.awt.geom.Line2D +import java.awt.geom.Path2D +import java.awt.geom.Rectangle2D +import java.awt.geom.RoundRectangle2D +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.File +import javax.imageio.ImageIO +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +private const val EmuPerPoint = 12_700f +private const val DefaultTextSizePoint = 18f +private const val DefaultTextMarginPoint = 91_440f / EmuPerPoint + +private val PptxWhite = pptxRgb(255, 255, 255) +private val PptxBlack = pptxRgb(0, 0, 0) +private val PptxLightGray = pptxRgb(245, 246, 248) +private val PptxGray = pptxRgb(128, 128, 128) + +internal class DesktopPptxDocument private constructor( + val path: String, + val title: String, + private val deck: DesktopPptxDeck +) { + val pageCount: Int = deck.slides.size + val pageSizes: List = deck.slides.map { slide -> + DesktopPdfPageSize(slide.widthPoint.toFloat(), slide.heightPoint.toFloat()) + } + + fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage { + val slide = slideAt(pageIndex) + return DesktopPptxRenderer.render(slide, scale) + } + + fun textOnlyPage(pageIndex: Int): String { + return deck.slides.getOrNull(pageIndex)?.text.orEmpty() + } + + fun textPageData(pageIndex: Int): DesktopPdfTextPageData { + val slide = deck.slides.getOrNull(pageIndex) ?: return DesktopPdfTextPageData() + return DesktopPdfTextPageData( + text = slide.text, + chars = slide.charBoxes.mapIndexed { index, box -> + DesktopPdfTextChar( + index = index, + char = box.char, + left = (box.bounds.left / slide.widthPoint).coerceIn(0f, 1f), + top = (box.bounds.top / slide.heightPoint).coerceIn(0f, 1f), + right = (box.bounds.right / slide.widthPoint).coerceIn(0f, 1f), + bottom = (box.bounds.bottom / slide.heightPoint).coerceIn(0f, 1f) + ) + } + ) + } + + fun linkAt(pageIndex: Int, normalizedX: Float, normalizedY: Float): DesktopPdfLinkTarget? { + val slide = deck.slides.getOrNull(pageIndex) ?: return null + val pointX = normalizedX.coerceIn(0f, 1f) * slide.widthPoint + val pointY = normalizedY.coerceIn(0f, 1f) * slide.heightPoint + return slide.elements + .asReversed() + .filterIsInstance() + .firstNotNullOfOrNull { shape -> + val link = shape.hyperlink?.takeIf { it.isNotBlank() } ?: return@firstNotNullOfOrNull null + link.takeIf { shape.bounds.rotatedBounds(shape.bounds, shape.rotationDegrees).contains(pointX, pointY) } + } + ?.let { DesktopPdfLinkTarget(uri = it) } + } + + fun charIndexAt(pageIndex: Int, normalizedX: Float, normalizedY: Float, tolerance: Float): Int? { + val slide = deck.slides.getOrNull(pageIndex) ?: return null + val pointX = normalizedX.coerceIn(0f, 1f) * slide.widthPoint + val pointY = normalizedY.coerceIn(0f, 1f) * slide.heightPoint + val toleranceX = slide.widthPoint * tolerance + val toleranceY = slide.heightPoint * tolerance + slide.charBoxes.forEachIndexed { index, box -> + if (box.bounds.expanded(toleranceX, toleranceY).contains(pointX, pointY)) { + return index + } + } + return slide.charBoxes + .mapIndexedNotNull { index, box -> + if (pointY < box.bounds.top - toleranceY || pointY > box.bounds.bottom + toleranceY) { + null + } else { + index to abs(pointX - box.bounds.centerX()) + } + } + .minByOrNull { it.second } + ?.takeIf { it.second <= toleranceX * 3f } + ?.first + } + + fun textRectsForRange(pageIndex: Int, startIndex: Int, endIndex: Int): List { + val slide = deck.slides.getOrNull(pageIndex) ?: return emptyList() + if (slide.charBoxes.isEmpty()) return emptyList() + val first = min(startIndex, endIndex).coerceIn(0, slide.charBoxes.size) + val lastExclusive = (max(startIndex, endIndex) + 1).coerceIn(first, slide.charBoxes.size) + return slide.charBoxes + .subList(first, lastExclusive) + .filterNot { it.char.isWhitespace() } + .groupBy { it.bounds.top.roundToInt() } + .values + .mapNotNull { boxes -> + boxes.fold(null) { acc, box -> + acc?.union(box.bounds) ?: box.bounds + } + } + .map { rect -> + DesktopPdfTextRect( + left = (rect.left / slide.widthPoint).coerceIn(0f, 1f), + top = (rect.top / slide.heightPoint).coerceIn(0f, 1f), + right = (rect.right / slide.widthPoint).coerceIn(0f, 1f), + bottom = (rect.bottom / slide.heightPoint).coerceIn(0f, 1f) + ) + } + } + + fun close() = Unit + + private fun slideAt(pageIndex: Int): DesktopPptxSlide { + return deck.slides.getOrNull(pageIndex) ?: error("Invalid PPTX slide index $pageIndex.") + } + + companion object { + fun load(file: File): DesktopPptxDocument { + require(file.isFile) { "Missing PPTX file: ${file.absolutePath}" } + val deck = DesktopPptxDeckCache.load(file) + return DesktopPptxDocument( + path = file.absolutePath, + title = file.nameWithoutExtension, + deck = deck + ) + } + } +} + +internal object DesktopPptxDocuments { + fun load(file: File): DesktopPptxDocument = DesktopPptxDocument.load(file) +} + +private fun DesktopPptxRect.inset(insets: DesktopPptxTextInsets): DesktopPptxRect { + return DesktopPptxRect( + left = left + insets.left, + top = top + insets.top, + right = right - insets.right, + bottom = bottom - insets.bottom + ) +} + +private fun DesktopPptxRect.toAwtRect(): Rectangle2D.Float { + return Rectangle2D.Float(left, top, width(), height()) +} + +private fun DesktopPptxRect.rotatedBounds(rotationBounds: DesktopPptxRect, rotationDegrees: Float): DesktopPptxRect { + if (rotationDegrees == 0f) return this + val radians = Math.toRadians(rotationDegrees.toDouble()) + val cosValue = cos(radians).toFloat() + val sinValue = sin(radians).toFloat() + val cx = rotationBounds.centerX() + val cy = rotationBounds.centerY() + val points = arrayOf( + left to top, + right to top, + right to bottom, + left to bottom + ).map { (x, y) -> + val dx = x - cx + val dy = y - cy + (cx + dx * cosValue - dy * sinValue) to (cy + dx * sinValue + dy * cosValue) + } + return DesktopPptxRect( + left = points.minOf { it.first }, + top = points.minOf { it.second }, + right = points.maxOf { it.first }, + bottom = points.maxOf { it.second } + ) +} + +private object DesktopPptxDeckCache { + fun load(file: File): DesktopPptxDeck = SharedPptxDeckCache.load(file) +} + +private data class DesktopPptxLaidOutLine( + val text: String, + val x: Float, + val baselineY: Float, + val font: Font, + val color: Int, + val charBoxes: List +) { + fun newlineBounds(): DesktopPptxRect { + val last = charBoxes.lastOrNull()?.bounds + return if (last == null) { + DesktopPptxRect(x, baselineY, x, baselineY) + } else { + DesktopPptxRect(last.right, last.top, last.right, last.bottom) + } + } +} + +private object DesktopPptxTextLayout { + fun layout(shape: DesktopPptxShapeElement): List { + if (!shape.renderText || shape.paragraphs.isEmpty()) return emptyList() + val textBounds = shape.textBounds() + if (textBounds.width() <= 0f || textBounds.height() <= 0f) return emptyList() + val measured = withMeasureGraphics { graphics -> + val paragraphs = shape.paragraphs.mapNotNull { paragraph -> + val text = paragraph.displayText().takeIf { it.isNotBlank() } ?: return@mapNotNull null + val font = paragraph.font(shape) + graphics.font = font + val metrics = graphics.fontMetrics + val lines = text.split('\n').flatMap { rawLine -> + wrapLine(rawLine, textBounds.width()) { value -> metrics.stringWidth(value) } + }.ifEmpty { listOf("") } + PreparedPptxParagraph( + paragraph = paragraph, + lines = lines, + font = font, + color = paragraph.runs.firstOrNull()?.color ?: PptxBlack, + ascent = metrics.ascent.toFloat(), + lineHeight = metrics.height.toFloat().coerceAtLeast(1f) + ) + } + val totalHeight = paragraphs.sumOf { item -> + (item.paragraph.spaceBeforePt + item.lines.size * item.lineHeight + item.paragraph.spaceAfterPt).toDouble() + }.toFloat() + paragraphs to totalHeight + } + val paragraphs = measured.first + if (paragraphs.isEmpty()) return emptyList() + val totalHeight = measured.second + var top = when (shape.verticalAnchor) { + DesktopPptxVerticalAnchor.TOP -> textBounds.top + DesktopPptxVerticalAnchor.MIDDLE -> textBounds.top + ((textBounds.height() - totalHeight) / 2f).coerceAtLeast(0f) + DesktopPptxVerticalAnchor.BOTTOM -> textBounds.bottom - totalHeight.coerceAtMost(textBounds.height()) + } + val lines = mutableListOf() + withMeasureGraphics { graphics -> + paragraphs.forEach { paragraph -> + graphics.font = paragraph.font + val metrics = graphics.fontMetrics + top += paragraph.paragraph.spaceBeforePt + paragraph.lines.forEach { text -> + val textWidth = metrics.stringWidth(text).toFloat() + val x = when (paragraph.paragraph.alignment) { + DesktopPptxTextAlign.START -> textBounds.left + DesktopPptxTextAlign.CENTER -> textBounds.left + ((textBounds.width() - textWidth) / 2f).coerceAtLeast(0f) + DesktopPptxTextAlign.END -> textBounds.right - textWidth + } + val baseline = top + paragraph.ascent + val boxes = text.charBoxes( + x = x, + top = top, + bottom = top + paragraph.lineHeight, + metrics = { char -> metrics.charWidth(char) }, + rotationBounds = shape.bounds, + rotationDegrees = shape.rotationDegrees + ) + lines += DesktopPptxLaidOutLine( + text = text, + x = x, + baselineY = baseline, + font = paragraph.font, + color = paragraph.color, + charBoxes = boxes + ) + top += paragraph.lineHeight + } + top += paragraph.paragraph.spaceAfterPt + } + } + return lines + } + + private fun wrapLine(rawLine: String, maxWidth: Float, measure: (String) -> Int): List { + if (rawLine.isBlank()) return listOf(rawLine) + if (measure(rawLine) <= maxWidth) return listOf(rawLine) + val lines = mutableListOf() + var current = "" + rawLine.split(Regex("(?<=\\s)|(?=\\s)")).forEach { token -> + val candidate = current + token + when { + candidate.isBlank() -> current = candidate + measure(candidate) <= maxWidth || current.isBlank() -> current = candidate + else -> { + lines += current.trimEnd() + current = token.trimStart() + } + } + } + if (current.isNotBlank()) lines += current.trimEnd() + return lines.ifEmpty { listOf(rawLine.take(1)) } + } +} + +private data class PreparedPptxParagraph( + val paragraph: DesktopPptxParagraph, + val lines: List, + val font: Font, + val color: Int, + val ascent: Float, + val lineHeight: Float +) + +private object DesktopPptxRenderer { + fun render(slide: DesktopPptxSlide, scale: Float): BufferedImage { + val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f + val width = (slide.widthPoint * safeScale).roundToInt().coerceAtLeast(1) + val height = (slide.heightPoint * safeScale).roundToInt().coerceAtLeast(1) + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + graphics.enablePptxRenderingHints() + graphics.color = (slide.backgroundColor ?: PptxWhite).toAwtColor() + graphics.fillRect(0, 0, width, height) + graphics.scale( + width.toDouble() / slide.widthPoint.toDouble().coerceAtLeast(1.0), + height.toDouble() / slide.heightPoint.toDouble().coerceAtLeast(1.0) + ) + slide.elements.forEach { element -> + when (element) { + is DesktopPptxShapeElement -> graphics.drawShape(element) + is DesktopPptxImageElement -> graphics.drawImageElement(element) + is DesktopPptxTableElement -> graphics.drawTable(element) + } + } + } finally { + graphics.dispose() + } + return image + } + + private fun Graphics2D.drawShape(shape: DesktopPptxShapeElement) { + withRotation(shape.bounds, shape.rotationDegrees) { + val geometry = shape.geometry() + val fillColor = shape.fillColor + if (shape.preset != "line" && fillColor != null && fillColor.pptxAlpha() > 0) { + paint = fillColor.toAwtColor() + fill(geometry) + } + val lineColor = shape.lineColor + if (lineColor != null && lineColor.pptxAlpha() > 0) { + color = lineColor.toAwtColor() + stroke = BasicStroke(shape.lineWidthPoint.coerceAtLeast(0.25f)) + draw(geometry) + } + drawShapeText(shape) + } + } + + private fun Graphics2D.drawShapeText(shape: DesktopPptxShapeElement) { + if (!shape.renderText) return + val oldClip = clip + clip = shape.textBounds().toAwtRect() + try { + DesktopPptxTextLayout.layout(shape).forEach { line -> + font = line.font + color = line.color.toAwtColor() + drawString(line.text, line.x, line.baselineY) + } + } finally { + clip = oldClip + } + } + + private fun Graphics2D.drawImageElement(image: DesktopPptxImageElement) { + withRotation(image.bounds, image.rotationDegrees) { + val source = ByteArrayInputStream(image.bytes).use { input -> ImageIO.read(input) } + if (source == null) { + drawImagePlaceholder(image.bounds) + return@withRotation + } + val oldComposite = composite + composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, image.opacity.coerceIn(0f, 1f)) + try { + val sourceRect = image.crop.sourceRect(source.width, source.height) + drawImage( + source, + image.bounds.left.roundToInt(), + image.bounds.top.roundToInt(), + image.bounds.right.roundToInt(), + image.bounds.bottom.roundToInt(), + sourceRect.left.roundToInt(), + sourceRect.top.roundToInt(), + sourceRect.right.roundToInt(), + sourceRect.bottom.roundToInt(), + null + ) + } finally { + composite = oldComposite + source.flush() + } + } + } + + private fun Graphics2D.drawTable(table: DesktopPptxTableElement) { + withRotation(table.bounds, table.rotationDegrees) { + layoutTableCells(table).forEach { laidOutCell -> + val rect = laidOutCell.rect + val cell = laidOutCell.cell + cell.fillColor?.takeIf { it.pptxAlpha() > 0 }?.let { + paint = it.toAwtColor() + fill(rect.toAwtRect()) + } + cell.lineColor?.takeIf { it.pptxAlpha() > 0 }?.let { + color = it.toAwtColor() + stroke = BasicStroke(0.5f) + draw(rect.toAwtRect()) + } + drawShapeText(laidOutCell.asShape()) + } + } + } + + private fun Graphics2D.drawImagePlaceholder(bounds: DesktopPptxRect) { + color = PptxLightGray.toAwtColor() + fill(bounds.toAwtRect()) + color = PptxGray.toAwtColor() + stroke = BasicStroke(0.75f) + draw(bounds.toAwtRect()) + } + + private fun Graphics2D.withRotation(bounds: DesktopPptxRect, rotationDegrees: Float, block: Graphics2D.() -> Unit) { + val oldTransform = transform + try { + if (rotationDegrees != 0f) { + rotate(Math.toRadians(rotationDegrees.toDouble()), bounds.centerX().toDouble(), bounds.centerY().toDouble()) + } + block() + } finally { + transform = oldTransform + } + } +} + +private data class LaidOutDesktopPptxTableCell( + val rect: DesktopPptxRect, + val cell: DesktopPptxTableCell +) + +private fun layoutTableCells(table: DesktopPptxTableElement): List { + if (table.rows.isEmpty() || table.bounds.width() <= 0f || table.bounds.height() <= 0f) return emptyList() + val explicitHeight = table.rows + .mapNotNull { it.heightPoint?.takeIf { height -> height > 0f } } + .sumOf { it.toDouble() } + .toFloat() + val missingRows = table.rows.count { row -> + val heightPoint = row.heightPoint + heightPoint == null || heightPoint <= 0f + } + val fallbackHeight = if (missingRows > 0) { + ((table.bounds.height() - explicitHeight).coerceAtLeast(1f)) / missingRows + } else { + table.bounds.height() / table.rows.size + } + val cells = mutableListOf() + var y = table.bounds.top + table.rows.forEach { row -> + val rowHeight = row.heightPoint?.takeIf { it > 0f } ?: fallbackHeight + val explicitWidth = row.cells + .mapNotNull { it.widthPoint?.takeIf { width -> width > 0f } } + .sumOf { it.toDouble() } + .toFloat() + val missingCells = row.cells.count { cell -> + val widthPoint = cell.widthPoint + widthPoint == null || widthPoint <= 0f + } + val fallbackWidth = if (missingCells > 0) { + ((table.bounds.width() - explicitWidth).coerceAtLeast(1f)) / missingCells + } else if (row.cells.isNotEmpty()) { + table.bounds.width() / row.cells.size + } else { + table.bounds.width() + } + var x = table.bounds.left + row.cells.forEach { cell -> + val cellWidth = cell.widthPoint?.takeIf { it > 0f } ?: fallbackWidth + cells += LaidOutDesktopPptxTableCell( + rect = DesktopPptxRect(x, y, x + cellWidth, y + rowHeight), + cell = cell + ) + x += cellWidth + } + y += rowHeight + } + return cells +} + +private fun LaidOutDesktopPptxTableCell.asShape(): DesktopPptxShapeElement { + return DesktopPptxShapeElement( + bounds = rect, + preset = "rect", + fillColor = cell.fillColor, + lineColor = cell.lineColor, + lineWidthPoint = 0.75f, + paragraphs = cell.paragraphs, + hyperlink = null, + placeholderKey = null, + textInsets = cell.textInsets, + verticalAnchor = cell.verticalAnchor + ) +} + +private fun DesktopPptxShapeElement.textBounds(): DesktopPptxRect { + return bounds.inset(textInsets) +} + +private fun DesktopPptxShapeElement.geometry(): Shape { + val shapeBounds = bounds + val rect = shapeBounds.toAwtRect() + return when (preset) { + "line" -> Line2D.Float(shapeBounds.left, shapeBounds.top, shapeBounds.right, shapeBounds.bottom) + "ellipse" -> Ellipse2D.Float(shapeBounds.left, shapeBounds.top, shapeBounds.width(), shapeBounds.height()) + "roundrect", "roundRect" -> RoundRectangle2D.Float( + shapeBounds.left, + shapeBounds.top, + shapeBounds.width(), + shapeBounds.height(), + shapeBounds.width() * 0.16f, + shapeBounds.height() * 0.16f + ) + "triangle" -> Path2D.Float().apply { + moveTo(shapeBounds.centerX(), shapeBounds.top) + lineTo(shapeBounds.right, shapeBounds.bottom) + lineTo(shapeBounds.left, shapeBounds.bottom) + closePath() + } + "diamond" -> Path2D.Float().apply { + moveTo(shapeBounds.centerX(), shapeBounds.top) + lineTo(shapeBounds.right, shapeBounds.centerY()) + lineTo(shapeBounds.centerX(), shapeBounds.bottom) + lineTo(shapeBounds.left, shapeBounds.centerY()) + closePath() + } + else -> rect + } +} + +private fun DesktopPptxParagraph.displayText(): String { + val text = runs.joinToString("") { it.text } + val prefix = bullet?.takeIf { it.isNotBlank() }?.let { "$it " }.orEmpty() + return prefix + text +} + +private fun DesktopPptxParagraph.font(shape: DesktopPptxShapeElement): Font { + val firstRun = runs.firstOrNull() + val style = (if (firstRun?.bold == true) Font.BOLD else Font.PLAIN) or + (if (firstRun?.italic == true) Font.ITALIC else Font.PLAIN) + val family = firstRun?.typeface + ?.takeIf { it.isNotBlank() && !it.startsWith("+") } + ?: Font.SANS_SERIF + val size = ((firstRun?.sizePt ?: DefaultTextSizePoint) * shape.fontScale.coerceIn(0.4f, 2f)) + .roundToInt() + .coerceAtLeast(1) + return Font(family, style, size) +} + +private fun String.charBoxes( + x: Float, + top: Float, + bottom: Float, + metrics: (Char) -> Int, + rotationBounds: DesktopPptxRect, + rotationDegrees: Float +): List { + val boxes = mutableListOf() + var left = x + forEach { char -> + val advance = metrics(char).toFloat().coerceAtLeast(0.5f) + val rect = DesktopPptxRect(left, top, left + advance, bottom) + .rotatedBounds(rotationBounds, rotationDegrees) + boxes += DesktopPptxCharBox(char, rect) + left += advance + } + return boxes +} + +private inline fun withMeasureGraphics(block: (Graphics2D) -> T): T { + val image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + return try { + graphics.enablePptxRenderingHints() + block(graphics) + } finally { + graphics.dispose() + } +} + +private fun Graphics2D.enablePptxRenderingHints() { + setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) + setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) +} + +private fun DesktopPptxImageCrop.sourceRect(width: Int, height: Int): DesktopPptxRect { + val leftPx = (width * left).coerceIn(0f, (width - 1).toFloat()) + val topPx = (height * top).coerceIn(0f, (height - 1).toFloat()) + val rightPx = (width * (1f - right)).coerceIn(leftPx + 1f, width.toFloat()) + val bottomPx = (height * (1f - bottom)).coerceIn(topPx + 1f, height.toFloat()) + return DesktopPptxRect(leftPx, topPx, rightPx, bottomPx) +} + +private fun pptxRgb(red: Int, green: Int, blue: Int): Int = pptxArgb(255, red, green, blue) + +private fun pptxArgb(alpha: Int, red: Int, green: Int, blue: Int): Int { + return ((alpha and 0xFF) shl 24) or + ((red and 0xFF) shl 16) or + ((green and 0xFF) shl 8) or + (blue and 0xFF) +} + +private fun Int.pptxAlpha(): Int = (this ushr 24) and 0xFF +private fun Int.pptxRed(): Int = (this ushr 16) and 0xFF +private fun Int.pptxGreen(): Int = (this ushr 8) and 0xFF +private fun Int.pptxBlue(): Int = this and 0xFF +private fun Int.toAwtColor(): Color = Color(this, true) + diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt new file mode 100644 index 0000000..655ae2f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProScreen.kt @@ -0,0 +1,160 @@ +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.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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.UserData +import org.dueattendant149.bookreader.shared.ui.readerString + +@Composable +internal fun DesktopProScreen( + user: UserData?, + isProUser: Boolean, + credits: Int, + authConfigured: Boolean, + isBusy: Boolean, + statusMessage: String?, + onSignIn: () -> Unit, + onSignOut: () -> Unit, + onRefresh: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 28.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(Icons.Default.Star, contentDescription = null, modifier = Modifier.size(30.dp), tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text(readerString("desktop_account_and_credits", "Account & credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text( + readerString("desktop_pro_sign_in_desc", "Sign in to check your account status on desktop."), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Default.Verified, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(readerString("desktop_account_overview", "Account overview"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + } + if (user == null) { + Text( + if (authConfigured) { + readerString("desktop_no_google_account_connected", "No Google account is connected.") + } else { + readerString("desktop_google_sign_in_not_configured", "Google sign-in is not configured for this desktop build.") + }, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button(onClick = onSignIn, enabled = authConfigured && !isBusy) { + if (isBusy) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.size(8.dp)) + } + Text(readerString("drawer_sign_in", "Sign in with Google")) + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium) + user.email?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + OutlinedButton(onClick = onRefresh, enabled = !isBusy) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(readerString("desktop_refresh", "Refresh")) + } + OutlinedButton(onClick = onSignOut, enabled = !isBusy) { + Text(readerString("drawer_sign_out", "Sign out")) + } + } + } + + HorizontalDivider() + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) { + DesktopAccountValue( + label = readerString("desktop_plan", "Plan"), + value = if (isProUser) { + readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.") + } else { + readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.") + }, + modifier = Modifier.weight(1f) + ) + DesktopAccountValue( + label = readerString("credits_tab", "Credits"), + value = readerString("desktop_credits_available_format", "%1\$d credits available", credits), + modifier = Modifier.weight(1f) + ) + } + + Text( + readerString( + "desktop_pro_purchase_android_desc", + "Pro and credits can only be purchased from the Android app. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI features." + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + statusMessage?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + Spacer(Modifier.height(12.dp)) + } +} + +@Composable +private fun DesktopAccountValue( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt new file mode 100644 index 0000000..e60ad28 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopProfileAvatar.kt @@ -0,0 +1,114 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.foundation.shape.CircleShape +import org.dueattendant149.bookreader.shared.UserData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage + +@Composable +internal fun DesktopProfileAvatar( + user: UserData, + modifier: Modifier = Modifier +) { + val photoUrl = user.photoUrl?.takeIf { it.isNotBlank() } + var bitmap by remember(photoUrl) { mutableStateOf(photoUrl?.let(DesktopProfileAvatarCache::peek)) } + + LaunchedEffect(photoUrl) { + bitmap = if (photoUrl == null) { + null + } else { + withContext(Dispatchers.IO) { + DesktopProfileAvatarCache.load(photoUrl) + } + } + } + + val imageBitmap = bitmap + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier.clip(CircleShape) + ) + } else { + DesktopProfileAvatarFallback(user = user, modifier = modifier) + } +} + +@Composable +private fun DesktopProfileAvatarFallback( + user: UserData, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + val initial = (user.displayName ?: user.email) + ?.trim() + ?.firstOrNull() + ?.uppercase() + if (initial != null) { + Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } else { + Icon(Icons.Default.AccountCircle, contentDescription = null) + } + } + } +} + +private object DesktopProfileAvatarCache { + private const val MaxEntries = 24 + + private val cache = object : LinkedHashMap(MaxEntries, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MaxEntries + } + } + + fun peek(url: String): ImageBitmap? { + return synchronized(cache) { cache[url] } + } + + fun load(url: String): ImageBitmap? { + peek(url)?.let { return it } + val bitmap = runCatching { + DesktopOpdsHttp.fetchBytes(url, catalog = null).toImageBitmap() + }.getOrNull() ?: return null + + synchronized(cache) { + cache[url] = bitmap + } + return bitmap + } + + private fun ByteArray.toImageBitmap(): ImageBitmap? { + return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull() + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt new file mode 100644 index 0000000..c0e0a73 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaults.kt @@ -0,0 +1,91 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderFeatureSurface +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings + +internal const val DesktopReaderDefaultsVersion = 1 + +internal enum class DesktopReaderSettingsEngine { + TEXT, + PDF +} + +internal val DesktopDefaultTextReaderSettings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE +) + +internal val DesktopDefaultPdfReaderSettings = ReaderSettings( + themeId = "no_theme", + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE +) + +internal fun FileType.desktopReaderSettingsEngine(): DesktopReaderSettingsEngine? { + return when (SharedFileCapabilities.surfaceFor(this, ReaderPlatform.DESKTOP)) { + ReaderFeatureSurface.PDF_VIEWER -> DesktopReaderSettingsEngine.PDF + ReaderFeatureSurface.EPUB_READER, + ReaderFeatureSurface.TEXT_READER -> DesktopReaderSettingsEngine.TEXT + null -> null + } +} + +internal fun BookItem.usesDesktopReaderSettingsEngine(engine: DesktopReaderSettingsEngine): Boolean { + return type.desktopReaderSettingsEngine() == engine +} + +internal fun List.withDesktopReaderEngineSettings( + engine: DesktopReaderSettingsEngine, + settings: ReaderSettings +): List { + return map { book -> + if (book.usesDesktopReaderSettingsEngine(engine)) { + book.copy(readerSettings = settings) + } else { + book + } + } +} + +internal fun SharedReaderScreenState.withDesktopReaderEngineDefaultSettings( + engine: DesktopReaderSettingsEngine, + settings: ReaderSettings +): SharedReaderScreenState { + val engineSettings = if (engine == DesktopReaderSettingsEngine.PDF) { + settings.toDesktopPdfReaderSettings() + } else { + settings + } + return when (engine) { + DesktopReaderSettingsEngine.TEXT -> copy( + readerDefaultSettings = engineSettings, + rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) + ) + DesktopReaderSettingsEngine.PDF -> copy( + pdfReaderDefaultSettings = engineSettings, + rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings) + ) + } +} + +internal fun ReaderSettings.toDesktopPdfDisplayMode(): PdfDisplayMode { + return when (readingMode) { + ReaderReadingMode.PAGINATED -> PdfDisplayMode.PAGINATION + ReaderReadingMode.VERTICAL -> PdfDisplayMode.VERTICAL_SCROLL + } +} + +internal fun PdfDisplayMode.toDesktopReaderReadingMode(): ReaderReadingMode { + return when (this) { + PdfDisplayMode.PAGINATION -> ReaderReadingMode.PAGINATED + PdfDisplayMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt new file mode 100644 index 0000000..8ee7aeb --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDiagnostics.kt @@ -0,0 +1,146 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.dueattendant149.bookreader.shared.ReaderLocator + +private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf" +private const val PdfZoomSettleLogTag = "EpistemePdfZoomSettle" +private const val PdfLinkLogTag = "EpistemePdfLink" +private const val PdfChromeTapLogTag = "EpistemePdfChromeTap" +private const val EpubLinkLogTag = "EpistemeEpubLink" +private const val EpubPaginationLogTag = "EpistemeEpubPagination" +private const val EpubCutoffLogTag = "EpistemeEpubCutoff" +private const val ReaderGapLogTag = "EpistemeReaderGap" +private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG" +private const val EpubHighlightFlowLogTag = "EpistemeEpubHighlightFlow" +private const val DesktopHighlightMapLogTag = "EpistemeDesktopHighlightMap" +private const val DesktopPositionTraceLogTag = "EpistemeDesktopPositionTrace" +private const val DesktopReaderCloseLogTag = "EpistemeDesktopReaderClose" +private const val DesktopNativeWebViewLogTag = "EpistemeNativeWebView" +private const val WebViewLayoutLogTag = "EpistemeWebViewLayout" +private const val ReaderModeSwitchLogTag = "EpistemeReaderModeSwitch" + +internal fun logPdfSelection(message: String) { +} + +internal fun logPdfZoomPerf(message: String) { + logDesktopDiagnostic(PdfZoomPerfLogTag) { message } +} + +internal fun logPdfZoomPerf(message: () -> String) { + logDesktopDiagnostic(PdfZoomPerfLogTag, message) +} + +internal fun logPdfZoomSettle(message: String) { + logDesktopDiagnostic(PdfZoomSettleLogTag) { message } +} + +internal fun logPdfZoomSettle(message: () -> String) { + logDesktopDiagnostic(PdfZoomSettleLogTag, message) +} + +internal fun logPdfLink(message: String) { + logDesktopDiagnostic(PdfLinkLogTag) { message } +} + +internal fun logPdfChromeTap(message: String) { + logDesktopDiagnostic(PdfChromeTapLogTag) { message } +} + +internal fun logPdfChromeTap(message: () -> String) { + logDesktopDiagnostic(PdfChromeTapLogTag, message) +} + +internal fun logEpubLink(message: String) { + logDesktopDiagnostic(EpubLinkLogTag) { message } +} + +internal fun logEpubPagination(message: String) { + logDesktopDiagnostic(EpubPaginationLogTag) { message } +} + +internal fun logEpubCutoff(message: String) { + logDesktopDiagnostic(EpubCutoffLogTag) { message } +} + +internal fun logReaderGap(message: String) { + logDesktopDiagnostic(ReaderGapLogTag) { message } +} + +internal fun logEpubSelectionDebug(message: String) { + logDesktopDiagnostic(EpubSelectionDebugLogTag) { message } +} + +internal fun logEpubHighlightFlow(message: String) { + logDesktopDiagnostic(EpubHighlightFlowLogTag) { message } +} + +internal fun logDesktopHighlightMap(message: String) { + logDesktopDiagnostic(DesktopHighlightMapLogTag) { message } +} + +internal fun logDesktopPositionTrace(message: String) { + logDesktopDiagnostic(DesktopPositionTraceLogTag) { message } +} + +internal fun logDesktopPositionTrace(message: () -> String) { + logDesktopDiagnostic(DesktopPositionTraceLogTag, message) +} + +internal fun logDesktopReaderClose(message: String) { + logDesktopDiagnostic(DesktopReaderCloseLogTag) { message } +} + +internal fun logDesktopWebView2(message: String) { + logDesktopDiagnostic(DesktopNativeWebViewLogTag) { message } +} + +internal fun logWebViewLayoutDiag(message: String) { + logDesktopDiagnostic(WebViewLayoutLogTag) { message } +} + +internal fun logReaderModeSwitch(message: String) { + logDesktopDiagnostic(ReaderModeSwitchLogTag) { message } +} + +internal fun DesktopPdfLinkTarget.formatLogTarget(): String { + return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\"" +} + +internal fun Float.formatLogFloat(): String { + return String.format("%.3f", this) +} + +internal fun Offset?.formatLogOffset(): String { + if (this == null) return "none" + return "${x.formatLogFloat()},${y.formatLogFloat()}" +} + +internal fun IntOffset?.formatLogIntOffset(): String { + if (this == null) return "none" + return "${this.x},${this.y}" +} + +internal fun IntSize.formatLogSize(): String { + return "${width}x${height}" +} + +internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String { + if (this == null) { + return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null" + } + return "${prefix}Index=$index ${prefix}Source=$source " + + "${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " + + "${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}" +} + +internal fun ReaderLocator?.desktopPositionTraceSummary(maxTextLength: Int = 90): String { + if (this == null) return "null" + return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " + + "offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " + + "block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " + + "chapterId=\"${chapterId.orEmpty().logPreview(80)}\" href=\"${href.orEmpty().logPreview(120)}\" " + + "cfi=\"${cfi.orEmpty().logPreview(180)}\" text=\"${textQuote.orEmpty().logPreview(maxTextLength)}\"" +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt new file mode 100644 index 0000000..540e7db --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpenTrace.kt @@ -0,0 +1,29 @@ +package org.dueattendant149.bookreader.desktop + +internal const val DesktopReaderOpenTraceTag = "EpistemeDesktopOpenTrace" + +internal fun logDesktopReaderOpenTrace(message: () -> String) { + logDesktopDiagnostic(DesktopReaderOpenTraceTag, message) +} + +internal fun Long.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { + return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L +} + +internal fun DesktopReaderOpening.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long { + return startedAtNanos.elapsedOpenTraceMs(nowNanos) +} + +internal fun DesktopReaderOpening.openTracePrefix(event: String): String { + return "event=$event requestId=$requestId bookId=\"${bookId.logPreview(80)}\" " + + "title=\"${title.logPreview(120)}\" format=\"$formatLabel\" elapsedMs=${elapsedOpenTraceMs()}" +} + +internal fun DesktopReaderOpenResult.openTraceKind(): String { + return when (this) { + is DesktopReaderOpenResult.Failure -> "failure" + is DesktopReaderOpenResult.PasswordRequired -> "password_required" + is DesktopReaderOpenResult.Pdf -> "pdf" + is DesktopReaderOpenResult.Text -> "text" + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt new file mode 100644 index 0000000..909ac08 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderOpening.kt @@ -0,0 +1,44 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.ui.SharedAppTab + +internal data class DesktopReaderOpening( + val requestId: Long, + val bookId: String, + val title: String, + val formatLabel: String, + val returnTab: SharedAppTab, + val password: String? = null, + val startedAtNanos: Long = System.nanoTime() +) + +internal sealed interface DesktopReaderOpenResult { + val opening: DesktopReaderOpening + val book: BookItem + + data class Pdf( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val document: DesktopPdfDocument + ) : DesktopReaderOpenResult + + data class Text( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val session: ReaderSessionState + ) : DesktopReaderOpenResult + + data class Failure( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val message: String + ) : DesktopReaderOpenResult + + data class PasswordRequired( + override val opening: DesktopReaderOpening, + override val book: BookItem, + val attemptedPassword: Boolean + ) : DesktopReaderOpenResult +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt new file mode 100644 index 0000000..90474f9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderPanels.kt @@ -0,0 +1,450 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiModelOption +import org.dueattendant149.bookreader.shared.ReaderAiModelOptions +import org.dueattendant149.bookreader.shared.ReaderAiResultState +import org.dueattendant149.bookreader.shared.ReaderCloudTtsVoices +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.maskedReaderAiKey +import org.dueattendant149.bookreader.shared.ui.SharedMarkdownText +import org.dueattendant149.bookreader.shared.ui.SharedReaderPopupLayer +import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsReplacementControls +import org.dueattendant149.bookreader.shared.ui.SharedStableOutlinedTextField +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.ui.sharedReaderPopupWidth + +@Composable +internal fun DesktopReaderBottomSheet( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + SharedReaderPopupLayer(onDismiss = onDismiss) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .zIndex(40f) + ) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + val sheetHorizontalPadding = 24.dp + val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp) + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = sheetHorizontalPadding, vertical = 16.dp) + .width(sharedReaderPopupWidth(sheetAvailableWidth)) + .heightIn(max = 560.dp), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(42.dp) + .height(4.dp) + .background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp)) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = readerString("action_close", "Close")) + } + } + HorizontalDivider() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + content() + } + } + } + } + } +} + +@Composable +internal fun DesktopReaderAiResultSheet( + result: ReaderAiResultState, + onDismiss: () -> Unit +) { + DesktopReaderBottomSheet( + title = result.title ?: "AI", + onDismiss = onDismiss + ) { + val errorMessage = result.errorMessage + when { + result.isLoading && result.text.isBlank() -> Text(readerString("desktop_working", "Working..."), color = MaterialTheme.colorScheme.onSurfaceVariant) + errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error) + else -> { + if (result.isLoading) { + Text(readerString("desktop_working", "Working..."), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SharedMarkdownText(result.text) + } + } + } +} + +@Composable +internal fun DesktopAiByokSettingsDialog( + settings: ReaderAiByokSettings, + secureStorageAvailable: Boolean, + onSettingsChange: (ReaderAiByokSettings) -> Unit, + onDismiss: () -> Unit +) { + val sanitized = settings.sanitized() + var selectedProvider by remember { mutableStateOf("gemini") } + var pendingKey by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(readerString("ai_settings_title", "AI keys and models")) }, + text = { + Column( + modifier = Modifier + .heightIn(max = 640.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (!secureStorageAvailable) { + Text( + readerString( + "desktop_secure_key_storage_unavailable", + "Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted." + ), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Text(readerString("ai_settings_saved_keys", "Saved keys"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + DesktopSavedAiKeyRow( + label = readerString("provider_gemini", "Gemini"), + keyValue = sanitized.geminiKey, + onClear = { onSettingsChange(sanitized.copy(geminiKey = "")) } + ) + DesktopSavedAiKeyRow( + label = readerString("provider_groq", "Groq"), + keyValue = sanitized.groqKey, + onClear = { onSettingsChange(sanitized.copy(groqKey = "")) } + ) + + HorizontalDivider() + + Text(readerString("ai_settings_add_or_replace_key", "Add or replace key"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + listOf( + "gemini" to readerString("provider_gemini", "Gemini"), + "groq" to readerString("provider_groq", "Groq") + ).forEach { (provider, label) -> + FilterChip( + selected = selectedProvider == provider, + onClick = { selectedProvider = provider }, + label = { Text(label) } + ) + } + } + SharedStableOutlinedTextField( + value = pendingKey, + onValueChange = { pendingKey = it }, + label = { Text(readerString("label_api_key", "API key")) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + TextButton( + enabled = pendingKey.isNotBlank(), + onClick = { + val trimmed = pendingKey.trim() + val next = when (selectedProvider) { + "gemini" -> sanitized.copy( + geminiKey = trimmed, + ttsModel = sanitized.ttsModel.ifBlank { GEMINI_CLOUD_TTS_MODEL_ID } + ) + "groq" -> sanitized.copy(groqKey = trimmed) + else -> sanitized + } + onSettingsChange(next) + pendingKey = "" + }, + modifier = Modifier.align(Alignment.End) + ) { + Text(readerString("ai_settings_save_key", "Save key")) + } + + HorizontalDivider() + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(readerString("ai_settings_use_one_model", "Use one model for all features"), style = MaterialTheme.typography.titleMedium) + Text( + readerString("ai_settings_use_one_model_desc", "When off, each reader AI feature uses its own selected model."), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = sanitized.useOneModel, + onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) } + ) + } + + if (sanitized.useOneModel) { + DesktopAiModelSelector( + title = readerString("ai_settings_all_features", "All AI features"), + description = readerString("ai_settings_all_features_desc", "Smart dictionary, summaries, and recaps all use this model."), + selectedId = sanitized.modelForAll, + onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) } + ) + } else { + DesktopAiModelSelector( + title = readerString("ai_settings_smart_dictionary", "Smart dictionary"), + description = readerString("ai_settings_smart_dictionary_desc", "Used when defining selected words or phrases."), + selectedId = sanitized.defineModel, + onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) } + ) + DesktopAiModelSelector( + title = readerString("ai_settings_summaries", "Summaries"), + description = readerString("desktop_ai_settings_summaries_desc", "Used for EPUB summaries and PDF page summaries."), + selectedId = sanitized.summarizeModel, + onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) } + ) + DesktopAiModelSelector( + title = readerString("ai_settings_recaps", "Recaps"), + description = readerString("ai_settings_recaps_desc", "Used for story recap generation."), + selectedId = sanitized.recapModel, + onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) } + ) + } + + DesktopAiModelSelector( + title = readerString("credits_cloud_tts_title", "Cloud TTS"), + description = readerString("ai_settings_cloud_tts_desc", "Uses the saved Gemini key. Only %1\$s is supported for now.", GEMINI_CLOUD_TTS_MODEL), + selectedId = sanitized.ttsModel, + options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)), + onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) } + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(readerString("action_done", "Done")) + } + } + ) +} + +@Composable +private fun DesktopSavedAiKeyRow( + label: String, + keyValue: String, + onClear: () -> Unit +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(label, fontWeight = FontWeight.SemiBold) + Text( + keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: readerString("ai_settings_no_key_saved", "No key saved"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) { + Text(readerString("action_clear", "Clear")) + } + } +} + +@Composable +private fun DesktopAiModelSelector( + title: String, + description: String, + selectedId: String, + options: List = ReaderAiModelOptions, + onSelected: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + FilterChip( + selected = selectedId.isBlank(), + onClick = { onSelected("") }, + label = { Text(readerString("ai_settings_no_model_selected", "No model selected")) } + ) + options.forEach { option -> + FilterChip( + selected = selectedId == option.id, + onClick = { onSelected(option.id) }, + label = { Text(option.label) } + ) + } + } + } +} + +@Composable +internal fun DesktopPdfTtsPanel( + extrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + cloudTtsFeatureAvailable: Boolean, + onCloudTtsClearCache: () -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit +) { + val settings = aiByokSettings.sanitized() + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text(readerString("menu_tts_settings", "TTS"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused + if (cloudTtsFeatureAvailable) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + when { + extrasState.cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio") + extrasState.cloudTts.isPaused -> readerString("desktop_paused", "Paused") + extrasState.cloudTts.isPlaying -> readerString("label_reading", "Reading") + settings.isCloudTtsAvailable -> readerString("desktop_cloud_tts_ready", "Cloud TTS ready") + settings.serverBackedReaderAiFeatures -> readerString("desktop_cloud_tts_needs_signed_in_credits", "Cloud TTS needs signed-in credits") + else -> readerString("desktop_cloud_tts_needs_gemini", "Cloud TTS needs Gemini") + }, + fontWeight = FontWeight.SemiBold + ) + extrasState.cloudTts.errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + val statusMessage = extrasState.cloudTts.progress.currentPositionLabel + ?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() } + statusMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), fontWeight = FontWeight.SemiBold) + if (ttsBusy) { + Text( + readerString("desktop_stop_reading_change_voices", "Stop reading to change voices."), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) { + ReaderCloudTtsVoices.forEach { voice -> + FilterChip( + selected = settings.ttsSpeakerId == voice.id, + enabled = !ttsBusy, + onClick = { onCloudTtsVoiceChange(voice.id) }, + label = { + Column { + Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + voice.description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + ) + } + } + } + val cacheSummary = extrasState.cloudTts.cacheSummary + if (cacheSummary.hasCachedAudio) { + Text( + readerString("desktop_cache_format", "Cache: %1\$s", cacheSummary.currentVoiceLabel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (cacheSummary.hasCurrentVoiceCachedAudio) { + TextButton(onClick = onCloudTtsClearCache) { + Text(readerString("desktop_clear_voice_cache", "Clear voice cache")) + } + } + } + } + SharedReaderTtsReplacementControls( + preferences = ttsReplacementPreferences, + bookId = ttsReplacementBookId, + onPreferencesChange = onTtsReplacementPreferencesChange + ) + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt new file mode 100644 index 0000000..e3db9d4 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderScreen.kt @@ -0,0 +1,1088 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.ReaderAction +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderAutoScrollState +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction +import org.dueattendant149.bookreader.shared.ReaderHighlightPalette +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.ReaderToolbarPreferences +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsReadScope +import org.dueattendant149.bookreader.shared.ReaderTtsReplacementPreferences +import org.dueattendant149.bookreader.shared.ReaderTheme +import org.dueattendant149.bookreader.shared.reader.ReaderEngine +import org.dueattendant149.bookreader.shared.reader.ReaderImageReference +import org.dueattendant149.bookreader.shared.reader.ReaderLinkTarget +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec +import org.dueattendant149.bookreader.shared.reader.SharedEpubPaginationCache +import org.dueattendant149.bookreader.shared.reader.SharedMeasuredEpubPaginator +import org.dueattendant149.bookreader.shared.reader.isRightToLeftPaginationEnabled +import org.dueattendant149.bookreader.shared.reader.layoutSignature +import org.dueattendant149.bookreader.shared.reduce +import org.dueattendant149.bookreader.shared.ui.DesktopEpubNativeImage +import org.dueattendant149.bookreader.shared.ui.ReaderContentRenderPlan +import org.dueattendant149.bookreader.shared.ui.SharedNativePaginatedReader +import org.dueattendant149.bookreader.shared.ui.SharedNativeReaderSelectionAction +import org.dueattendant149.bookreader.shared.ui.SharedNativeVerticalReader +import org.dueattendant149.bookreader.shared.ui.SharedReaderScreen +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.awt.EventQueue +import java.awt.Window +import java.awt.event.KeyEvent as AwtKeyEvent + +@Composable +internal fun DesktopReaderScreen( + session: ReaderSessionState, + readerEngine: ReaderEngine, + onSessionChange: (ReaderSessionState) -> Unit, + onReturnToLibrary: (() -> Unit)? = null, + onFullscreenChange: (Boolean) -> Unit = {}, + readerAwtWindow: Window? = null, + toolbarPreferences: ReaderToolbarPreferences, + onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit, + appThemeControls: (@Composable () -> Unit)? = null, + customReaderThemes: List, + onCustomReaderThemesChange: (List) -> Unit, + highlightPalette: ReaderHighlightPalette, + onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit, + ttsReplacementPreferences: ReaderTtsReplacementPreferences, + ttsReplacementBookId: String?, + onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit, + onPickCustomFont: () -> String?, + customFonts: List, + readerExtrasState: ReaderExtrasState, + aiByokSettings: ReaderAiByokSettings, + externalLookupAvailable: Boolean, + cloudTtsControlsAvailable: Boolean, + onExternalLookup: (ReaderExternalLookupAction, String) -> Unit, + onAiAction: (ReaderAiFeature, String) -> Unit, + onAiResultDismiss: () -> Unit, + onCloudTtsToggle: (String, ReaderLocator?) -> Unit, + onCloudTtsStart: (ReaderTtsReadScope, List) -> Unit, + onCloudTtsPauseResume: () -> Unit, + onCloudTtsStop: () -> Unit, + onCloudTtsClearCache: () -> Unit, + onCloudTtsVoiceChange: (String) -> Unit, + onOpenAiHub: (() -> Unit)? = null, + onDownloadReaderImage: (ReaderImageReference) -> Unit, + readerTextureDataUri: (String) -> String?, + readerCustomTextureIds: List, + onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?, + bottomChromeExtraContent: @Composable ColumnScope.() -> Unit = {}, + webViewRuntimeState: DesktopWebViewRuntimeState, + webViewNetworkAccessEnabled: Boolean, + epubPaginationCache: SharedEpubPaginationCache, + epubPaginationCacheGeneration: Int, + useDetachedChromeLayer: Boolean = true, + useDetachedPanelLayer: Boolean = true +) { + val clipboardManager = LocalClipboardManager.current + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val paginationCacheWriteScope = rememberCoroutineScope() + val measuredPaginator = remember( + textMeasurer, + density, + session.reader.settings.fontFamily, + session.reader.settings.customFontPath, + epubPaginationCache, + paginationCacheWriteScope + ) { + SharedMeasuredEpubPaginator( + textMeasurer = textMeasurer, + density = density, + fontFamily = session.reader.settings.toDesktopReaderFontFamily(), + pageCache = epubPaginationCache, + cacheWriteScope = paginationCacheWriteScope + ) + } + LaunchedEffect(session.reader.book.id) { + logDesktopReaderOpenTrace { + "event=desktop_text_reader_screen_composed bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" mode=${session.reader.settings.readingMode} " + + "chapters=${session.reader.book.chapters.size} pages=${session.reader.pages.size} " + + "currentPage=${session.reader.currentPageIndex + 1} " + + "textChars=${session.reader.book.chapters.sumOf { it.plainText.length }} " + + "htmlChars=${session.reader.book.chapters.sumOf { it.htmlContent.length }} " + + "semanticBlocks=${session.reader.book.chapters.sumOf { it.semanticBlocks.size }} " + + "bookmarks=${session.bookmarks.size} highlights=${session.highlights.size}" + } + } + var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) } + val paginationLayoutSignature = session.reader.settings.layoutSignature() + val paginationContentSignature = remember(session.reader.book) { + session.reader.book.desktopPaginationContentSignature() + } + val paginationDensitySignature = DesktopEpubPaginationDensity( + density = density.density, + fontScale = density.fontScale + ) + val measuredPaginationRequest = remember( + session.reader.book.id, + paginationContentSignature, + paginationLayoutSignature, + readerViewport, + paginationDensitySignature, + epubPaginationCacheGeneration + ) { + if (session.reader.settings.readingMode == ReaderReadingMode.PAGINATED && readerViewport.isSpecified) { + DesktopEpubPaginationRequest( + bookId = session.reader.book.id, + chapterSignature = paginationContentSignature, + layoutSignature = paginationLayoutSignature, + viewport = readerViewport, + density = paginationDensitySignature, + cacheGeneration = epubPaginationCacheGeneration + ) + } else { + null + } + } + var completedMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + var completedMeasuredPaginationPages by remember(session.reader.book.id) { + mutableStateOf(emptyList()) + } + var warmMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + var warmMeasuredPaginationPages by remember(session.reader.book.id) { + mutableStateOf(emptyList()) + } + var runningMeasuredPaginationRequest by remember(session.reader.book.id) { + mutableStateOf(null) + } + val measuredPaginationPagesApplied = desktopMeasuredPaginationReady( + request = measuredPaginationRequest, + completedRequest = completedMeasuredPaginationRequest, + currentPages = session.reader.pages, + measuredPages = completedMeasuredPaginationPages + ) + val warmMeasuredPaginationPagesApplied = desktopMeasuredPaginationReady( + request = measuredPaginationRequest, + completedRequest = warmMeasuredPaginationRequest, + currentPages = session.reader.pages, + measuredPages = warmMeasuredPaginationPages + ) + val paginatedLayoutReady = desktopPaginatedLayoutReadyForDisplay( + readingMode = session.reader.settings.readingMode, + measuredPagesApplied = measuredPaginationPagesApplied + ) + val latestSession by rememberUpdatedState(session) + val latestOnSessionChange by rememberUpdatedState(onSessionChange) + var externalLinkDialogUrl by remember { mutableStateOf(null) } + var lastHandledLink by remember { mutableStateOf(null) } + var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) } + val desktopReaderExtrasState = readerExtrasState.copy(autoScroll = ReaderAutoScrollState()) + val currentReaderFullscreen by rememberUpdatedState(isFullscreen) + val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange) + + fun setReaderFullscreen(enabled: Boolean) { + isFullscreen = enabled + onFullscreenChange(enabled) + } + + DesktopExternalLinkDialog( + url = externalLinkDialogUrl, + onDismiss = { externalLinkDialogUrl = null } + ) + + fun handleReaderAwtKeyEvent(event: AwtKeyEvent): Boolean { + val currentSession = latestSession + val action = event.desktopReaderKeyNavigationOrNull( + fullscreen = isFullscreen, + rightToLeftPagination = currentSession.reader.settings.isRightToLeftPaginationEnabled() + ) ?: return false + val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine) + if (nextSession == null) { + if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { + setReaderFullscreen(false) + } + } else { + latestOnSessionChange(nextSession) + } + return true + } + + fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (latestSession.isSearchActive) { + if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) { + setReaderFullscreen(false) + return true + } + return false + } + return handleReaderAwtKeyEvent(event) + } + + fun handleReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean { + if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false + val action = when (event.keyCode) { + AwtKeyEvent.VK_F -> DesktopReaderKeyNavigation.SEARCH + AwtKeyEvent.VK_G -> DesktopReaderKeyNavigation.NEXT_SEARCH + else -> return false + } + val nextSession = latestSession.reduceDesktopReaderKeyNavigation(action, readerEngine) ?: return false + latestOnSessionChange(nextSession) + return true + } + + DesktopReaderKeyDispatcherEffect( + enabled = externalLinkDialogUrl == null, + allowChromeModalWindows = true, + onKeyPressed = { event -> handleReaderGlobalShortcutAwtKeyEvent(event) } + ) + + DesktopReaderKeyDispatcherEffect( + enabled = externalLinkDialogUrl == null && !session.isSearchActive, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false, + onKeyPressed = { event -> handleReaderAwtKeyEvent(event) } + ) + + DesktopReaderFullscreenKeyEffect( + enabled = isFullscreen && externalLinkDialogUrl == null, + onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) } + ) + + LaunchedEffect(session.reader.settings.readingMode) { + if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) { + completedMeasuredPaginationRequest = null + completedMeasuredPaginationPages = emptyList() + warmMeasuredPaginationRequest = null + warmMeasuredPaginationPages = emptyList() + runningMeasuredPaginationRequest = null + } + } + + DisposableEffect(session.reader.book.id) { + onDispose { + if (currentReaderFullscreen) { + currentOnReaderFullscreenChange(false) + } + } + } + + LaunchedEffect( + measuredPaginationRequest, + measuredPaginator + ) { + val request = measuredPaginationRequest ?: return@LaunchedEffect + if (completedMeasuredPaginationRequest == request) { + logEpubPagination( + "reflow_skip reason=request_already_measured book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" + ) + return@LaunchedEffect + } + runningMeasuredPaginationRequest = request + try { + val cacheProbeStartedAt = System.nanoTime() + val cacheProbeSettings = latestSession.reader.settings + val cachedPages = if ( + cacheProbeSettings.readingMode == ReaderReadingMode.PAGINATED && + cacheProbeSettings.layoutSignature() == request.layoutSignature + ) { + withContext(Dispatchers.Default) { + epubPaginationCache.loadMemory( + book = session.reader.book, + settings = cacheProbeSettings, + viewport = request.viewport, + density = request.density.density, + fontScale = request.density.fontScale + ) + } + } else { + null + } + val settingsAfterCacheProbe = latestSession.reader.settings + if (settingsAfterCacheProbe.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (settingsAfterCacheProbe.layoutSignature() != request.layoutSignature) return@LaunchedEffect + if (cachedPages != null) { + val cacheLayoutChanged = !latestSession.reader.pages.samePageLayoutAs(cachedPages) + logEpubPagination( + "cache_warm_result book=\"${session.reader.book.title.logPreview()}\" pages=${cachedPages.size} " + + "layoutChanged=$cacheLayoutChanged viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "elapsedMs=${cacheProbeStartedAt.elapsedMillis()}" + ) + if (cacheLayoutChanged) { + val cacheApplySession = latestSession + latestOnSessionChange( + readerEngine.replacePages( + state = cacheApplySession, + pages = cachedPages, + reflowAnchor = readerEngine.reflowAnchorFor(cacheApplySession) + ) + ) + } + completedMeasuredPaginationPages = cachedPages + completedMeasuredPaginationRequest = request + return@LaunchedEffect + } + + val warmStartSession = latestSession + val warmAnchor = readerEngine.reflowAnchorFor(warmStartSession) + val warmChapterIndex = warmAnchor?.chapterIndex + ?: warmStartSession.reader.currentPage?.chapterIndex + ?: 0 + val warmFirstPageIndex = warmStartSession.reader.pages.firstPageIndexForChapter(warmChapterIndex) ?: 0 + val warmStartedAt = System.nanoTime() + logEpubPagination( + "chapter_warm_start book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + + "firstPage=${warmFirstPageIndex + 1} viewport=${request.viewport.widthPx}x${request.viewport.heightPx}" + ) + val cachedWarmChapterPages = epubPaginationCache.loadChapter( + book = session.reader.book, + settings = settingsAfterCacheProbe, + viewport = request.viewport, + chapterIndex = warmChapterIndex, + density = request.density.density, + fontScale = request.density.fontScale + ) + val warmChapterPages = cachedWarmChapterPages ?: withContext(Dispatchers.Default) { + measuredPaginator.paginateChapterWindow( + book = session.reader.book, + settings = settingsAfterCacheProbe, + viewport = request.viewport, + chapterIndex = warmChapterIndex, + firstPageIndex = warmFirstPageIndex + ) + } + val warmPages = desktopPagesWithMeasuredChapter( + currentPages = warmStartSession.reader.pages, + chapterIndex = warmChapterIndex, + measuredChapterPages = warmChapterPages + ) + val warmLayoutChanged = warmPages.isNotEmpty() && !warmStartSession.reader.pages.samePageLayoutAs(warmPages) + logEpubPagination( + "chapter_warm_result book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " + + "source=${if (cachedWarmChapterPages != null) "cache" else "measured"} " + + "chapterPages=${warmChapterPages.size} pages=${warmPages.size} layoutChanged=$warmLayoutChanged " + + "elapsedMs=${warmStartedAt.elapsedMillis()}" + ) + if (warmLayoutChanged) { + logReaderModeSwitch( + "pagination_warm_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "chapter=$warmChapterIndex chapterPages=${warmChapterPages.size} currentPages=${warmStartSession.reader.pages.size}" + ) + latestOnSessionChange( + readerEngine.replacePages( + state = warmStartSession, + pages = warmPages, + reflowAnchor = warmAnchor + ) + ) + warmMeasuredPaginationPages = warmPages + warmMeasuredPaginationRequest = request + } + + val reflowStartSession = latestSession + val reflowStartRequestId = reflowStartSession.navigationRequestId + val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession) + val settings = reflowStartSession.reader.settings + if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect + logEpubPagination( + "reflow_start book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "spread=${settings.pageSpreadMode} font=${settings.fontSize} lineSpacing=${settings.lineSpacing} " + + "margins=${settings.resolvedHorizontalMargin}x${settings.resolvedVerticalMargin} " + + "pageWidthSetting=${settings.pageWidth} oldPages=${reflowStartSession.reader.pages.size} " + + "anchorPage=${reflowAnchor?.pageIndex} anchorOffsets=${reflowAnchor?.startOffset}..${reflowAnchor?.endOffset}" + ) + val pages = withContext(Dispatchers.Default) { + measuredPaginator.paginate( + book = session.reader.book, + settings = settings, + viewport = request.viewport, + readCache = true + ) + } + val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages) + logEpubPagination( + "reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " + + "layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}" + ) + val currentVisiblePageDetails = latestSession.reader.visiblePages.map { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + val measuredCurrentPageDetails = pages.getOrNull(latestSession.reader.currentPageIndex) + ?.let { page -> "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" } + ?: "none" + logReaderModeSwitch( + "pagination_result requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size} layoutChanged=$layoutChanged " + + "currentVisible=$currentVisiblePageDetails measuredAtCurrent=$measuredCurrentPageDetails" + ) + if (layoutChanged) { + logReaderModeSwitch( + "pagination_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size}" + ) + latestOnSessionChange( + readerEngine.replacePages( + state = latestSession, + pages = pages, + reflowAnchor = reflowAnchor, + navigationRequestIdAtReflowStart = reflowStartRequestId + ) + ) + } + if (pages.isNotEmpty()) { + completedMeasuredPaginationPages = pages + completedMeasuredPaginationRequest = request + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + logEpubPagination( + "reflow_failed book=\"${session.reader.book.title.logPreview()}\" " + + "viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "error=\"${error.message.orEmpty().logPreview(300)}\"" + ) + } finally { + if (runningMeasuredPaginationRequest == request) { + runningMeasuredPaginationRequest = null + } + } + } + + LaunchedEffect( + measuredPaginationRequest, + completedMeasuredPaginationRequest, + completedMeasuredPaginationPages, + session.reader.pages + ) { + val request = measuredPaginationRequest ?: return@LaunchedEffect + val measuredPages = completedMeasuredPaginationPages + if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect + if (completedMeasuredPaginationRequest != request || measuredPages.isEmpty()) return@LaunchedEffect + if (session.reader.pages.samePageLayoutAs(measuredPages)) return@LaunchedEffect + val currentVisiblePageDetails = session.reader.visiblePages.map { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + logReaderModeSwitch( + "pagination_apply_pending requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " + + "currentPages=${session.reader.pages.size} measuredPages=${measuredPages.size} " + + "currentVisible=$currentVisiblePageDetails" + ) + onSessionChange( + readerEngine.replacePages( + state = session, + pages = measuredPages, + reflowAnchor = readerEngine.reflowAnchorFor(session) + ) + ) + } + + val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> + val settings = aiByokSettings.sanitized() + when (action) { + DesktopReaderSelectionAction.DEFINE -> { + if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text) + } + DesktopReaderSelectionAction.SPEAK -> { + if (settings.isCloudTtsAvailable) onCloudTtsToggle(text, locator) + } + DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text) + DesktopReaderSelectionAction.PALETTE -> Unit + } + } + val nativeSelectionActions = buildSet { + val settings = aiByokSettings.sanitized() + if (settings.areReaderAiFeaturesAvailable) add(SharedNativeReaderSelectionAction.DEFINE) + if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH) + if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK) + } + val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator -> + when (action) { + SharedNativeReaderSelectionAction.DEFINE -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text, locator) + SharedNativeReaderSelectionAction.SPEAK -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text, locator) + SharedNativeReaderSelectionAction.SEARCH -> + handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text, locator) + } + } + val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link -> + val now = System.currentTimeMillis() + val last = lastHandledLink + if (last != null && last.href == link.href && now - last.handledAtMs < 900L) { + logEpubLink( + "click_duplicate_ignored source=${link.source} href=\"${link.href.logPreview()}\" " + + "ageMs=${now - last.handledAtMs}" + ) + } else { + lastHandledLink = DesktopEpubHandledLink(link.href, now) + logEpubLink( + "click source=${link.source} href=\"${link.href.logPreview()}\" " + + "chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\" " + + "text=\"${link.text.orEmpty().logPreview()}\"" + ) + when (val target = readerEngine.resolveLink(session, link.href, link.chapterIndex)) { + is ReaderLinkTarget.External -> { + logEpubLink("resolved_external url=\"${target.url.logPreview()}\"") + if (externalLookupAvailable) { + externalLinkDialogUrl = target.url + } + } + is ReaderLinkTarget.Internal -> { + logEpubLink( + "resolved_internal chapter=${target.locator.chapterIndex} " + + "page=${target.locator.pageIndex} offset=${target.locator.startOffset}" + ) + onSessionChange(readerEngine.jumpToLocator(session, target.locator)) + } + ReaderLinkTarget.Ignored -> { + logEpubLink("resolved_ignored href=\"${link.href.logPreview()}\"") + } + } + } + } + + SharedReaderScreen( + session = session, + readerEngine = readerEngine, + onSessionChange = onSessionChange, + onReturnToLibrary = onReturnToLibrary, + isFullscreen = isFullscreen, + onFullscreenChange = ::setReaderFullscreen, + toolbarPreferences = toolbarPreferences, + onToolbarPreferencesChange = onToolbarPreferencesChange, + appThemeControls = appThemeControls, + customReaderThemes = customReaderThemes, + onCustomReaderThemesChange = onCustomReaderThemesChange, + highlightPalette = highlightPalette, + onHighlightPaletteChange = onHighlightPaletteChange, + ttsReplacementPreferences = ttsReplacementPreferences, + ttsReplacementBookId = ttsReplacementBookId, + onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange, + onPickCustomFont = onPickCustomFont, + customFonts = customFonts, + readerExtrasState = desktopReaderExtrasState, + aiByokSettings = aiByokSettings, + externalLookupAvailable = externalLookupAvailable, + cloudTtsControlsAvailable = cloudTtsControlsAvailable, + onExternalLookup = onExternalLookup, + onAiAction = onAiAction, + onAiResultDismiss = onAiResultDismiss, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, + onCloudTtsStart = onCloudTtsStart, + onCloudTtsPauseResume = onCloudTtsPauseResume, + onCloudTtsStop = onCloudTtsStop, + onCloudTtsClearCache = onCloudTtsClearCache, + onCloudTtsVoiceChange = onCloudTtsVoiceChange, + onOpenAiHub = onOpenAiHub, + onDownloadReaderImage = onDownloadReaderImage, + readerImagePreviewContent = { image, previewModifier -> + DesktopEpubNativeImage( + image = image.toDesktopPreviewSemanticImage(), + modifier = previewModifier.clip(RoundedCornerShape(3.dp)) + ) + }, + readerTextureDataUri = readerTextureDataUri, + readerTexturePreviewContent = { textureId, previewModifier -> + DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier) + }, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = onImportReaderTexture, + preferNativeVerticalReader = desktopShouldUseNativeVerticalEpubReader(), + bottomChromeExtraContent = bottomChromeExtraContent, + useDetachedChromeLayer = useDetachedChromeLayer, + useDetachedPanelLayer = useDetachedPanelLayer + ) { renderPlan, onVisiblePageChanged, onHighlightSelected, onOpenHighlightPaletteManager, onChromeActivity -> + val renderPlanModeKey = renderPlan.desktopReaderSurfaceModeKey() + val readerSurfaceKey = renderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady) + val readerModeSwitchLayoutModifier = + if (renderPlan.desktopReaderUsesNativeComposeSurface()) { + Modifier.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_surface_layout modeKey=$renderPlanModeKey surfaceKey=$readerSurfaceKey " + + "paginatedReady=$paginatedLayoutReady size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()}" + ) + } + } else { + Modifier + } + val readerSurfaceModifier = Modifier + .fillMaxWidth() + .weight(1f) + .onSizeChanged { size -> + val next = ReaderViewportSpec(size.width, size.height) + logReaderGap( + "desktop_epub_reader_surface size=${size.width}x${size.height} " + + "mode=${session.reader.settings.readingMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" + ) + logEpubCutoff( + "cutoff_probe layer=desktop_surface size=${size.width}x${size.height} " + + "mode=${session.reader.settings.readingMode} spread=${session.reader.settings.pageSpreadMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + + "margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + + "pageWidthSetting=${session.reader.settings.pageWidth}" + ) + logWebViewLayoutDiag( + "compose_reader_surface size=${size.width}x${size.height} " + + "renderPlan=${if (renderPlan is ReaderContentRenderPlan.WebDocument) "web" else "native"} " + + "mode=${session.reader.settings.readingMode} " + + "fullscreen=$isFullscreen margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " + + "pageWidth=${session.reader.settings.pageWidth} fontSize=${session.reader.settings.fontSize} " + + "lineSpacing=${session.reader.settings.lineSpacing} textAlign=${session.reader.settings.textAlign} " + + "paragraphSpacing=${session.reader.settings.paragraphSpacing} imageScale=${session.reader.settings.imageScale}" + ) + logDesktopReaderOpenTrace { + "event=desktop_reader_surface_size bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" size=${size.width}x${size.height} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}" + } + if (next != readerViewport) { + logEpubPagination( + "viewport_changed width=${next.widthPx} height=${next.heightPx} " + + "previous=${readerViewport.widthPx}x${readerViewport.heightPx}" + ) + readerViewport = next + } + } + LaunchedEffect( + renderPlanModeKey, + session.reader.settings.readingMode, + paginatedLayoutReady + ) { + logReaderModeSwitch( + "surface_state modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "paginatedReady=$paginatedLayoutReady runningPagination=${runningMeasuredPaginationRequest != null} " + + "completedPagination=${completedMeasuredPaginationRequest != null} measuredApplied=$measuredPaginationPagesApplied " + + "warmApplied=$warmMeasuredPaginationPagesApplied warmPageCount=${warmMeasuredPaginationPages.size} " + + "completedMatchesRequest=${completedMeasuredPaginationRequest == measuredPaginationRequest} " + + "measuredPageCount=${completedMeasuredPaginationPages.size} " + + "currentPage=${session.reader.currentPageIndex + 1} " + + "pageCount=${session.reader.pages.size} visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }} " + + "fullscreen=$isFullscreen surfaceKey=$readerSurfaceKey" + ) + logDesktopReaderOpenTrace { + "event=desktop_render_plan_ready bookId=\"${session.reader.book.id.logPreview(120)}\" " + + "title=\"${session.reader.book.title.logPreview(120)}\" " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " + + "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " + + "htmlChars=${(renderPlan as? ReaderContentRenderPlan.WebDocument)?.html?.length ?: 0} " + + "paginatedReady=$paginatedLayoutReady" + } + if (renderPlan.desktopReaderUsesNativeComposeSurface()) { + cleanupRetiredDesktopWebView2InteropHosts( + readerAwtWindow, + "native_surface_state_ready_$paginatedLayoutReady" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_state_${renderPlanModeKey}_after_sweep_request") + readerAwtWindow.requestDesktopReaderModeSwitchRepaint( + "native_surface_state_ready_$paginatedLayoutReady" + ) + DesktopReaderModeSwitchProbeDelaysMillis.forEach { delayMillis -> + delay(delayMillis) + cleanupRetiredDesktopWebView2InteropHosts( + readerAwtWindow, + "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" + ) + readerAwtWindow.requestDesktopReaderModeSwitchRepaint( + "native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady" + ) + logReaderModeSwitch( + "native_probe_after delayMs=$delayMillis modeKey=$renderPlanModeKey " + + "viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " + + "paginatedReady=$paginatedLayoutReady currentPage=${session.reader.currentPageIndex + 1} " + + "visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }}" + ) + logDesktopWebView2ModeSwitchSnapshot("native_probe_after_${delayMillis}ms") + } + } else { + logDesktopWebView2ModeSwitchSnapshot("surface_state_$renderPlanModeKey") + } + } + DisposableEffect(renderPlanModeKey) { + logReaderModeSwitch( + "surface_enter modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_enter_$renderPlanModeKey") + onDispose { + logReaderModeSwitch( + "surface_exit modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " + + "renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}" + ) + logDesktopWebView2ModeSwitchSnapshot("surface_exit_$renderPlanModeKey") + } + } + @Composable + fun ReaderSurfaceContent() { + if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) { + DesktopEpubPaginationPreparing( + active = runningMeasuredPaginationRequest != null, + modifier = Modifier.fillMaxSize() + ) + } else { + when (renderPlan) { + is ReaderContentRenderPlan.WebDocument -> { + val canRenderWebDocument = desktopEpubWebViewCanRender(webViewRuntimeState) + LaunchedEffect( + renderPlan.html, + canRenderWebDocument, + webViewRuntimeState, + webViewNetworkAccessEnabled + ) { + logDesktopWebView2( + "reader_screen_web_document canRender=$canRenderWebDocument " + + "backend=${desktopEpubWebViewBackend().logName} " + + "runtimeInitialized=${webViewRuntimeState.initialized} restart=${webViewRuntimeState.restartRequired} " + + "error=${webViewRuntimeState.errorMessage != null} network=$webViewNetworkAccessEnabled " + + "htmlChars=${renderPlan.html.length} htmlHash=${renderPlan.html.hashCode()}" + ) + } + if (canRenderWebDocument) { + DesktopEpubWebView( + html = renderPlan.html, + appearanceScript = renderPlan.appearanceScript, + highlightPaletteScript = renderPlan.highlightPaletteScript, + navigationTarget = renderPlan.navigationTarget, + highlights = renderPlan.highlights, + onHighlightCreated = { highlight -> + logEpubHighlightFlow( + "state_reduce_start id=${highlight.id} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "page=${highlight.locator.pageIndex} textChars=${highlight.text.length}" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logEpubHighlightFlow( + "state_reduce_done id=${highlight.id} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) + }, + onHighlightSelected = onHighlightSelected, + isFullscreen = isFullscreen, + onKeyboardNavigation = { action -> + val nextSession = session.reduceDesktopReaderKeyNavigation(action, readerEngine) + if (nextSession == null) { + if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) { + setReaderFullscreen(false) + } + } else { + onSessionChange(nextSession) + } + }, + onSelectionAction = { payload -> + if (payload.action == DesktopReaderSelectionAction.PALETTE) { + onOpenHighlightPaletteManager() + } else { + handleDesktopSelectionAction(payload.action, payload.text, payload.locator) + } + }, + onLinkClicked = handleDesktopEpubLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onChromeActivity, + networkAccessEnabled = webViewNetworkAccessEnabled, + backgroundColor = renderPlan.background, + modifier = Modifier.fillMaxSize() + ) + } else { + DesktopWebViewRuntimeIndicator( + state = webViewRuntimeState, + modifier = Modifier.fillMaxSize() + ) + } + } + is ReaderContentRenderPlan.NativePaginatedPages -> { + LaunchedEffect(renderPlan.visiblePages, paginatedLayoutReady) { + val pageDetails = renderPlan.visiblePages.joinToString(prefix = "[", postfix = "]") { page -> + "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" + } + logReaderModeSwitch( + "native_reader_render paginatedReady=$paginatedLayoutReady " + + "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }} " + + "pageDetails=$pageDetails " + + "background=${renderPlan.background} foreground=${renderPlan.foreground}" + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " + + "visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }}" + ) + } + ) { + SharedNativePaginatedReader( + renderPlan = renderPlan, + readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), + searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), + onVisiblePageChanged = onVisiblePageChanged, + enabledSelectionActions = nativeSelectionActions, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, + onSelectionAction = handleNativeSelectionAction, + onOpenHighlightPaletteManager = onOpenHighlightPaletteManager, + onHighlightCreated = { highlight -> + logDesktopHighlightMap( + "native_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + + "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\"" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logDesktopHighlightMap( + "native_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) + }, + onHighlightSelected = onHighlightSelected, + onLinkClicked = { link -> + handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) + }, + onReaderTap = onChromeActivity, + imageContent = { image, imageModifier -> + DesktopEpubNativeImage( + image = image, + modifier = imageModifier + ) + }, + modifier = Modifier.fillMaxSize() + ) + } + } + is ReaderContentRenderPlan.NativeVerticalPages -> { + LaunchedEffect(renderPlan.book.id, renderPlan.pages, renderPlan.currentPageIndex) { + logReaderModeSwitch( + "native_vertical_reader_render currentPage=${renderPlan.currentPageIndex + 1} " + + "pages=${renderPlan.pages.size} chapters=${renderPlan.book.chapters.size} " + + "semanticBlocks=${renderPlan.book.chapters.sumOf { it.semanticBlocks.size }} " + + "background=${renderPlan.background} foreground=${renderPlan.foreground}" + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + logReaderModeSwitch( + "native_vertical_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " + + "windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " + + "${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " + + "currentPage=${renderPlan.currentPageIndex + 1}" + ) + } + ) { + SharedNativeVerticalReader( + renderPlan = renderPlan, + readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(), + searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f), + onVisiblePageChanged = onVisiblePageChanged, + enabledSelectionActions = nativeSelectionActions, + onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) }, + onSelectionAction = handleNativeSelectionAction, + onOpenHighlightPaletteManager = onOpenHighlightPaletteManager, + onHighlightCreated = { highlight -> + logDesktopHighlightMap( + "native_vertical_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " + + "color=${highlight.color.id} chapter=${highlight.chapterIndex} " + + "page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " + + "block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " + + "textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\"" + ) + val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine) + logDesktopHighlightMap( + "native_vertical_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " + + "contains=${nextSession.highlights.any { it.id == highlight.id }}" + ) + onSessionChange(nextSession) + }, + onHighlightSelected = onHighlightSelected, + onLinkClicked = { link -> + handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick()) + }, + onReaderTap = onChromeActivity, + imageContent = { image, imageModifier -> + DesktopEpubNativeImage( + image = image, + modifier = imageModifier + ) + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + } + + key(readerSurfaceKey) { + if (renderPlan is ReaderContentRenderPlan.WebDocument) { + Box( + modifier = readerSurfaceModifier + .fillMaxSize() + .background(renderPlan.background) + ) { + ReaderSurfaceContent() + } + } else { + Surface( + color = renderPlan.background, + shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp), + modifier = readerSurfaceModifier + .fillMaxSize() + .clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp)) + .then(readerModeSwitchLayoutModifier) + ) { + ReaderSurfaceContent() + } + } + } + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceModeKey(): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" + is ReaderContentRenderPlan.NativePaginatedPages -> "desktop-reader-native" + is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical" + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceModeLabel(): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "web" + is ReaderContentRenderPlan.NativePaginatedPages -> "native" + is ReaderContentRenderPlan.NativeVerticalPages -> "native-vertical" + } +} + +private fun ReaderContentRenderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady: Boolean): String { + return when (this) { + is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web" + is ReaderContentRenderPlan.NativePaginatedPages -> + "desktop-reader-native-${if (paginatedLayoutReady) "ready" else "preparing"}" + is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical" + } +} + +private fun ReaderContentRenderPlan.desktopReaderUsesNativeComposeSurface(): Boolean { + return this is ReaderContentRenderPlan.NativePaginatedPages || + this is ReaderContentRenderPlan.NativeVerticalPages +} + +private fun Window?.requestDesktopReaderModeSwitchRepaint(reason: String) { + val targetWindow = this + EventQueue.invokeLater { + if (targetWindow == null) { + logReaderModeSwitch("awt_repaint_skip reason=$reason window=null") + return@invokeLater + } + if (!targetWindow.isDisplayable) { + logReaderModeSwitch( + "awt_repaint_skip reason=$reason window=${targetWindow.javaClass.simpleName} " + + "displayable=false visible=${targetWindow.isVisible} showing=${targetWindow.isShowing} " + + "size=${targetWindow.width}x${targetWindow.height}" + ) + return@invokeLater + } + targetWindow.invalidate() + targetWindow.validate() + targetWindow.repaint() + (targetWindow as? javax.swing.RootPaneContainer)?.contentPane?.let { contentPane -> + contentPane.invalidate() + contentPane.validate() + contentPane.repaint() + } + logReaderModeSwitch( + "awt_repaint reason=$reason window=${targetWindow.javaClass.simpleName} " + + "visible=${targetWindow.isVisible} displayable=${targetWindow.isDisplayable} " + + "showing=${targetWindow.isShowing} size=${targetWindow.width}x${targetWindow.height}" + ) + } +} + +private val DesktopReaderModeSwitchProbeDelaysMillis = longArrayOf(120L, 350L, 900L) + +private fun Long.elapsedMillis(): Long { + return ((System.nanoTime() - this) / 1_000_000L).coerceAtLeast(0L) +} + +private fun ReaderImageReference.toDesktopPreviewSemanticImage(): SemanticImage { + return SemanticImage( + path = source, + altText = altText, + intrinsicWidth = intrinsicWidth, + intrinsicHeight = intrinsicHeight, + style = CssStyle(), + elementId = null, + cfi = cfi, + blockIndex = blockIndex + ) +} + +private fun ReaderSessionState.reduceDesktopReaderKeyNavigation( + action: DesktopReaderKeyNavigation, + readerEngine: ReaderEngine +): ReaderSessionState? { + return when (action) { + DesktopReaderKeyNavigation.NEXT -> reduce(ReaderAction.NextPage, readerEngine) + DesktopReaderKeyNavigation.PREVIOUS -> reduce(ReaderAction.PreviousPage, readerEngine) + DesktopReaderKeyNavigation.FIRST -> reduce(ReaderAction.JumpToPage(0), readerEngine) + DesktopReaderKeyNavigation.LAST -> reduce(ReaderAction.JumpToPage(reader.pages.lastIndex), readerEngine) + DesktopReaderKeyNavigation.SEARCH -> reduce(ReaderAction.SearchOpened, readerEngine) + DesktopReaderKeyNavigation.NEXT_SEARCH -> reduce(ReaderAction.JumpToNextSearchResult, readerEngine) + DesktopReaderKeyNavigation.EXIT_FULLSCREEN -> null + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt new file mode 100644 index 0000000..6789d5c --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTexturePreview.kt @@ -0,0 +1,43 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight + +@Composable +internal fun DesktopReaderTexturePreview( + textureId: String, + modifier: Modifier = Modifier +) { + val bitmap = remember(textureId) { DesktopReaderTextures.imageBitmapFor(textureId) } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + ) + } else { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text( + "Aa", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt new file mode 100644 index 0000000..143aefa --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTextures.kt @@ -0,0 +1,98 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.dueattendant149.bookreader.shared.ReaderTexture +import org.dueattendant149.bookreader.shared.ReaderTextureFilePrefix +import org.dueattendant149.bookreader.shared.ReaderTextureImportExtensions +import org.dueattendant149.bookreader.shared.readerTextureMimeTypeForExtension +import java.io.ByteArrayInputStream +import java.io.File +import java.util.Base64 +import java.util.Locale +import javax.imageio.ImageIO + +internal object DesktopReaderTextures { + private val bytesCache = mutableMapOf() + private val dataUriCache = mutableMapOf() + private val imageCache = mutableMapOf() + + fun importedTextureIds(): List { + return readerTextureDirectory() + .listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in ReaderTextureImportExtensions } + ?.sortedBy { it.name.lowercase(Locale.ROOT) } + ?.map { ReaderTextureFilePrefix + it.absolutePath } + .orEmpty() + } + + fun importTexture(source: File): String? { + if (!source.isFile) return null + val extension = source.extension.lowercase(Locale.ROOT) + .takeIf { it in ReaderTextureImportExtensions } + ?: return null + val safeName = source.nameWithoutExtension + .replace(Regex("[^A-Za-z0-9._-]+"), "_") + .trim('_') + .ifBlank { "texture" } + val directory = readerTextureDirectory().apply { mkdirs() } + val target = File(directory, "texture_${System.currentTimeMillis()}_$safeName.$extension") + return runCatching { + source.copyTo(target, overwrite = false) + val textureId = ReaderTextureFilePrefix + target.absolutePath + bytesCache.remove(textureId) + dataUriCache.remove(textureId) + imageCache.remove(textureId) + textureId + }.getOrNull() + } + + fun dataUriFor(textureId: String): String? { + return dataUriCache.getOrPut(textureId) { + val bytes = bytesFor(textureId) ?: return@getOrPut null + val extension = textureExtension(textureId) + "data:${readerTextureMimeTypeForExtension(extension)};base64," + + Base64.getEncoder().encodeToString(bytes) + } + } + + fun imageBitmapFor(textureId: String?): ImageBitmap? { + val id = textureId ?: return null + return imageCache.getOrPut(id) { + val bytes = bytesFor(id) ?: return@getOrPut null + runCatching { + ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap() + }.getOrNull() + } + } + + private fun bytesFor(textureId: String): ByteArray? { + return bytesCache.getOrPut(textureId) { + if (textureId.startsWith(ReaderTextureFilePrefix)) { + File(textureId.removePrefix(ReaderTextureFilePrefix)).takeIf { it.isFile }?.readBytes() + } else { + val texture = ReaderTexture.entries.firstOrNull { it.id == textureId } ?: return@getOrPut null + val classLoader = Thread.currentThread().contextClassLoader ?: DesktopReaderTextures::class.java.classLoader + classLoader + ?.getResourceAsStream(texture.assetPath) + ?.use { it.readBytes() } + ?: DesktopReaderTextures::class.java.classLoader + ?.getResourceAsStream(texture.assetPath) + ?.use { it.readBytes() } + } + } + } + + private fun textureExtension(textureId: String): String { + if (textureId.startsWith(ReaderTextureFilePrefix)) { + return File(textureId.removePrefix(ReaderTextureFilePrefix)).extension + } + return ReaderTexture.entries.firstOrNull { it.id == textureId } + ?.assetPath + ?.substringAfterLast('.', "png") + ?: "png" + } + + private fun readerTextureDirectory(): File { + return File(desktopUserDataRoot(), "reader_textures") + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt new file mode 100644 index 0000000..c1301d3 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypography.kt @@ -0,0 +1,105 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font as DesktopFont +import org.dueattendant149.bookreader.shared.AppFontPreference +import org.dueattendant149.bookreader.shared.AppFontPreferenceKind +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.detectFontVariant +import org.dueattendant149.bookreader.shared.familyFilenameSignature +import org.dueattendant149.bookreader.shared.supportsVariableWeightAxis +import java.io.File + +internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily { + customFontPath?.takeIf { it.isNotBlank() }?.let { path -> + val baseFile = File(path) + val signature = baseFile.nameWithoutExtension.familyFilenameSignature() + val siblings = baseFile.parentFile?.listFiles()?.filter { + it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") && + it.nameWithoutExtension.familyFilenameSignature() == signature + } ?: listOf(baseFile) + + val seenVariants = mutableSetOf() + val fontList = siblings.flatMap { sibling -> + try { + val variant = sibling.nameWithoutExtension.detectFontVariant() + val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) { + variableDesktopReaderFontWeights + } else { + listOf(variant?.weight ?: androidx.compose.ui.text.font.FontWeight.Normal) + } + weights.mapNotNull { weight -> + val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal + if (seenVariants.add("${weight.weight}|$style")) { + DesktopFont(sibling, weight, style) + } else { + null + } + } + } catch (e: Exception) { + emptyList() + } + } + if (fontList.isNotEmpty()) { + return FontFamily(fontList) + } + } + return fontFamily.toComposeFontFamily() +} + +private fun String.toComposeFontFamily(): FontFamily { + return when (this) { + "Serif" -> FontFamily.Serif + "Sans" -> FontFamily.SansSerif + "Mono" -> FontFamily.Monospace + else -> FontFamily.Default + } +} + +private val variableDesktopReaderFontWeights = listOf( + androidx.compose.ui.text.font.FontWeight.Thin, + androidx.compose.ui.text.font.FontWeight.ExtraLight, + androidx.compose.ui.text.font.FontWeight.Light, + androidx.compose.ui.text.font.FontWeight.Normal, + androidx.compose.ui.text.font.FontWeight.Medium, + androidx.compose.ui.text.font.FontWeight.SemiBold, + androidx.compose.ui.text.font.FontWeight.Bold, + androidx.compose.ui.text.font.FontWeight.ExtraBold, + androidx.compose.ui.text.font.FontWeight.Black +) + +internal fun List.samePageLayoutAs(other: List): Boolean { + if (size != other.size) return false + return indices.all { index -> + val left = this[index] + val right = other[index] + left.pageIndex == right.pageIndex && + left.chapterIndex == right.chapterIndex && + left.startOffset == right.startOffset && + left.endOffset == right.endOffset && + left.text.length == right.text.length && + left.semanticBlocks == right.semanticBlocks + } +} + +internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? { + val file = File(path).takeIf { it.isFile } ?: return null + return runCatching { FontFamily(DesktopFont(file)) }.getOrNull() +} + +internal fun AppFontPreference.toDesktopAppFontFamily(customFonts: List): FontFamily? { + val sanitized = sanitized() + return when (sanitized.kind) { + AppFontPreferenceKind.SYSTEM -> null + AppFontPreferenceKind.SERIF -> FontFamily.Serif + AppFontPreferenceKind.SANS_SERIF -> FontFamily.SansSerif + AppFontPreferenceKind.MONOSPACE -> FontFamily.Monospace + AppFontPreferenceKind.CUSTOM -> { + val fontId = sanitized.customFontId ?: return null + customFonts.firstOrNull { it.id == fontId && !it.isDeleted } + ?.toDesktopPreviewFontFamily() + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt new file mode 100644 index 0000000..99ccd30 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowState.kt @@ -0,0 +1,190 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.ReaderCloudTtsState +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import kotlinx.coroutines.Job + +internal const val DesktopReaderWindowDefaultWidthDp = 1120f +internal const val DesktopReaderWindowDefaultHeightDp = 760f +internal val DesktopReaderWindowDefaultSize = DpSize( + DesktopReaderWindowDefaultWidthDp.dp, + DesktopReaderWindowDefaultHeightDp.dp +) + +internal fun DesktopWindowStateSnapshot.toReaderWindowPlacement(): WindowPlacement { + return when (placement) { + DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Floating + else -> toWindowPlacement() + } +} + +internal fun DesktopWindowStateSnapshot.toPersistableReaderWindowSnapshot(): DesktopWindowStateSnapshot? { + if (placement == DesktopSavedWindowPlacement.FULLSCREEN) return null + return sanitized() +} + +internal fun shouldResetDesktopTextReaderWindowSurface( + previousMode: ReaderReadingMode, + currentMode: ReaderReadingMode, + usesNativeWebView: Boolean +): Boolean { + return usesNativeWebView && + previousMode == ReaderReadingMode.VERTICAL && + currentMode == ReaderReadingMode.PAGINATED +} + +internal data class DesktopReaderWindowState( + val id: String, + val opening: DesktopReaderOpening, + val content: DesktopReaderWindowContent = DesktopReaderWindowContent.Opening, + val focusRequestId: Long = 0L, + val fullscreen: Boolean = false, + val surfaceResetId: Long = 0L +) { + val bookId: String + get() = opening.bookId + + val title: String + get() = when (content) { + DesktopReaderWindowContent.Opening -> opening.title + is DesktopReaderWindowContent.PasswordRequired -> content.book.cardTitleForMessage() + is DesktopReaderWindowContent.Pdf -> content.book.cardTitleForMessage() + is DesktopReaderWindowContent.Text -> content.book.cardTitleForMessage() + } + + val formatLabel: String + get() = opening.formatLabel +} + +internal sealed interface DesktopReaderWindowContent { + data object Opening : DesktopReaderWindowContent + + data class PasswordRequired( + val book: BookItem, + val attemptedPassword: Boolean + ) : DesktopReaderWindowContent + + data class Pdf( + val book: BookItem, + val document: DesktopPdfDocument + ) : DesktopReaderWindowContent + + data class Text( + val book: BookItem, + val session: ReaderSessionState, + val extrasState: ReaderExtrasState = ReaderExtrasState( + cloudTts = ReaderCloudTtsState() + ), + val showAiHub: Boolean = false, + val readerAiResultRequestId: Long = 0L, + val dismissedReaderAiResultRequestId: Long? = null, + val summaryResult: SummarizationResult? = null, + val recapResult: RecapResult? = null, + val isSummaryLoading: Boolean = false, + val isRecapLoading: Boolean = false, + val recapProgressMessage: String? = null, + val ttsJob: Job? = null + ) : DesktopReaderWindowContent +} + +internal data class DesktopReaderWindowOpenDecision( + val windows: List, + val shouldStartOpen: Boolean +) + +internal fun List.openOrFocusDesktopReaderWindow( + opening: DesktopReaderOpening, + force: Boolean +): DesktopReaderWindowOpenDecision { + val existing = firstOrNull { it.bookId == opening.bookId } + if (existing != null && !force) { + return DesktopReaderWindowOpenDecision( + windows = map { window -> + if (window.id == existing.id) { + window.copy(focusRequestId = window.focusRequestId + 1) + } else { + window + } + }, + shouldStartOpen = false + ) + } + + val replacement = DesktopReaderWindowState( + id = existing?.id ?: opening.bookId.ifBlank { opening.requestId.toString() }, + opening = opening, + focusRequestId = (existing?.focusRequestId ?: 0L) + 1 + ) + return DesktopReaderWindowOpenDecision( + windows = filterNot { it.bookId == opening.bookId } + replacement, + shouldStartOpen = true + ) +} + +internal fun List.focusDesktopReaderWindow(bookId: String): List { + return map { window -> + if (window.bookId == bookId) { + window.copy(focusRequestId = window.focusRequestId + 1) + } else { + window + } + } +} + +internal fun List.withDesktopReaderWindowContent( + requestId: Long, + content: DesktopReaderWindowContent +): List { + return map { window -> + if (window.opening.requestId == requestId) { + window.copy(content = content) + } else { + window + } + } +} + +internal fun List.withoutDesktopReaderWindow(windowId: String): List { + return filterNot { it.id == windowId } +} + +internal fun List.withoutDesktopReaderBookIds( + bookIds: Set +): List { + return filterNot { it.bookId in bookIds } +} + +internal fun List.replaceDesktopTextReaderContent( + windowId: String, + transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text +): List { + return map { window -> + val content = window.content + if (window.id == windowId && content is DesktopReaderWindowContent.Text) { + window.copy(content = transform(content)) + } else { + window + } + } +} + +internal fun List.replaceAllDesktopTextReaderContent( + transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text +): List { + return map { window -> + val content = window.content + if (content is DesktopReaderWindowContent.Text) { + window.copy(content = transform(content)) + } else { + window + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt new file mode 100644 index 0000000..11c49d9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupSplash.kt @@ -0,0 +1,163 @@ +package org.dueattendant149.bookreader.desktop + +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import java.awt.EventQueue +import java.awt.Font +import java.awt.GraphicsEnvironment +import java.awt.Image +import java.lang.reflect.InvocationTargetException +import java.util.concurrent.atomic.AtomicReference +import javax.swing.BorderFactory +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.ImageIcon +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JProgressBar +import javax.swing.JWindow +import javax.swing.SwingConstants + +internal data class DesktopStartupSplashSpec( + val title: String = EpistemeDesktopWindowTitle, + val message: String = "Opening your library", + val width: Int = 360, + val height: Int = 220 +) + +internal fun epistemeDesktopStartupSplashSpec( + profile: DesktopBuildProfile = currentDesktopBuildProfile() +): DesktopStartupSplashSpec { + return DesktopStartupSplashSpec(title = profile.appName) +} + +internal class DesktopStartupSplash private constructor( + private val window: JWindow +) { + fun close() { + runOnSplashEventThread { + window.isVisible = false + window.dispose() + } + } + + companion object { + fun show(spec: DesktopStartupSplashSpec = epistemeDesktopStartupSplashSpec()): DesktopStartupSplash? { + if (GraphicsEnvironment.isHeadless()) return null + + val splashRef = AtomicReference() + runOnSplashEventThreadAndWait { + runCatching { + val window = JWindow().apply { + name = "episteme-startup-splash" + preferredSize = Dimension(spec.width, spec.height) + minimumSize = Dimension(spec.width, spec.height) + background = SplashBackground + contentPane = startupSplashContent(spec) + pack() + setLocationRelativeTo(null) + isAlwaysOnTop = true + isVisible = true + } + splashRef.set(DesktopStartupSplash(window)) + } + } + return splashRef.get() + } + } +} + +private fun startupSplashContent(spec: DesktopStartupSplashSpec): JPanel { + return JPanel(BorderLayout()).apply { + preferredSize = Dimension(spec.width, spec.height) + background = SplashBackground + border = BorderFactory.createLineBorder(SplashBorder) + + val body = JPanel().apply { + background = SplashBackground + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = BorderFactory.createEmptyBorder(24, 28, 22, 28) + } + + startupSplashIcon()?.let { icon -> + body.add( + JLabel(icon).apply { + alignmentX = Component.CENTER_ALIGNMENT + horizontalAlignment = SwingConstants.CENTER + } + ) + body.add(Box.createVerticalStrut(14)) + } + + body.add( + JLabel(spec.title).apply { + alignmentX = Component.CENTER_ALIGNMENT + horizontalAlignment = SwingConstants.CENTER + foreground = SplashTitle + font = font.deriveFont(Font.BOLD, 24f) + } + ) + body.add(Box.createVerticalStrut(8)) + body.add( + JLabel(spec.message).apply { + alignmentX = Component.CENTER_ALIGNMENT + horizontalAlignment = SwingConstants.CENTER + foreground = SplashText + font = font.deriveFont(Font.PLAIN, 13f) + } + ) + body.add(Box.createVerticalStrut(20)) + body.add( + JProgressBar().apply { + alignmentX = Component.CENTER_ALIGNMENT + isIndeterminate = true + isBorderPainted = false + preferredSize = Dimension(220, 8) + maximumSize = Dimension(220, 8) + foreground = SplashAccent + background = SplashTrack + } + ) + + add(body, BorderLayout.CENTER) + } +} + +private fun startupSplashIcon(): ImageIcon? { + val resource = Thread.currentThread().contextClassLoader?.getResource(EpistemeDesktopWindowIconResource) + ?: DesktopStartupSplash::class.java.classLoader?.getResource(EpistemeDesktopWindowIconResource) + ?: return null + val icon = ImageIcon(resource) + return ImageIcon(icon.image.getScaledInstance(56, 56, Image.SCALE_SMOOTH)) +} + +private fun runOnSplashEventThread(block: () -> Unit) { + if (EventQueue.isDispatchThread()) { + block() + } else { + EventQueue.invokeLater { block() } + } +} + +private fun runOnSplashEventThreadAndWait(block: () -> Unit) { + if (EventQueue.isDispatchThread()) { + block() + return + } + try { + EventQueue.invokeAndWait { block() } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } catch (_: InvocationTargetException) { + // Startup feedback should never prevent the app from launching. + } +} + +private val SplashBackground = Color(0xF9, 0xF7, 0xEF) +private val SplashBorder = Color(0xD8, 0xD2, 0xC3) +private val SplashTitle = Color(0x1E, 0x22, 0x1A) +private val SplashText = Color(0x61, 0x64, 0x58) +private val SplashAccent = Color(0x2F, 0x6F, 0x68) +private val SplashTrack = Color(0xE3, 0xDE, 0xD1) diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt new file mode 100644 index 0000000..6432e60 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResources.kt @@ -0,0 +1,250 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ui.SharedStringResolver +import org.w3c.dom.Element +import java.io.InputStream +import java.util.Locale +import javax.xml.XMLConstants +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.math.abs + +internal fun loadDesktopStringResolver( + locale: Locale = currentDesktopStringsLocale(), + classLoader: ClassLoader = Thread.currentThread().contextClassLoader + ?: DesktopAndroidStringResources::class.java.classLoader +): SharedStringResolver { + val resources = DesktopAndroidStringResources.load(locale = locale, classLoader = classLoader) + return SharedStringResolver( + resolve = resources::stringOrNull, + resolveQuantity = resources::quantityStringOrNull + ) +} + +internal data class DesktopAndroidStringResources( + private val strings: Map, + private val plurals: Map>, + private val locale: Locale +) { + fun stringOrNull(name: String): String? = strings[name] + + fun quantityStringOrNull(name: String, quantity: Int): String? { + val items = plurals[name].orEmpty() + if (items.isEmpty()) return null + val quantityName = desktopAndroidPluralQuantity(locale, quantity, items.keys) + return items[quantityName] ?: items["other"] ?: items.values.firstOrNull() + } + + companion object { + fun load( + locale: Locale, + classLoader: ClassLoader + ): DesktopAndroidStringResources { + val fallback = loadResourceMap(classLoader, "$DesktopAndroidStringsRoot/values/strings.xml") + val localized = desktopAndroidStringResourcePaths(locale) + .asReversed() + .fold(emptyMap()) { merged, path -> + merged + loadResourceMap(classLoader, path) + } + val fallbackPlurals = loadPluralMap(classLoader, "$DesktopAndroidStringsRoot/values/plurals.xml") + val localizedPlurals = desktopAndroidPluralResourcePaths(locale) + .asReversed() + .fold(emptyMap>()) { merged, path -> + merged + loadPluralMap(classLoader, path) + } + return DesktopAndroidStringResources( + strings = fallback + localized, + plurals = fallbackPlurals + localizedPlurals, + locale = locale + ) + } + + private fun loadResourceMap(classLoader: ClassLoader, path: String): Map { + val stream = classLoader.getResourceAsStream(path) ?: return emptyMap() + return stream.use(::parseAndroidStringXml) + } + + private fun loadPluralMap(classLoader: ClassLoader, path: String): Map> { + val stream = classLoader.getResourceAsStream(path) ?: return emptyMap() + return stream.use(::parseAndroidPluralXml) + } + } +} + +internal fun desktopAndroidStringResourcePaths(locale: Locale): List { + return desktopAndroidResourcePaths(locale, "strings.xml") +} + +internal fun desktopAndroidPluralResourcePaths(locale: Locale): List { + return desktopAndroidResourcePaths(locale, "plurals.xml") +} + +private fun desktopAndroidResourcePaths(locale: Locale, fileName: String): List { + val language = locale.language.takeIf { it.isNotBlank() } ?: return emptyList() + val country = locale.country.takeIf { it.isNotBlank() } + val exact = country?.let { androidValuesFolderFor(language, it) } + val languageOnly = androidValuesFolderFor(language, null) + return listOfNotNull(exact, languageOnly) + .filterNot { it == "values" } + .distinct() + .map { "$DesktopAndroidStringsRoot/$it/$fileName" } +} + +internal fun currentDesktopStringsLocale(): Locale { + val overrideTag = System.getProperty(DesktopLocaleProperty) + ?.trim() + ?.takeIf { it.isNotBlank() } + return overrideTag?.let(Locale::forLanguageTag)?.takeUnless { it.language.isBlank() } + ?: Locale.getDefault() +} + +internal fun desktopLocaleForLanguageTag(languageTag: String?): Locale { + return normalizeDesktopLanguageTag(languageTag) + ?.let(Locale::forLanguageTag) + ?.takeUnless { it.language.isBlank() } + ?: currentDesktopStringsLocale() +} + +internal fun parseAndroidStringXml(stream: InputStream): Map { + val factory = DocumentBuilderFactory.newInstance().apply { + isIgnoringComments = true + isNamespaceAware = false + runCatching { setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } + runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) } + runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) } + runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) } + } + val document = factory.newDocumentBuilder().parse(stream) + val nodes = document.getElementsByTagName("string") + val strings = linkedMapOf() + for (index in 0 until nodes.length) { + val element = nodes.item(index) as? Element ?: continue + val name = element.getAttribute("name").takeIf { it.isNotBlank() } ?: continue + strings[name] = element.textContent.orEmpty().decodeAndroidStringEscapes() + } + return strings +} + +internal fun parseAndroidPluralXml(stream: InputStream): Map> { + val factory = secureAndroidXmlDocumentBuilderFactory() + val document = factory.newDocumentBuilder().parse(stream) + val nodes = document.getElementsByTagName("plurals") + val plurals = linkedMapOf>() + for (index in 0 until nodes.length) { + val element = nodes.item(index) as? Element ?: continue + val name = element.getAttribute("name").takeIf { it.isNotBlank() } ?: continue + val items = linkedMapOf() + val itemNodes = element.getElementsByTagName("item") + for (itemIndex in 0 until itemNodes.length) { + val item = itemNodes.item(itemIndex) as? Element ?: continue + val quantity = item.getAttribute("quantity").takeIf { it.isNotBlank() } ?: continue + items[quantity] = item.textContent.orEmpty().decodeAndroidStringEscapes() + } + if (items.isNotEmpty()) plurals[name] = items + } + return plurals +} + +internal fun desktopAndroidPluralQuantity(locale: Locale, quantity: Int, availableQuantities: Set): String { + val preferred = desktopAndroidPluralQuantity(locale, quantity) + return when { + preferred in availableQuantities -> preferred + "other" in availableQuantities -> "other" + else -> availableQuantities.firstOrNull().orEmpty() + } +} + +private fun desktopAndroidPluralQuantity(locale: Locale, quantity: Int): String { + val language = locale.language.lowercase(Locale.ROOT) + val absolute = abs(quantity) + return when (language) { + "ar" -> { + val mod100 = absolute % 100 + when { + absolute == 0 -> "zero" + absolute == 1 -> "one" + absolute == 2 -> "two" + mod100 in 3..10 -> "few" + mod100 in 11..99 -> "many" + else -> "other" + } + } + "ru", "uk", "be" -> { + val mod10 = absolute % 10 + val mod100 = absolute % 100 + when { + mod10 == 1 && mod100 != 11 -> "one" + mod10 in 2..4 && mod100 !in 12..14 -> "few" + mod10 == 0 || mod10 in 5..9 || mod100 in 11..14 -> "many" + else -> "other" + } + } + "pl" -> { + val mod10 = absolute % 10 + val mod100 = absolute % 100 + when { + absolute == 1 -> "one" + mod10 in 2..4 && mod100 !in 12..14 -> "few" + mod10 == 0 || mod10 == 1 || mod10 in 5..9 || mod100 in 12..14 -> "many" + else -> "other" + } + } + "fr" -> if (absolute == 0 || absolute == 1) "one" else "other" + "ja", "ko", "zh", "vi", "id", "in", "tr" -> "other" + else -> if (absolute == 1) "one" else "other" + } +} + +private fun secureAndroidXmlDocumentBuilderFactory(): DocumentBuilderFactory { + return DocumentBuilderFactory.newInstance().apply { + isIgnoringComments = true + isNamespaceAware = false + runCatching { setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } + runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) } + runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) } + runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) } + } +} + +private fun androidValuesFolderFor(language: String, country: String?): String { + val normalizedLanguage = language.lowercase(Locale.ROOT) + val resourceLanguage = if (normalizedLanguage == "id") "in" else normalizedLanguage + return if (country.isNullOrBlank()) { + if (resourceLanguage == "en") "values" else "values-$resourceLanguage" + } else { + "values-$resourceLanguage-r${country.uppercase(Locale.ROOT)}" + } +} + +internal fun normalizeDesktopLanguageTag(languageTag: String?): String? { + val normalizedInput = languageTag + ?.trim() + ?.replace('_', '-') + ?.takeIf { it.isNotBlank() } + ?: return null + val canonicalInput = when { + normalizedInput.equals("in", ignoreCase = true) -> "id" + normalizedInput.startsWith("in-", ignoreCase = true) -> "id-${normalizedInput.substringAfter('-')}" + else -> normalizedInput + } + val locale = Locale.forLanguageTag(canonicalInput).takeUnless { it.language.isBlank() } ?: return null + val language = when (locale.language) { + "in" -> "id" + else -> locale.language + } + val country = locale.country.takeIf { it.isNotBlank() } + return if (country == null) { + language + } else { + "$language-${country.uppercase(Locale.ROOT)}" + } +} + +private fun String.decodeAndroidStringEscapes(): String { + return replace("\\'", "'") + .replace("\\\"", "\"") + .replace("\\n", "\n") + .replace("\\t", "\t") +} + +private const val DesktopAndroidStringsRoot = "desktop-android-res" +private const val DesktopLocaleProperty = "episteme.desktop.locale" diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt new file mode 100644 index 0000000..f3f741f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStore.kt @@ -0,0 +1,88 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.security.MessageDigest +import java.util.Base64 +import java.util.Properties + +internal data class DesktopCachedSummaryItem( + val index: Int, + val title: String, + val summary: String +) + +internal class DesktopSummaryCacheStore( + private val root: File = File(desktopUserCacheRoot(), "summary-cache") +) { + fun getSummary(bookKey: String, index: Int): String? { + val file = summaryFile(bookKey, index) + if (!file.isFile) return null + return load(file).getProperty("summary", "").takeIf { it.isNotBlank() } + } + + fun saveSummary(bookKey: String, index: Int, title: String, summary: String) { + if (summary.isBlank()) return + val file = summaryFile(bookKey, index) + file.parentFile?.mkdirs() + Properties().apply { + setProperty("index", index.toString()) + setProperty("title", title) + setProperty("summary", summary) + }.also { properties -> + file.outputStream().use { output -> + properties.store(output, "Episteme desktop summary cache") + } + } + } + + fun getAllSummaries(bookKey: String): List { + val directory = bookDirectory(bookKey) + return directory.listFiles { file -> file.isFile && file.extension == "properties" } + .orEmpty() + .mapNotNull { file -> + val properties = load(file) + val index = properties.getProperty("index")?.toIntOrNull() + ?: file.nameWithoutExtension.toIntOrNull() + ?: return@mapNotNull null + val summary = properties.getProperty("summary", "").takeIf { it.isNotBlank() } + ?: return@mapNotNull null + DesktopCachedSummaryItem( + index = index, + title = properties.getProperty("title", "Chapter ${index + 1}"), + summary = summary + ) + } + .sortedBy { it.index } + } + + fun deleteSummary(bookKey: String, index: Int) { + summaryFile(bookKey, index).delete() + } + + fun clearBookCache(bookKey: String) { + val directory = bookDirectory(bookKey) + directory.listFiles()?.forEach { file -> + if (file.isFile) file.delete() + } + } + + private fun summaryFile(bookKey: String, index: Int): File { + return bookDirectory(bookKey).resolve("$index.properties") + } + + private fun bookDirectory(bookKey: String): File { + return root.resolve(bookKey.cacheKey()) + } + + private fun load(file: File): Properties { + return Properties().apply { + runCatching { file.inputStream().use { input -> load(input) } } + } + } +} + +private fun String.cacheKey(): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(trim().ifBlank { "untitled" }.toByteArray(Charsets.UTF_8)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest).take(32) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt new file mode 100644 index 0000000..ddd623d --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLog.kt @@ -0,0 +1,40 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ReaderTtsChunk + +private const val DesktopTtsLogTag = "EpistemeDesktopTts" +private const val DesktopTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace" +private val DesktopTtsSensitiveQueryRegex = Regex("""(?i)([?&](?:key|token)=)[^&\s"]+""") +private val DesktopTtsSensitiveLabelRegex = Regex( + """(?i)\b((?:geminiKey|groqKey|api[_-]?key|authorization|token)\s*[:=]\s*)[^\s,;"]+""" +) + +internal fun logDesktopTts(message: String) { + logDesktopDiagnostic(DesktopTtsLogTag) { message } +} + +internal fun logDesktopTtsStartTrace(message: () -> String) { + logDesktopDiagnostic(DesktopTtsStartTraceLogTag, message) +} + +internal fun ReaderTtsChunk?.desktopTtsStartTraceSummary(maxTextLength: Int = 120): String { + if (this == null) return "null" + return "index=$index page=${pageIndex + 1} chapter=$chapterIndex " + + "offsets=$startOffset..$endOffset sourceCfi=\"${sourceCfi.orEmpty().logPreview(160)}\" " + + "textChars=${text.length} spokenChars=${spokenText.length} " + + "text=\"${text.logPreview(maxTextLength)}\" spoken=\"${spokenText.logPreview(maxTextLength)}\"" +} + +internal fun Throwable.desktopTtsSummary(): String { + val type = this::class.java.simpleName.ifBlank { "Throwable" } + return "$type: ${message.orEmpty().desktopTtsPreview(220)}" +} + +internal fun String.desktopTtsPreview(maxLength: Int = 120): String { + return replace(DesktopTtsSensitiveQueryRegex) { match -> match.groupValues[1] + "" } + .replace(DesktopTtsSensitiveLabelRegex) { match -> match.groupValues[1] + "" } + .replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt new file mode 100644 index 0000000..50aac69 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolish.kt @@ -0,0 +1,291 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.ptr.IntByReference +import com.sun.jna.win32.StdCallLibrary +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import java.awt.EventQueue +import java.awt.Color as AwtColor +import java.awt.Window as AwtWindow +import java.util.concurrent.atomic.AtomicReference +import javax.swing.RootPaneContainer +import javax.swing.SwingUtilities +import kotlinx.coroutines.delay + +internal const val EpistemeDesktopWindowTitle = EpistemeDesktopStandardAppName +internal const val EpistemeDesktopWindowIconResource = "episteme_icon.png" +internal const val EpistemeDesktopWindowMinimumWidthPx = 960 +internal const val EpistemeDesktopWindowMinimumHeightPx = 640 + +internal data class DesktopWindowDefaults( + val title: String, + val defaultSize: DpSize, + val minimumSize: Dimension, + val iconResourcePath: String +) + +internal fun epistemeDesktopWindowDefaults( + profile: DesktopBuildProfile = currentDesktopBuildProfile() +): DesktopWindowDefaults { + return DesktopWindowDefaults( + title = profile.appName, + defaultSize = DpSize(1280.dp, 820.dp), + minimumSize = Dimension(EpistemeDesktopWindowMinimumWidthPx, EpistemeDesktopWindowMinimumHeightPx), + iconResourcePath = EpistemeDesktopWindowIconResource + ) +} + +internal data class DesktopWindowChromeColors( + val useDarkMode: Boolean, + val captionColorRef: Int, + val textColorRef: Int, + val borderColorRef: Int +) + +internal fun desktopWindowChromeColors( + captionColor: Color, + textColor: Color, + borderColor: Color +): DesktopWindowChromeColors { + return DesktopWindowChromeColors( + useDarkMode = captionColor.luminance() < 0.5f, + captionColorRef = captionColor.toWindowsColorRef(), + textColorRef = textColor.toWindowsColorRef(), + borderColorRef = borderColor.toWindowsColorRef() + ) +} + +@Composable +internal fun EpistemeDesktopWindowChromeEffect( + window: Component?, + captionColor: Color, + textColor: Color, + borderColor: Color +) { + DisposableEffect(window, captionColor, textColor, borderColor) { + applyDesktopWindowBackground(window, borderColor) + applyWindowsDesktopWindowChrome( + window = window, + colors = desktopWindowChromeColors( + captionColor = captionColor, + textColor = textColor, + borderColor = borderColor + ) + ) + onDispose {} + } +} + +@Composable +internal fun EpistemeDesktopWindowDecorationEffect( + window: Component?, + hideDecoration: Boolean +) { + val originalStyle = remember(window) { AtomicReference(null) } + LaunchedEffect(window, hideDecoration) { + delay(if (hideDecoration) 120L else 80L) + applyWindowsDesktopWindowDecoration( + window = window, + hideDecoration = hideDecoration, + originalStyle = originalStyle + ) + } + DisposableEffect(window, hideDecoration) { + onDispose { + if (hideDecoration) { + applyWindowsDesktopWindowDecoration( + window = window, + hideDecoration = false, + originalStyle = originalStyle + ) + } + } + } +} + +internal fun isWindowsDesktop(osName: String = System.getProperty("os.name").orEmpty()): Boolean { + return osName.startsWith("Windows", ignoreCase = true) +} + +private fun applyDesktopWindowBackground(window: Component?, color: Color) { + val awtColor = color.toAwtOpaqueColor() + runOnEventDispatchThread { + val awtWindow = window.toAwtWindowOrNull() + window?.background = awtColor + awtWindow?.background = awtColor + (awtWindow as? Container)?.background = awtColor + (awtWindow as? RootPaneContainer)?.let { rootPaneContainer -> + rootPaneContainer.contentPane.background = awtColor + rootPaneContainer.rootPane.background = awtColor + rootPaneContainer.layeredPane.background = awtColor + rootPaneContainer.glassPane.background = awtColor + } + } +} + +private fun applyWindowsDesktopWindowChrome( + window: Component?, + colors: DesktopWindowChromeColors, + osName: String = System.getProperty("os.name").orEmpty() +) { + if (!isWindowsDesktop(osName)) return + runOnEventDispatchThread { + val awtWindow = window.toAwtWindowOrNull() ?: return@runOnEventDispatchThread + val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@runOnEventDispatchThread + WindowsDwmApi.applyWindowChrome(hwnd, colors) + } +} + +private fun applyWindowsDesktopWindowDecoration( + window: Component?, + hideDecoration: Boolean, + originalStyle: AtomicReference, + osName: String = System.getProperty("os.name").orEmpty() +) { + if (!isWindowsDesktop(osName)) return + EventQueue.invokeLater decoration@{ + val awtWindow = window.toAwtWindowOrNull() ?: return@decoration + val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@decoration + val api = runCatching { User32Api.INSTANCE }.getOrNull() ?: return@decoration + if (hideDecoration) { + val style = api.GetWindowLongW(hwnd, GWL_STYLE) + originalStyle.compareAndSet(null, style) + val fullscreenStyle = style and WS_CAPTION.inv() and WS_THICKFRAME.inv() + if (fullscreenStyle != style) { + api.SetWindowLongW(hwnd, GWL_STYLE, fullscreenStyle) + api.SetWindowPos( + hwnd, + null, + 0, + 0, + 0, + 0, + SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED + ) + } + } else { + val restoredStyle = originalStyle.getAndSet(null) ?: return@decoration + api.SetWindowLongW(hwnd, GWL_STYLE, restoredStyle) + api.SetWindowPos( + hwnd, + null, + 0, + 0, + 0, + 0, + SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED + ) + } + } +} + +private fun Component?.toAwtWindowOrNull(): AwtWindow? { + return when (this) { + null -> null + is AwtWindow -> this + else -> SwingUtilities.getWindowAncestor(this) + } +} + +private fun runOnEventDispatchThread(block: () -> Unit) { + if (EventQueue.isDispatchThread()) { + block() + } else { + EventQueue.invokeLater(block) + } +} + +private fun Color.toAwtOpaqueColor(): AwtColor { + val argb = toArgb() + return AwtColor( + (argb shr 16) and 0xFF, + (argb shr 8) and 0xFF, + argb and 0xFF + ) +} + +private fun Color.toWindowsColorRef(): Int { + val argb = toArgb() + val red = (argb shr 16) and 0xFF + val green = (argb shr 8) and 0xFF + val blue = argb and 0xFF + return red or (green shl 8) or (blue shl 16) +} + +private const val GWL_STYLE = -16 +private const val WS_CAPTION = 0x00C00000 +private const val WS_THICKFRAME = 0x00040000 +private const val SWP_NOSIZE = 0x0001 +private const val SWP_NOMOVE = 0x0002 +private const val SWP_NOZORDER = 0x0004 +private const val SWP_NOACTIVATE = 0x0010 +private const val SWP_FRAMECHANGED = 0x0020 + +private object WindowsDwmApi { + private const val DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 + private const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + private const val DWMWA_BORDER_COLOR = 34 + private const val DWMWA_CAPTION_COLOR = 35 + private const val DWMWA_TEXT_COLOR = 36 + + fun applyWindowChrome(hwnd: Pointer, colors: DesktopWindowChromeColors) { + val api = runCatching { DwmApi.INSTANCE }.getOrNull() ?: return + val darkModeValue = if (colors.useDarkMode) 1 else 0 + val darkModeResult = api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, darkModeValue) + if (darkModeResult != 0) { + api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, darkModeValue) + } + api.setIntAttribute(hwnd, DWMWA_CAPTION_COLOR, colors.captionColorRef) + api.setIntAttribute(hwnd, DWMWA_TEXT_COLOR, colors.textColorRef) + api.setIntAttribute(hwnd, DWMWA_BORDER_COLOR, colors.borderColorRef) + } + + private fun DwmApi.setIntAttribute(hwnd: Pointer, attribute: Int, value: Int): Int { + return runCatching { + val ref = IntByReference(value) + DwmSetWindowAttribute(hwnd, attribute, ref.pointer, Int.SIZE_BYTES) + }.getOrDefault(-1) + } +} + +private interface DwmApi : StdCallLibrary { + fun DwmSetWindowAttribute(hwnd: Pointer, attribute: Int, value: Pointer, valueSize: Int): Int + + companion object { + val INSTANCE: DwmApi by lazy { + Native.load("dwmapi", DwmApi::class.java) as DwmApi + } + } +} + +private interface User32Api : StdCallLibrary { + fun GetWindowLongW(hwnd: Pointer, index: Int): Int + fun SetWindowLongW(hwnd: Pointer, index: Int, value: Int): Int + fun SetWindowPos( + hwnd: Pointer, + insertAfter: Pointer?, + x: Int, + y: Int, + cx: Int, + cy: Int, + flags: Int + ): Boolean + + companion object { + val INSTANCE: User32Api by lazy { + Native.load("user32", User32Api::class.java) as User32Api + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt new file mode 100644 index 0000000..3b5408b --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStore.kt @@ -0,0 +1,159 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +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.floatOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private const val DesktopWindowStateSchemaVersion = 1 + +internal enum class DesktopSavedWindowPlacement { + FLOATING, + MAXIMIZED, + FULLSCREEN +} + +internal data class DesktopWindowStateSnapshot( + val placement: DesktopSavedWindowPlacement, + val widthDp: Float, + val heightDp: Float, + val xDp: Float? = null, + val yDp: Float? = null +) { + fun toWindowPlacement(): WindowPlacement { + return when (placement) { + DesktopSavedWindowPlacement.FLOATING -> WindowPlacement.Floating + DesktopSavedWindowPlacement.MAXIMIZED -> WindowPlacement.Maximized + DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Fullscreen + } + } + + fun toWindowSize(defaultSize: DpSize): DpSize { + val width = widthDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumWidthPx.toFloat() } + val height = heightDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumHeightPx.toFloat() } + return DpSize( + width = width?.dp ?: defaultSize.width, + height = height?.dp ?: defaultSize.height + ) + } + + fun toWindowPosition(): WindowPosition { + val x = xDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault + val y = yDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault + return WindowPosition(x.dp, y.dp) + } + + fun sanitized(): DesktopWindowStateSnapshot { + return copy( + widthDp = widthDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumWidthPx.toFloat()) + ?: EpistemeDesktopWindowMinimumWidthPx.toFloat(), + heightDp = heightDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumHeightPx.toFloat()) + ?: EpistemeDesktopWindowMinimumHeightPx.toFloat(), + xDp = xDp?.takeIf { it.isFinite() }, + yDp = yDp?.takeIf { it.isFinite() } + ) + } + + fun toJsonObject(): JsonObject { + val sanitized = sanitized() + return JsonObject( + buildMap { + put("schemaVersion", JsonPrimitive(DesktopWindowStateSchemaVersion)) + put("placement", JsonPrimitive(sanitized.placement.name)) + put("widthDp", JsonPrimitive(sanitized.widthDp)) + put("heightDp", JsonPrimitive(sanitized.heightDp)) + put("xDp", sanitized.xDp?.let { JsonPrimitive(it) } ?: JsonNull) + put("yDp", sanitized.yDp?.let { JsonPrimitive(it) } ?: JsonNull) + } + ) + } + + companion object { + fun default(): DesktopWindowStateSnapshot { + return DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.MAXIMIZED, + widthDp = 1280f, + heightDp = 820f + ) + } + + fun fromWindowState(state: WindowState): DesktopWindowStateSnapshot? { + if (state.isMinimized) return null + val size = state.size + val width = size.width.value.takeIf { it.isFinite() } ?: return null + val height = size.height.value.takeIf { it.isFinite() } ?: return null + val placement = when (state.placement) { + WindowPlacement.Floating -> DesktopSavedWindowPlacement.FLOATING + WindowPlacement.Maximized -> DesktopSavedWindowPlacement.MAXIMIZED + WindowPlacement.Fullscreen -> DesktopSavedWindowPlacement.FULLSCREEN + } + val position = state.position.takeIf { it.isSpecified } + return DesktopWindowStateSnapshot( + placement = placement, + widthDp = width, + heightDp = height, + xDp = position?.x?.value?.takeIf { it.isFinite() }, + yDp = position?.y?.value?.takeIf { it.isFinite() } + ).sanitized() + } + + fun fromJsonElement(element: JsonElement): DesktopWindowStateSnapshot? { + val obj = runCatching { element.jsonObject }.getOrNull() ?: return null + val placement = obj["placement"] + ?.jsonPrimitive + ?.content + ?.let { runCatching { DesktopSavedWindowPlacement.valueOf(it) }.getOrNull() } + ?: DesktopSavedWindowPlacement.MAXIMIZED + val width = obj["widthDp"]?.jsonPrimitive?.floatOrNull ?: return null + val height = obj["heightDp"]?.jsonPrimitive?.floatOrNull ?: return null + return DesktopWindowStateSnapshot( + placement = placement, + widthDp = width, + heightDp = height, + xDp = obj["xDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull, + yDp = obj["yDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull + ).sanitized() + } + } +} + +internal class DesktopWindowStateStore( + private val stateFile: File = defaultWindowStateFile() +) { + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + fun load(): DesktopWindowStateSnapshot? { + if (!stateFile.exists()) return null + return runCatching { + DesktopWindowStateSnapshot.fromJsonElement(json.parseToJsonElement(stateFile.readText())) + }.getOrNull() + } + + fun save(snapshot: DesktopWindowStateSnapshot) { + stateFile.parentFile?.mkdirs() + stateFile.writeText(json.encodeToString(JsonElement.serializer(), snapshot.toJsonObject())) + } + + companion object { + fun defaultWindowStateFile(): File { + return File(desktopUserConfigRoot(), "window_state.json") + } + + fun defaultReaderWindowStateFile(): File { + return File(desktopUserConfigRoot(), "reader_window_state.json") + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt new file mode 100644 index 0000000..56e2ce9 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowsWebView2EpubWebView.kt @@ -0,0 +1,1910 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.UserHighlight +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.ui.ReaderContentNavigationTarget +import org.dueattendant149.bookreader.shared.ui.readerString +import kotlinx.coroutines.delay +import org.eclipse.swt.SWT +import org.eclipse.swt.awt.SWT_AWT +import org.eclipse.swt.browser.Browser +import org.eclipse.swt.browser.BrowserFunction +import org.eclipse.swt.browser.LocationEvent +import org.eclipse.swt.browser.LocationListener +import org.eclipse.swt.browser.ProgressAdapter +import org.eclipse.swt.browser.ProgressEvent +import org.eclipse.swt.widgets.Display +import org.eclipse.swt.widgets.Shell +import java.awt.Canvas +import java.awt.EventQueue +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities + +@Composable +internal fun DesktopNativeSwtEpubWebView( + html: String, + appearanceScript: String, + highlightPaletteScript: String, + navigationTarget: ReaderContentNavigationTarget, + highlights: List, + onHighlightCreated: (UserHighlight) -> Unit, + onHighlightSelected: (String) -> Unit, + isFullscreen: Boolean, + onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit, + onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit, + onLinkClicked: (DesktopEpubLinkClick) -> Unit, + onVisiblePageChanged: (Int, ReaderLocator?) -> Unit, + onPointerActivity: () -> Unit = {}, + networkAccessEnabled: Boolean, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + val backend = remember { desktopEpubWebViewBackend() } + if (backend == DesktopEpubWebViewBackend.UNSUPPORTED) { + DesktopNativeWebViewError( + backend = backend, + message = desktopNativeWebViewUnavailableMessage(backend), + modifier = modifier.fillMaxSize() + ) + return + } + val latestOnLinkClicked by rememberUpdatedState(onLinkClicked) + val bridgeHandlers = rememberDesktopEpubBridgeHandlers( + onHighlightCreated = onHighlightCreated, + onHighlightSelected = onHighlightSelected, + onKeyboardNavigation = onKeyboardNavigation, + onSelectionAction = onSelectionAction, + onLinkClicked = onLinkClicked, + onVisiblePageChanged = onVisiblePageChanged, + onPointerActivity = onPointerActivity + ) + val bridgeHandlersByMethod = remember(bridgeHandlers) { + bridgeHandlers.associateBy { it.methodName } + } + val hostBackground = remember(backgroundColor) { backgroundColor.toAwtColor() } + val panel = remember { DesktopWindowsWebView2Panel(hostBackground, backend) } + val composeDensity = LocalDensity.current + var loaded by remember { mutableStateOf(false) } + var loadProgress by remember { mutableFloatStateOf(-1f) } + var errorMessage by remember { mutableStateOf(null) } + val webViewHtml = remember(html, networkAccessEnabled) { + html.withDesktopWebView2Bootstrap(networkAccessEnabled = networkAccessEnabled) + } + + DisposableEffect(panel) { + onDispose { + logDesktopWebView2("compose_dispose panel=${panel.instanceId}") + panel.disposeWebView(waitForSwtDisposal = true) + } + } + + LaunchedEffect(hostBackground) { + panel.updateBackground(hostBackground) + } + + Box( + modifier = modifier.fillMaxSize().onSizeChanged { size -> + logWebViewLayoutDiag( + "compose_webview_box panel=${panel.instanceId} size=${size.width}x${size.height} " + + "loaded=$loaded network=$networkAccessEnabled navMode=${navigationTarget.readingMode} " + + "composeDensity=${composeDensity.density.formatLogFloat()}" + ) + } + ) { + SwingPanel( + background = backgroundColor, + factory = { panel }, + update = { currentPanel -> + currentPanel.configure( + bridgeHandlersByMethod = bridgeHandlersByMethod, + networkAccessEnabled = networkAccessEnabled, + onLinkIntercepted = { link -> latestOnLinkClicked(link) }, + onLoadStateChanged = { isLoaded, progress -> + loaded = isLoaded + loadProgress = progress + }, + onError = { message -> + errorMessage = message + loaded = false + loadProgress = -1f + } + ) + }, + modifier = Modifier + .matchParentSize() + .onSizeChanged { size -> + logWebViewLayoutDiag( + "compose_swing_panel panel=${panel.instanceId} size=${size.width}x${size.height} " + + "loaded=$loaded composeDensity=${composeDensity.density.formatLogFloat()}" + ) + } + ) + + LaunchedEffect(webViewHtml) { + loaded = false + loadProgress = -1f + errorMessage = null + logDesktopWebView2( + "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + + "wrappedHtmlChars=${webViewHtml.length} rawHash=${html.hashCode()} wrappedHash=${webViewHtml.hashCode()} " + + "network=$networkAccessEnabled" + ) + logWebViewLayoutDiag( + "compose_load_request panel=${panel.instanceId} rawHtmlChars=${html.length} " + + "wrappedHtmlChars=${webViewHtml.length} navMode=${navigationTarget.readingMode} " + + "background=${backgroundColor.toArgb()}" + ) + panel.loadHtml(webViewHtml) + } + + LaunchedEffect(loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_loaded panel=${panel.instanceId} action=install_key_navigation") + panel.executeJavaScript(DesktopEpubKeyNavigationScript) + } + + LaunchedEffect(isFullscreen, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_script panel=${panel.instanceId} name=fullscreen value=$isFullscreen") + panel.executeJavaScript( + "window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};" + + "window.dispatchEvent(new Event('resize'));" + ) + panel.relayoutWebView("fullscreen_state_changed") + DesktopWebView2FullscreenRelayoutDelaysMillis.forEach { delayMillis -> + delay(delayMillis) + panel.relayoutWebView("fullscreen_state_changed_after_${delayMillis}ms") + panel.executeJavaScript("window.dispatchEvent(new Event('resize'));") + } + } + + LaunchedEffect(html, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2("compose_script panel=${panel.instanceId} name=desktop_finished") + panel.executeJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');") + } + + LaunchedEffect(appearanceScript, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=appearance chars=${appearanceScript.length} hash=${appearanceScript.hashCode()}" + ) + panel.executeJavaScript(appearanceScript + "\n" + desktopWebView2DocumentProbeScript("appearance_applied")) + } + + LaunchedEffect(highlightPaletteScript, loaded) { + if (!loaded) return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=highlight_palette chars=${highlightPaletteScript.length} " + + "hash=${highlightPaletteScript.hashCode()}" + ) + panel.executeJavaScript(highlightPaletteScript) + } + + LaunchedEffect( + navigationTarget.requestId, + navigationTarget.readingMode, + loaded + ) { + if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect + if (!loaded) return@LaunchedEffect + val locator = navigationTarget.locator ?: return@LaunchedEffect + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=scroll_locator request=${navigationTarget.requestId} " + + "chapter=${locator.chapterIndex} page=${locator.pageIndex}" + ) + panel.executeJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});") + } + + LaunchedEffect( + navigationTarget.ttsRequestId, + navigationTarget.ttsLocator, + navigationTarget.readingMode, + loaded + ) { + if (!loaded) return@LaunchedEffect + val locator = navigationTarget.ttsLocator + val command = if (locator == null) { + logDesktopTts( + "epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}" + ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);" + } else { + val follow = navigationTarget.readingMode == ReaderReadingMode.VERTICAL + logDesktopTts( + "epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " + + "follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " + + "offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " + + "text=\"${locator.textQuote.orEmpty().logPreview()}\"" + ) + "window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);" + } + panel.executeJavaScript(command) + } + + LaunchedEffect(highlights, loaded) { + if (!loaded) return@LaunchedEffect + val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights) + logDesktopWebView2( + "compose_script panel=${panel.instanceId} name=apply_highlights count=${highlights.size} chars=${highlightsJson.length}" + ) + panel.executeJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);") + } + + if (errorMessage != null) { + DesktopNativeWebViewError( + backend = backend, + message = errorMessage.orEmpty(), + modifier = Modifier.fillMaxSize() + ) + } else if (!loaded) { + if (loadProgress in 0f..1f) { + LinearProgressIndicator( + progress = { loadProgress }, + modifier = Modifier.fillMaxWidth() + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun DesktopNativeWebViewError( + backend: DesktopEpubWebViewBackend, + message: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = readerString( + "desktop_native_webview_start_error", + "%1\$s could not start: %2\$s", + backend.displayName, + message + ), + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } +} + +private class DesktopWindowsWebView2Panel( + initialBackground: java.awt.Color, + private val backend: DesktopEpubWebViewBackend +) : Canvas() { + val instanceId: Int = nextDesktopWebView2InstanceId() + + @Volatile + private var bridgeHandlersByMethod: Map = emptyMap() + + @Volatile + private var networkAccessEnabled: Boolean = true + + @Volatile + private var onLinkIntercepted: (DesktopEpubLinkClick) -> Unit = {} + + @Volatile + private var onLoadStateChanged: (Boolean, Float) -> Unit = { _, _ -> } + + @Volatile + private var onError: (String) -> Unit = {} + + private var controller: DesktopWindowsWebView2Controller? = null + private var requestedHtml: String? = null + + @Volatile + private var lastLoadStartedAtNanos: Long = 0L + + val hasController: Boolean get() = controller != null + + @Volatile + private var disposeInProgress = false + + @Volatile + private var hostWindowClosing = false + + private var hostWindow: java.awt.Window? = null + private var hostWindowListener: WindowAdapter? = null + + init { + background = initialBackground + updateModeSwitchPanelState("init") + addComponentListener( + object : ComponentAdapter() { + override fun componentResized(event: ComponentEvent) { + logDesktopWebView2("panel_resized panel=$instanceId size=${width}x${height}") + logWebViewLayoutDiag( + "awt_canvas_resized panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_resized") + controller?.resize(width, height, reason = "component_resized") + } + + override fun componentMoved(event: ComponentEvent) { + logWebViewLayoutDiag( + "awt_canvas_moved panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_moved") + controller?.resize(width, height, reason = "component_moved") + } + + override fun componentShown(event: ComponentEvent) { + logWebViewLayoutDiag( + "awt_canvas_shown panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()}" + ) + updateModeSwitchPanelState("component_shown") + controller?.resize(width, height, reason = "component_shown") + } + } + ) + } + + fun relayoutWebView(reason: String) { + EventQueue.invokeLater { + updateModeSwitchPanelState("relayout_$reason") + logWebViewLayoutDiag( + "awt_canvas_relayout panel=$instanceId reason=$reason size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} displayable=$isDisplayable" + ) + revalidate() + repaint() + controller?.resize(width, height, reason = reason) + } + } + + fun updateBackground(color: java.awt.Color) { + EventQueue.invokeLater { + if (background != color) { + background = color + repaint() + } + } + } + + fun configure( + bridgeHandlersByMethod: Map, + networkAccessEnabled: Boolean, + onLinkIntercepted: (DesktopEpubLinkClick) -> Unit, + onLoadStateChanged: (Boolean, Float) -> Unit, + onError: (String) -> Unit + ) { + updateHostWindowListener() + this.bridgeHandlersByMethod = bridgeHandlersByMethod + this.networkAccessEnabled = networkAccessEnabled + this.onLinkIntercepted = onLinkIntercepted + this.onLoadStateChanged = { isLoaded, progress -> + if (isLoaded) { + val startedAt = lastLoadStartedAtNanos + if (startedAt > 0L) { + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_loaded panel=$instanceId " + + "durationMs=${startedAt.elapsedOpenTraceMs()} progress=$progress" + } + lastLoadStartedAtNanos = 0L + } + } + onLoadStateChanged(isLoaded, progress) + } + this.onError = { message -> + val startedAt = lastLoadStartedAtNanos + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_error panel=$instanceId " + + "durationMs=${if (startedAt > 0L) startedAt.elapsedOpenTraceMs() else -1L} " + + "message=\"${message.logPreview(240)}\"" + } + lastLoadStartedAtNanos = 0L + onError(message) + } + logDesktopWebView2( + "panel_configure panel=$instanceId handlers=${bridgeHandlersByMethod.size} network=$networkAccessEnabled " + + "controller=${controller != null}" + ) + updateModeSwitchPanelState("configure") + } + + fun loadHtml(html: String) { + if (requestedHtml == html) { + logDesktopWebView2("panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}") + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_load_skip_duplicate panel=$instanceId htmlHash=${html.hashCode()}" + } + return + } + lastLoadStartedAtNanos = System.nanoTime() + requestedHtml = html + logDesktopWebView2( + "panel_load_requested panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} " + + "controller=${controller != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_panel_load_requested panel=$instanceId htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} controller=${controller != null} canvas=${width}x${height}" + } + logWebViewLayoutDiag( + "panel_load_requested panel=$instanceId canvas=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} controller=${controller != null}" + ) + updateModeSwitchPanelState("load_requested") + ensureController(reason = "load_requested") + controller?.loadHtml(html) + } + + fun executeJavaScript(script: String) { + logDesktopWebView2( + "panel_execute panel=$instanceId scriptChars=${script.length} scriptHash=${script.hashCode()} controller=${controller != null}" + ) + controller?.executeJavaScript(script) + } + + fun disposeWebView( + waitForSwtDisposal: Boolean = false, + detachAwtCanvas: Boolean = true + ) { + if (disposeInProgress) { + logDesktopWebView2( + "panel_dispose_skip panel=$instanceId reason=in_progress controller=${controller != null}" + ) + updateModeSwitchPanelState("dispose_skip_in_progress") + return + } + disposeInProgress = true + logDesktopWebView2( + "panel_dispose panel=$instanceId controller=${controller != null} " + + "waitForSwtDisposal=$waitForSwtDisposal detachAwtCanvas=$detachAwtCanvas" + ) + try { + updateModeSwitchPanelState("dispose_begin") + if (detachAwtCanvas) { + retireAwtCanvasFromReaderSurface() + } + controller?.dispose(waitForCompletion = waitForSwtDisposal) + controller = null + updateModeSwitchPanelState("dispose_end") + } finally { + disposeInProgress = false + } + } + + override fun addNotify() { + super.addNotify() + updateHostWindowListener() + updateModeSwitchPanelState("add_notify") + logDesktopWebView2( + "panel_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + + "canvas=${width}x${height} controller=${controller != null} hasHtml=${requestedHtml != null}" + ) + logWebViewLayoutDiag( + "awt_canvas_add_notify panel=$instanceId displayable=$isDisplayable showing=$isShowing " + + "size=${width}x${height} bounds=${bounds.formatAwtBounds()} screen=${safeScreenLocationLog()} " + + "hasHtml=${requestedHtml != null}" + ) + ensureController(reason = "add_notify") + requestedHtml?.let { html -> controller?.loadHtml(html) } + controller?.resize(width, height, reason = "add_notify") + } + + override fun removeNotify() { + logDesktopWebView2("panel_remove_notify panel=$instanceId") + updateModeSwitchPanelState("remove_notify_begin") + updateHostWindowListener() + disposeWebView( + waitForSwtDisposal = true, + detachAwtCanvas = shouldRetireAwtCanvasFromReaderSurface() + ) + clearHostWindowListener() + super.removeNotify() + updateModeSwitchPanelState("remove_notify_end") + } + + private fun updateHostWindowListener() { + val window = SwingUtilities.getWindowAncestor(this) + if (hostWindow === window) return + clearHostWindowListener() + hostWindow = window + hostWindowClosing = false + if (window == null) return + val listener = object : WindowAdapter() { + override fun windowClosing(event: WindowEvent?) { + hostWindowClosing = true + logDesktopWebView2("panel_host_window_closing panel=$instanceId") + updateModeSwitchPanelState("host_window_closing") + } + + override fun windowClosed(event: WindowEvent?) { + hostWindowClosing = true + logDesktopWebView2("panel_host_window_closed panel=$instanceId") + updateModeSwitchPanelState("host_window_closed") + } + } + hostWindowListener = listener + window.addWindowListener(listener) + } + + private fun clearHostWindowListener() { + hostWindowListener?.let { listener -> + hostWindow?.removeWindowListener(listener) + } + hostWindowListener = null + hostWindow = null + } + + private fun shouldRetireAwtCanvasFromReaderSurface(): Boolean { + val window = hostWindow ?: SwingUtilities.getWindowAncestor(this) + val shouldRetire = desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = hostWindowClosing, + hostWindowDisplayable = window?.isDisplayable == true + ) + if (!shouldRetire) { + logDesktopWebView2( + "panel_retire_skip panel=$instanceId reason=host_window_closing_or_disposed " + + "hostClosing=$hostWindowClosing host=${window.formatAwtComponentState()}" + ) + updateModeSwitchPanelState("retire_skip_host_window_closing_or_disposed") + } + return shouldRetire + } + + private fun retireAwtCanvasFromReaderSurface() { + if (!shouldRetireAwtCanvasFromReaderSurface()) return + runOnAwtEventThreadBlocking( + onError = { error -> + logDesktopWebView2( + "panel_retire_failed panel=$instanceId error=\"${error.message.orEmpty().logPreview(300)}\"" + ) + } + ) { + logWebViewLayoutDiag( + "awt_canvas_retire panel=$instanceId size=${width}x${height} " + + "bounds=${bounds.formatAwtBounds()} displayable=$isDisplayable visible=$isVisible" + ) + updateModeSwitchPanelState("retire_begin") + val parentContainer = parent + val grandParent = parentContainer?.parent + logReaderModeSwitch( + "webview2_interop_retire_begin panel=$instanceId " + + "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" + ) + isVisible = false + setBounds(0, 0, 0, 0) + parentContainer?.isVisible = false + parentContainer?.setBounds(0, 0, 0, 0) + parentContainer?.revalidate() + parentContainer?.repaint() + grandParent?.revalidate() + grandParent?.repaint() + repaint() + scheduleRetiredInteropHostCleanup(parentContainer, grandParent, reason = "retire") + updateModeSwitchPanelState("retire_end") + logReaderModeSwitch( + "webview2_interop_retire_end panel=$instanceId " + + "parent=${parentContainer.formatAwtComponentState()} grandParent=${grandParent.formatAwtComponentState()}" + ) + } + } + + private fun scheduleRetiredInteropHostCleanup( + parentContainer: java.awt.Container?, + grandParent: java.awt.Container?, + reason: String + ) { + if (parentContainer == null || grandParent == null) return + EventQueue.invokeLater { + cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_next_event") + } + EventQueue.invokeLater { + DesktopWebView2InteropHostCleanupDelaysMillis.forEach { delayMillis -> + javax.swing.Timer(delayMillis.toInt()) { _ -> + cleanupRetiredInteropHost(parentContainer, grandParent, "${reason}_after_${delayMillis}ms") + }.apply { + isRepeats = false + start() + } + } + } + } + + private fun cleanupRetiredInteropHost( + parentContainer: java.awt.Container, + grandParent: java.awt.Container, + reason: String + ) { + val isInteropHost = parentContainer.javaClass.simpleName == DesktopSwingInteropHostClassName + val ownsOnlyRetiredPanel = parentContainer.components.all { component -> + component === this || !component.isDisplayable || !component.isShowing + } + if (!isInteropHost || !ownsOnlyRetiredPanel || parentContainer.parent !== grandParent) { + logReaderModeSwitch( + "webview2_interop_host_cleanup_skip panel=$instanceId reason=$reason " + + "isInteropHost=$isInteropHost ownsOnlyRetiredPanel=$ownsOnlyRetiredPanel " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + + "children=${parentContainer.formatAwtChildrenState()}" + ) + return + } + logReaderModeSwitch( + "webview2_interop_host_cleanup_begin panel=$instanceId reason=$reason " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "children=${parentContainer.formatAwtChildrenState()}" + ) + if (parent === parentContainer) { + parentContainer.remove(this) + } + parentContainer.removeAll() + grandParent.remove(parentContainer) + parentContainer.invalidate() + grandParent.invalidate() + grandParent.validate() + grandParent.repaint() + updateModeSwitchPanelState("interop_host_cleanup_$reason") + logReaderModeSwitch( + "webview2_interop_host_cleanup_end panel=$instanceId reason=$reason " + + "parent=${parentContainer.formatAwtComponentState()} " + + "grandParent=${grandParent.formatAwtComponentState()} " + + "parentParent=${parentContainer.parent?.javaClass?.simpleName ?: "none"} " + + "grandParentChildren=${grandParent.componentCount}" + ) + } + + private fun updateModeSwitchPanelState(event: String) { + val snapshot = modeSwitchPanelSnapshot(event) + DesktopWebView2ModeSwitchPanelStates[instanceId] = snapshot + logReaderModeSwitch("webview2_panel $snapshot") + } + + fun modeSwitchPanelSnapshot(event: String): String { + val parentContainer = parent + val parentName = parentContainer?.javaClass?.simpleName ?: "none" + val parentDetails = parentContainer.formatAwtComponentState() + return "panel=$instanceId event=$event visible=$isVisible displayable=$isDisplayable " + + "showing=$isShowing size=${width}x${height} bounds=${bounds.formatAwtBounds()} " + + "parent=$parentName parentState=$parentDetails controller=${controller != null} hasHtml=${requestedHtml != null}" + } + + private fun ensureController(reason: String) { + if (controller != null) return + if (!isDisplayable) { + logDesktopWebView2("panel_controller_skip panel=$instanceId reason=$reason displayable=false") + return + } + logDesktopWebView2( + "panel_controller_create panel=$instanceId reason=$reason backend=${backend.logName} hasHtml=${requestedHtml != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create panel=$instanceId reason=$reason " + + "backend=${backend.logName} hasHtml=${requestedHtml != null} canvas=${width}x${height}" + } + var createdController: DesktopWindowsWebView2Controller? = null + val newController = DesktopWindowsWebView2Controller( + instanceId = instanceId, + backend = backend, + canvas = this, + isNetworkAccessEnabled = { networkAccessEnabled }, + dispatchBridgeMessage = { method, params -> + EventQueue.invokeLater { + bridgeHandlersByMethod[method]?.onMessage(params) + } + }, + dispatchLinkClick = { link -> + EventQueue.invokeLater { + onLinkIntercepted(link) + } + }, + updateLoadState = { isLoaded, progress -> + EventQueue.invokeLater { + onLoadStateChanged(isLoaded, progress) + } + }, + reportError = { error -> + val message = error.desktopNativeWebViewMessage(backend) + EventQueue.invokeLater { + createdController?.let { failedController -> + if (controller === failedController) { + controller = null + } + } + onError(message) + } + } + ) + createdController = newController + controller = newController + } +} + +private class DesktopWindowsWebView2Controller( + private val instanceId: Int, + private val backend: DesktopEpubWebViewBackend, + private val canvas: Canvas, + private val isNetworkAccessEnabled: () -> Boolean, + private val dispatchBridgeMessage: (String, String) -> Unit, + private val dispatchLinkClick: (DesktopEpubLinkClick) -> Unit, + private val updateLoadState: (Boolean, Float) -> Unit, + private val reportError: (Throwable) -> Unit +) { + @Volatile + private var disposed = false + + private var shell: Shell? = null + private var browser: Browser? = null + private var bridgeFunction: BrowserFunction? = null + + @Volatile + private var pendingHtml: String? = null + + @Volatile + private var lastBrowserBoundsLog: String = "" + + init { + logDesktopWebView2("controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height}") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_init panel=$instanceId backend=${backend.logName} " + + "canvas=${canvas.width}x${canvas.height}" + } + logWebViewLayoutDiag( + "controller_init panel=$instanceId backend=${backend.logName} canvas=${canvas.width}x${canvas.height} " + + "canvasBounds=${canvas.bounds.formatAwtBounds()} screen=${canvas.safeScreenLocationLog()}" + ) + DesktopSwtWebView2EventLoop.asyncExec(reportError) { display -> + if (!disposed) createBrowser(display) + } + } + + fun loadHtml(html: String) { + logDesktopWebView2( + "controller_load_enqueue panel=$instanceId htmlChars=${html.length} htmlHash=${html.hashCode()} browser=${browser != null}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_load_enqueue panel=$instanceId htmlChars=${html.length} " + + "htmlHash=${html.hashCode()} browser=${browser != null}" + } + logWebViewLayoutDiag( + "controller_load_enqueue panel=$instanceId htmlChars=${html.length} browser=${browser != null} " + + "canvas=${canvas.width}x${canvas.height} browserBounds=$lastBrowserBoundsLog" + ) + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + updateLoadState(false, -1f) + pendingHtml = html + val webView = browser + if (webView == null || webView.isDisposed) { + logDesktopWebView2("controller_load_pending panel=$instanceId reason=browser_not_ready") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_load_pending panel=$instanceId reason=browser_not_ready" + } + } else { + pendingHtml = null + setBrowserText(webView, html, reason = "load") + } + } + } + + fun executeJavaScript(script: String) { + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + val webView = browser + if (webView == null || webView.isDisposed) { + logDesktopWebView2( + "controller_execute_drop panel=$instanceId reason=browser_not_ready " + + "scriptChars=${script.length} scriptHash=${script.hashCode()}" + ) + } else { + val executed = webView.execute(script) + logDesktopWebView2( + "controller_execute panel=$instanceId executed=$executed scriptChars=${script.length} scriptHash=${script.hashCode()}" + ) + } + } + } + + fun resize(width: Int, height: Int, reason: String = "resize") { + DesktopSwtWebView2EventLoop.asyncExec(reportError) { + if (disposed) return@asyncExec + applyCanvasSizeToBrowser(width, height, reason = reason) + } + } + + fun dispose(waitForCompletion: Boolean = false) { + if (disposed) return + disposed = true + logDesktopWebView2("controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") + logReaderModeSwitch("webview2_controller_dispose panel=$instanceId waitForCompletion=$waitForCompletion") + if (waitForCompletion) { + DesktopSwtWebView2EventLoop.syncExec({}) { + disposeSwtWidgets() + } + } else { + DesktopSwtWebView2EventLoop.asyncExec({}) { + disposeSwtWidgets() + } + } + } + + private fun disposeSwtWidgets() { + logReaderModeSwitch( + "webview2_swt_dispose_begin panel=$instanceId shell=${shell?.isDisposed == false} browser=${browser?.isDisposed == false}" + ) + bridgeFunction?.takeUnless { it.isDisposed }?.dispose() + bridgeFunction = null + browser?.takeUnless { it.isDisposed }?.dispose() + browser = null + shell?.takeUnless { it.isDisposed }?.dispose() + shell = null + lastBrowserBoundsLog = "" + logReaderModeSwitch("webview2_swt_dispose_end panel=$instanceId") + } + + private fun createBrowser(display: Display) { + logDesktopWebView2("controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}") + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create_start panel=$instanceId displayDisposed=${display.isDisposed}" + } + runCatching { + shell = SWT_AWT.new_Shell(display, canvas) + logDesktopWebView2("controller_shell_created panel=$instanceId shellDisposed=${shell?.isDisposed == true}") + logWebViewLayoutDiag( + "swt_shell_created panel=$instanceId canvas=${canvas.width}x${canvas.height} " + + "canvasBounds=${canvas.bounds.formatAwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" + ) + val webView = Browser(shell, backend.swtBrowserStyle()) + browser = webView + val swtBackground = org.eclipse.swt.graphics.Color( + display, + canvas.background.red, + canvas.background.green, + canvas.background.blue + ) + shell?.background = swtBackground + webView.background = swtBackground + shell?.addDisposeListener { + if (!swtBackground.isDisposed) swtBackground.dispose() + } + val browserType = webView.browserType.orEmpty() + logDesktopWebView2( + "controller_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\"" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_browser_created panel=$instanceId backend=${backend.logName} " + + "browserType=\"${browserType.logPreview(120)}\"" + } + logWebViewLayoutDiag( + "swt_browser_created panel=$instanceId backend=${backend.logName} browserType=\"$browserType\" " + + "browserBounds=${webView.bounds.formatSwtBounds()} shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()}" + ) + check(backend.acceptsBrowserType(browserType)) { + "${backend.displayName} is not available; SWT opened '${browserType.ifBlank { "unknown" }}' instead." + } + val warmupHtml = desktopWebView2WarmupHtml(canvas.background) + val warmupAccepted = webView.setText(warmupHtml) + logDesktopReaderOpenTrace { + "event=desktop_webview_warmup_loaded panel=$instanceId accepted=$warmupAccepted " + + "background=\"${canvas.background.toCssHex()}\"" + } + run { + bridgeFunction = object : BrowserFunction(webView, DesktopWebView2NativeBridgeName) { + override fun function(arguments: Array): Any? { + val method = arguments.getOrNull(0)?.toString().orEmpty() + if (method.isBlank()) return null + val params = arguments.getOrNull(1)?.toString() ?: "{}" + if (method == DesktopWebView2DiagnosticMethodName) { + val preview = params.logPreview(6000) + logDesktopWebView2("bridge_diagnostic panel=$instanceId params=\"$preview\"") + logWebViewLayoutDiag("document_probe panel=$instanceId params=\"$preview\"") + } else { + logDesktopWebView2( + "bridge_message panel=$instanceId method=$method paramsChars=${params.length} params=\"${params.logPreview()}\"" + ) + dispatchBridgeMessage(method, params) + } + return null + } + } + webView.addLocationListener( + object : LocationListener { + override fun changing(event: LocationEvent) { + val location = event.location.orEmpty() + logDesktopWebView2( + "location_changing panel=$instanceId top=${event.top} doit=${event.doit} " + + "location=\"${location.logPreview()}\"" + ) + if (!isNetworkAccessEnabled() && location.isRemoteNetworkUrl()) { + logEpubLink("request_blocked_offline url=\"${location.logPreview()}\"") + event.doit = false + return + } + val link = location.readerLinkClickFromIntercept() ?: return + logEpubLink( + "request_intercept_webview2 url=\"${location.logPreview()}\" " + + "href=\"${link.href.logPreview()}\"" + ) + event.doit = false + dispatchLinkClick(link.copy(source = "request")) + } + + override fun changed(event: LocationEvent) = Unit + } + ) + webView.addProgressListener( + object : ProgressAdapter() { + private var lastLoggedProgressBucket = -1 + + override fun changed(event: ProgressEvent) { + val total = event.total + val progress = if (total > 0) { + event.current.coerceIn(0, total).toFloat() / total.toFloat() + } else { + -1f + } + val bucket = if (progress < 0f) { + -1 + } else { + (progress * 4).toInt().coerceIn(0, 4) + } + if (bucket != lastLoggedProgressBucket) { + lastLoggedProgressBucket = bucket + logDesktopWebView2( + "progress_changed panel=$instanceId current=${event.current} total=${event.total} " + + "progress=${if (progress < 0f) "unknown" else progress.formatLogFloat()}" + ) + } + updateLoadState(false, progress) + } + + override fun completed(event: ProgressEvent) { + val bridgeInjected = webView.execute(DesktopWebView2BridgeRuntimeScript) + applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "load_completed") + val probeInjected = webView.execute(desktopWebView2DocumentProbeScript("load_completed")) + logDesktopWebView2( + "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + + "current=${event.current} total=${event.total}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_progress_completed panel=$instanceId " + + "bridgeInjected=$bridgeInjected probeInjected=$probeInjected " + + "current=${event.current} total=${event.total}" + } + logWebViewLayoutDiag( + "progress_completed panel=$instanceId bridgeInjected=$bridgeInjected " + + "current=${event.current} total=${event.total}" + ) + updateLoadState(true, 1f) + } + } + ) + pendingHtml?.let { html -> + pendingHtml = null + setBrowserText(webView, html, reason = "browser_ready") + } + } + applyCanvasSizeToBrowser(canvas.width, canvas.height, reason = "open") + shell?.open() + logDesktopWebView2( + "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "browserBounds=${browser?.bounds?.width ?: -1}x${browser?.bounds?.height ?: -1}" + } + logWebViewLayoutDiag( + "controller_open panel=$instanceId shellVisible=${shell?.isVisible == true} " + + "initial=${canvas.width}x${canvas.height} " + + "hostScale=${canvas.webView2HostScale().scaleX.formatLogFloat()}x${canvas.webView2HostScale().scaleY.formatLogFloat()} " + + "shellBounds=${shell?.bounds?.formatSwtBounds().orEmpty()} " + + "browserBounds=${browser?.bounds?.formatSwtBounds().orEmpty()} canvasBounds=${canvas.bounds.formatAwtBounds()}" + ) + }.onFailure { error -> + logDesktopWebView2( + "controller_create_failed panel=$instanceId error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_create_failed panel=$instanceId " + + "error=\"${error.desktopNativeWebViewMessage(backend).logPreview(300)}\"" + } + reportError(error) + dispose() + } + } + + private fun setBrowserText(webView: Browser, html: String, reason: String) { + val accepted = webView.setText(html) + logDesktopWebView2( + "controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + + "htmlChars=${html.length} htmlHash=${html.hashCode()}" + ) + logDesktopReaderOpenTrace { + "event=desktop_webview_controller_set_text panel=$instanceId reason=$reason accepted=$accepted " + + "htmlChars=${html.length} htmlHash=${html.hashCode()}" + } + } + + private fun applyCanvasSizeToBrowser(width: Int, height: Int, reason: String) { + val webShell = shell ?: return + val webBrowser = browser + if (webShell.isDisposed || webBrowser?.isDisposed == true) return + val hostScale = canvas.webView2HostScale() + if (width <= 0 || height <= 0) { + logWebViewLayoutDiag( + "controller_resize_skip panel=$instanceId reason=$reason requested=${width}x${height} " + + "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + + "canvas=${canvas.width}x${canvas.height} shellBounds=${webShell.bounds.formatSwtBounds()} " + + "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" + ) + return + } + val targetBounds = desktopWebView2TargetBoundsForCanvas(width, height) ?: return + webShell.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) + webBrowser?.setBounds(targetBounds.x, targetBounds.y, targetBounds.width, targetBounds.height) + lastBrowserBoundsLog = webBrowser?.bounds?.formatSwtBounds().orEmpty() + logDesktopWebView2( + "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + + "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + + "shellBounds=${webShell.bounds.x},${webShell.bounds.y} ${webShell.bounds.width}x${webShell.bounds.height} " + + "browserBounds=${webBrowser?.bounds?.width ?: -1}x${webBrowser?.bounds?.height ?: -1}" + ) + logWebViewLayoutDiag( + "controller_resize panel=$instanceId reason=$reason requested=${width}x${height} " + + "target=${targetBounds.width}x${targetBounds.height} axisMode=logicalCanvas_zeroOrigin " + + "hostScale=${hostScale.scaleX.formatLogFloat()}x${hostScale.scaleY.formatLogFloat()} " + + "canvas=${canvas.width}x${canvas.height} canvasBounds=${canvas.bounds.formatAwtBounds()} " + + "shellBounds=${webShell.bounds.formatSwtBounds()} " + + "browserBounds=${webBrowser?.bounds?.formatSwtBounds().orEmpty()}" + ) + } +} + +private object DesktopSwtWebView2EventLoop { + private val ready = CountDownLatch(1) + + @Volatile + private var display: Display? = null + + @Volatile + private var startupError: Throwable? = null + + init { + Thread( + { + runCatching { + logDesktopWebView2("swt_event_loop_start") + runCatching { Display.setAppName(EpistemeDesktopWindowTitle) } + if (desktopEpubWebViewUsesWebView2() && + System.getProperty(DesktopWebView2EdgeDataDirProperty).isNullOrBlank() + ) { + System.setProperty( + DesktopWebView2EdgeDataDirProperty, + File(desktopUserCacheRoot(), "webview2").absolutePath + ) + } + if (desktopEpubWebViewUsesWebView2()) { + logDesktopWebView2( + "swt_event_loop_user_data_dir path=\"${System.getProperty(DesktopWebView2EdgeDataDirProperty).orEmpty().logPreview(200)}\"" + ) + } + val swtDisplay = Display() + display = swtDisplay + ready.countDown() + logDesktopWebView2("swt_event_loop_ready") + while (!swtDisplay.isDisposed) { + if (!swtDisplay.readAndDispatch()) { + swtDisplay.sleep() + } + } + }.onFailure { error -> + startupError = error + ready.countDown() + logDesktopWebView2("swt_event_loop_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + } + }, + "Episteme SWT Browser" + ).apply { + isDaemon = true + start() + } + } + + fun asyncExec( + onError: (Throwable) -> Unit, + block: (Display) -> Unit + ) { + val displayReady = runCatching { + ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) + }.getOrElse { error -> + Thread.currentThread().interrupt() + onError(error) + return + } + if (!displayReady) { + logDesktopWebView2("swt_async_timeout") + onError(IllegalStateException("SWT display did not become ready.")) + return + } + startupError?.let { error -> + logDesktopWebView2("swt_async_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + return + } + val swtDisplay = display + if (swtDisplay == null) { + logDesktopWebView2("swt_async_display_unavailable") + onError(IllegalStateException("SWT display is not available.")) + return + } + runCatching { + swtDisplay.asyncExec { + runCatching { + if (!swtDisplay.isDisposed) { + block(swtDisplay) + } + }.onFailure(onError) + } + }.onFailure { error -> + logDesktopWebView2("swt_async_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + } + } + + fun syncExec( + onError: (Throwable) -> Unit, + block: (Display) -> Unit + ) { + val displayReady = runCatching { + ready.await(DesktopSwtReadyTimeoutSeconds, TimeUnit.SECONDS) + }.getOrElse { error -> + Thread.currentThread().interrupt() + onError(error) + return + } + if (!displayReady) { + logDesktopWebView2("swt_sync_timeout") + onError(IllegalStateException("SWT display did not become ready.")) + return + } + startupError?.let { error -> + logDesktopWebView2("swt_sync_startup_error error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + return + } + val swtDisplay = display + if (swtDisplay == null) { + logDesktopWebView2("swt_sync_display_unavailable") + onError(IllegalStateException("SWT display is not available.")) + return + } + runCatching { + swtDisplay.syncExec { + runCatching { + if (!swtDisplay.isDisposed) { + block(swtDisplay) + } + }.onFailure(onError) + } + }.onFailure { error -> + logDesktopWebView2("swt_sync_enqueue_failed error=\"${error.message.orEmpty().logPreview(300)}\"") + onError(error) + } + } +} + +private fun Color.toAwtColor(): java.awt.Color = java.awt.Color(toArgb(), true) + +private fun desktopWebView2WarmupHtml(background: java.awt.Color): String { + val cssColor = background.toCssHex() + return """ + + + + + + + + + """.trimIndent() +} + +private fun java.awt.Color.toCssHex(): String { + return "#${red.toTwoDigitHex()}${green.toTwoDigitHex()}${blue.toTwoDigitHex()}" +} + +private fun Int.toTwoDigitHex(): String { + return coerceIn(0, 255).toString(16).padStart(2, '0') +} + +private fun runOnAwtEventThreadBlocking( + onError: (Throwable) -> Unit = {}, + block: () -> Unit +) { + if (EventQueue.isDispatchThread()) { + runCatching(block).onFailure(onError) + return + } + runCatching { + EventQueue.invokeAndWait { + runCatching(block).onFailure(onError) + } + }.onFailure(onError) +} + +private fun java.awt.Rectangle.formatAwtBounds(): String { + return "${x},${y} ${width}x$height" +} + +private fun java.awt.Component?.formatAwtComponentState(): String { + if (this == null) return "none" + return "${javaClass.simpleName}{visible=$isVisible displayable=$isDisplayable showing=$isShowing " + + "size=${width}x$height bounds=${bounds.formatAwtBounds()}}" +} + +private fun java.awt.Container.formatAwtChildrenState(): String { + if (componentCount == 0) return "none" + return components.joinToString(prefix = "[", postfix = "]") { component -> + component.formatAwtComponentState() + } +} + +private fun java.awt.Component.desktopWebView2Descendants(includeSelf: Boolean = false): List { + val descendants = mutableListOf() + if (includeSelf) descendants += this + fun collect(component: java.awt.Component) { + if (component is java.awt.Container) { + component.components.forEach { child -> + descendants += child + collect(child) + } + } + } + collect(this) + return descendants +} + +private fun org.eclipse.swt.graphics.Rectangle.formatSwtBounds(): String { + return "${x},${y} ${width}x$height" +} + +private fun Canvas.safeScreenLocationLog(): String { + return runCatching { + val point = locationOnScreen + "${point.x},${point.y}" + }.getOrDefault("unavailable") +} + +private data class DesktopWebView2HostScale( + val scaleX: Float, + val scaleY: Float +) + +internal data class DesktopWebView2TargetBounds( + val x: Int, + val y: Int, + val width: Int, + val height: Int +) + +internal fun desktopWebView2TargetBoundsForCanvas(width: Int, height: Int): DesktopWebView2TargetBounds? { + if (width <= 0 || height <= 0) return null + return DesktopWebView2TargetBounds( + x = 0, + y = 0, + width = width.coerceAtLeast(1), + height = height.coerceAtLeast(1) + ) +} + +internal fun desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing: Boolean, + hostWindowDisplayable: Boolean +): Boolean { + return !hostWindowClosing && hostWindowDisplayable +} + +private fun java.awt.Component.webView2HostScale(): DesktopWebView2HostScale { + val transform = graphicsConfiguration?.defaultTransform + return DesktopWebView2HostScale( + scaleX = transform?.scaleX?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f, + scaleY = transform?.scaleY?.takeIf { it.isFinite() && it > 0.0 }?.toFloat() ?: 1f + ) +} + +private fun String.withDesktopWebView2Bootstrap(networkAccessEnabled: Boolean): String { + val injection = buildString { + if (!networkAccessEnabled) { + append(DesktopWebView2OfflineCspMetaTag) + append('\n') + } + append(DesktopWebView2ReaderSurfaceCssTag) + append('\n') + append(DesktopWebView2BridgeScriptTag) + } + val headStart = Regex("]*>", RegexOption.IGNORE_CASE).find(this) + if (headStart != null) { + val insertAt = headStart.range.last + 1 + return substring(0, insertAt) + "\n" + injection + "\n" + substring(insertAt) + } + return "$injection\n$this" +} + +internal fun desktopNativeWebViewUnavailableMessage( + backend: DesktopEpubWebViewBackend, + detail: String? = null +): String { + val base = when (backend) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> + "Microsoft Edge WebView2 runtime is unavailable. Install or repair the WebView2 Runtime." + DesktopEpubWebViewBackend.WEBKIT -> + "WebKitGTK is unavailable. Install WebKitGTK from your Linux distribution packages." + DesktopEpubWebViewBackend.UNSUPPORTED -> + "Native webview is unavailable on this desktop platform." + } + val trimmedDetail = detail?.trim().orEmpty() + return if (trimmedDetail.isBlank()) base else "$base $trimmedDetail" +} + +private fun Throwable.desktopNativeWebViewMessage(backend: DesktopEpubWebViewBackend): String { + return desktopNativeWebViewUnavailableMessage( + backend = backend, + detail = message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName + ) +} + +private fun desktopWebView2DocumentProbeScript(eventName: String): String { + return """ + (function () { + try { + var body = document.body; + var root = document.documentElement; + var firstChapter = document.querySelector('.chapter'); + var firstContent = document.querySelector('.reader-content'); + var blockSelector = 'p, div, h1, h2, h3, h4, h5, h6, li, blockquote, figure, table, pre'; + function round(value) { + return Math.round(Number(value || 0)); + } + function cssValue(element, name) { + if (!element) return ''; + var style = window.getComputedStyle(element); + return style ? (style.getPropertyValue(name) || '') : ''; + } + function cssVar(name) { + return cssValue(root, name).trim(); + } + function rectPayload(element) { + if (!element) return null; + var rect = element.getBoundingClientRect(); + var centerX = rect.left + (rect.width / 2); + var centerY = rect.top + (rect.height / 2); + var viewportHeight = window.innerHeight || 0; + return { + left: round(rect.left), + top: round(rect.top), + right: round(rect.right), + bottom: round(rect.bottom), + width: round(rect.width), + height: round(rect.height), + centerX: round(centerX), + centerDelta: round(centerX - ((window.innerWidth || 0) / 2)), + centerY: round(centerY), + viewportHeightDelta: round(rect.height - viewportHeight), + marginLeft: cssValue(element, 'margin-left').trim(), + marginRight: cssValue(element, 'margin-right').trim(), + paddingLeft: cssValue(element, 'padding-left').trim(), + paddingRight: cssValue(element, 'padding-right').trim(), + paddingTop: cssValue(element, 'padding-top').trim(), + paddingBottom: cssValue(element, 'padding-bottom').trim(), + textAlign: cssValue(element, 'text-align').trim(), + display: cssValue(element, 'display').trim(), + cssFloat: cssValue(element, 'float').trim(), + clear: cssValue(element, 'clear').trim(), + cssWidth: cssValue(element, 'width').trim(), + maxWidth: cssValue(element, 'max-width').trim(), + minHeight: cssValue(element, 'min-height').trim(), + boxSizing: cssValue(element, 'box-sizing').trim() + }; + } + function visibleChapter() { + var chapters = Array.prototype.slice.call(document.querySelectorAll('[data-reader-chapter-index]')); + var viewportTop = 0; + var viewportBottom = window.innerHeight || 0; + var best = null; + var bestVisibleHeight = -1; + chapters.forEach(function (candidate) { + var rect = candidate.getBoundingClientRect(); + var visibleHeight = Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop); + if (visibleHeight > bestVisibleHeight && rect.bottom >= viewportTop && rect.top <= viewportBottom) { + best = candidate; + bestVisibleHeight = visibleHeight; + } + }); + return best || firstChapter; + } + function visibleBlockIn(content) { + if (!content) return null; + var blocks = Array.prototype.slice.call(content.querySelectorAll(blockSelector)); + for (var i = 0; i < blocks.length; i++) { + var rect = blocks[i].getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= (window.innerHeight || 0)) { + return blocks[i]; + } + } + return blocks[0] || null; + } + var chapter = visibleChapter(); + var content = chapter ? (chapter.querySelector('.reader-content') || chapter) : firstContent; + var firstBlock = firstContent ? firstContent.querySelector(blockSelector) : null; + var visibleBlock = visibleBlockIn(content); + var viewportCenterX = Math.max(0, Math.min((window.innerWidth || 0) - 1, Math.round((window.innerWidth || 0) / 2))); + var viewportTopY = Math.max(0, Math.min((window.innerHeight || 0) - 1, 8)); + var topElement = document.elementFromPoint(viewportCenterX, viewportTopY); + var topBlock = topElement && topElement.closest ? topElement.closest(blockSelector) : null; + var sampledElement = document.elementFromPoint( + viewportCenterX, + Math.max(0, Math.min((window.innerHeight || 0) - 1, Math.round((window.innerHeight || 0) / 2))) + ); + var sampledBlock = sampledElement && sampledElement.closest ? sampledElement.closest(blockSelector) : null; + var payload = { + event: '$eventName', + readyState: document.readyState || '', + title: document.title || '', + url: location.href || '', + devicePixelRatio: window.devicePixelRatio || 1, + bodyClass: body ? body.className : '', + rootClass: root ? root.className : '', + readerAlign: cssVar('--reader-align'), + readerMarginX: cssVar('--reader-margin-x'), + readerMarginY: cssVar('--reader-margin-y'), + readerVerticalMarginY: cssVar('--reader-vertical-margin-y'), + readerVerticalContentWidth: cssVar('--reader-vertical-content-width'), + readerVerticalPageWidth: cssVar('--reader-vertical-page-width'), + readerFontSize: cssVar('--reader-font-size'), + bodyZoom: cssValue(body, 'zoom').trim(), + bodyChildren: body ? body.children.length : -1, + bodyTextChars: body && body.innerText ? body.innerText.length : 0, + bodyHtmlChars: body && body.innerHTML ? body.innerHTML.length : 0, + bodyClientWidth: body ? body.clientWidth : -1, + bodyScrollWidth: body ? body.scrollWidth : -1, + rootClientWidth: root ? root.clientWidth : -1, + rootScrollWidth: root ? root.scrollWidth : -1, + scrollHeight: root ? root.scrollHeight : -1, + clientHeight: root ? root.clientHeight : -1, + viewportWidth: window.innerWidth || -1, + viewportHeight: window.innerHeight || -1, + visualViewportWidth: window.visualViewport ? round(window.visualViewport.width) : -1, + visualViewportHeight: window.visualViewport ? round(window.visualViewport.height) : -1, + visualViewportScale: window.visualViewport ? window.visualViewport.scale : -1, + scrollX: window.scrollX || 0, + topElementTag: topElement ? topElement.tagName : '', + topElementClass: topElement && topElement.className ? String(topElement.className) : '', + topBlockTag: topBlock ? topBlock.tagName : '', + topBlockRect: rectPayload(topBlock), + bodyRect: rectPayload(body), + rootRect: rectPayload(root), + firstChapterRect: rectPayload(firstChapter), + firstContentRect: rectPayload(firstContent), + visibleChapterIndex: chapter ? chapter.getAttribute('data-reader-chapter-index') : '', + chapterRect: rectPayload(chapter), + contentRect: rectPayload(content), + firstBlockTag: firstBlock ? firstBlock.tagName : '', + firstBlockRect: rectPayload(firstBlock), + visibleBlockTag: visibleBlock ? visibleBlock.tagName : '', + visibleBlockRect: rectPayload(visibleBlock), + sampledBlockTag: sampledBlock ? sampledBlock.tagName : '', + sampledBlockRect: rectPayload(sampledBlock) + }; + if (window.kmpJsBridge && window.kmpJsBridge.callNative) { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify(payload)); + } + } catch (error) { + if (window.kmpJsBridge && window.kmpJsBridge.callNative) { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ + event: '$eventName', + error: String(error && error.message ? error.message : error) + })); + } + } + })(); + """.trimIndent() +} + +private var DesktopWebView2InstanceSeed = 0 +private val DesktopWebView2ModeSwitchPanelStates = ConcurrentHashMap() + +@Synchronized +private fun nextDesktopWebView2InstanceId(): Int { + DesktopWebView2InstanceSeed += 1 + return DesktopWebView2InstanceSeed +} + +internal fun logDesktopWebView2ModeSwitchSnapshot(reason: String) { + val states = DesktopWebView2ModeSwitchPanelStates + .toSortedMap() + .values + .joinToString(separator = " | ") + .ifBlank { "none" } + logReaderModeSwitch( + "webview2_snapshot reason=$reason knownPanelCount=${DesktopWebView2ModeSwitchPanelStates.size} panels=$states" + ) +} + +internal fun cleanupRetiredDesktopWebView2InteropHosts(window: java.awt.Window?, reason: String) { + EventQueue.invokeLater { + if (window == null) { + logReaderModeSwitch("webview2_interop_host_sweep_skip reason=$reason window=null") + return@invokeLater + } + val interopHosts = window + .desktopWebView2Descendants() + .filterIsInstance() + .filter { component -> component.javaClass.simpleName == DesktopSwingInteropHostClassName } + if (interopHosts.isEmpty()) { + logReaderModeSwitch( + "webview2_interop_host_sweep reason=$reason window=${window.formatAwtComponentState()} hosts=none" + ) + return@invokeLater + } + interopHosts.forEach { host -> + cleanupRetiredDesktopWebView2InteropHost(window, host, reason) + } + } +} + +private fun cleanupRetiredDesktopWebView2InteropHost( + window: java.awt.Window, + host: java.awt.Container, + reason: String +) { + val panels = host + .desktopWebView2Descendants(includeSelf = true) + .filterIsInstance() + val hostRetired = !host.isShowing || !host.isVisible || host.width <= 0 || host.height <= 0 + val panelsRetired = panels.isNotEmpty() && panels.all { panel -> + !panel.isDisplayable || !panel.isShowing || panel.width <= 0 || panel.height <= 0 || !panel.hasController + } + val parent = host.parent + val removable = parent != null && hostRetired && panelsRetired + val panelStates = panels.joinToString(prefix = "[", postfix = "]") { panel -> + "panel=${panel.instanceId}{visible=${panel.isVisible} displayable=${panel.isDisplayable} " + + "showing=${panel.isShowing} size=${panel.width}x${panel.height} controller=${panel.hasController}}" + }.ifBlank { "none" } + logReaderModeSwitch( + "webview2_interop_host_sweep_candidate reason=$reason removable=$removable " + + "hostRetired=$hostRetired panelsRetired=$panelsRetired " + + "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + + "parent=${parent.formatAwtComponentState()} panels=$panelStates children=${host.formatAwtChildrenState()}" + ) + if (!removable) return + panels.forEach { panel -> + if (panel.parent === host) { + host.remove(panel) + } + } + host.removeAll() + parent?.remove(host) + host.invalidate() + parent?.invalidate() + parent?.validate() + parent?.repaint() + window.invalidate() + window.validate() + window.repaint() + panels.forEach { panel -> + DesktopWebView2ModeSwitchPanelStates[panel.instanceId] = + panel.modeSwitchPanelSnapshot("interop_host_sweep_removed_$reason") + logReaderModeSwitch("webview2_panel ${DesktopWebView2ModeSwitchPanelStates[panel.instanceId]}") + } + logReaderModeSwitch( + "webview2_interop_host_sweep_removed reason=$reason " + + "window=${window.formatAwtComponentState()} host=${host.formatAwtComponentState()} " + + "parent=${parent.formatAwtComponentState()} parentChildren=${parent?.componentCount ?: -1}" + ) +} + +private const val DesktopSwtReadyTimeoutSeconds = 10L +private val DesktopWebView2FullscreenRelayoutDelaysMillis = longArrayOf(180L, 260L, 420L) +private val DesktopWebView2InteropHostCleanupDelaysMillis = longArrayOf(80L, 220L) +private const val DesktopWebView2NativeBridgeName = "epistemeCallNative" +private const val DesktopWebView2DiagnosticMethodName = "readerWebView2Diagnostic" +private const val DesktopSwingInteropHostClassName = "SwingInteropViewGroup" +private const val DesktopWebView2EdgeDataDirProperty = "org.eclipse.swt.browser.EdgeDataDir" + +private fun DesktopEpubWebViewBackend.swtBrowserStyle(): Int { + return when (this) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> SWT.EDGE + DesktopEpubWebViewBackend.WEBKIT -> SWT.WEBKIT + DesktopEpubWebViewBackend.UNSUPPORTED -> SWT.NONE + } +} + +private fun DesktopEpubWebViewBackend.acceptsBrowserType(browserType: String): Boolean { + return when (this) { + DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2 -> browserType.equals("edge", ignoreCase = true) + DesktopEpubWebViewBackend.WEBKIT -> + browserType.contains("webkit", ignoreCase = true) || browserType.equals("safari", ignoreCase = true) + DesktopEpubWebViewBackend.UNSUPPORTED -> false + } +} + +private val DesktopWebView2BridgeRuntimeScript = """ + (function () { + window.kmpJsBridge = window.kmpJsBridge || {}; + window.kmpJsBridge.callNative = function (method, params) { + if (!window.$DesktopWebView2NativeBridgeName) return null; + var payload = '{}'; + if (typeof params === 'string') { + payload = params; + } else { + try { payload = JSON.stringify(params || {}); } catch (error) { payload = '{}'; } + } + return window.$DesktopWebView2NativeBridgeName(String(method || ''), payload); + }; + })(); +""".trimIndent() + +private val DesktopWebView2ReaderSurfaceCssTag = """ + +""".trimIndent() + +private val DesktopWebView2HorizontalClampScript = """ + (function () { + if (window.readerWebView2HorizontalClampInstalled) return; + window.readerWebView2HorizontalClampInstalled = true; + var clampQueued = false; + function clampHorizontalScroll() { + clampQueued = false; + var root = document.documentElement; + var body = document.body; + var changed = false; + if (window.scrollX) { + window.scrollTo({ top: window.scrollY || 0, left: 0, behavior: 'auto' }); + changed = true; + } + if (root && root.scrollLeft) { + root.scrollLeft = 0; + changed = true; + } + if (body && body.scrollLeft) { + body.scrollLeft = 0; + changed = true; + } + if (changed && window.kmpJsBridge && window.kmpJsBridge.callNative) { + try { + window.kmpJsBridge.callNative('$DesktopWebView2DiagnosticMethodName', JSON.stringify({ + event: 'horizontal_scroll_clamped' + })); + } catch (error) {} + } + } + function scheduleClamp() { + if (clampQueued) return; + clampQueued = true; + window.requestAnimationFrame(clampHorizontalScroll); + } + window.addEventListener('scroll', scheduleClamp, { passive: true }); + window.addEventListener('resize', scheduleClamp, { passive: true }); + document.addEventListener('DOMContentLoaded', scheduleClamp, { once: true }); + window.addEventListener('load', scheduleClamp, { once: true }); + scheduleClamp(); + })(); +""".trimIndent() + +private val DesktopWebView2BridgeScriptTag = """ + +""".trimIndent() + +private const val DesktopWebView2OfflineCspMetaTag = + "" diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt new file mode 100644 index 0000000..6893a1e --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Launcher.kt @@ -0,0 +1,6 @@ +package org.dueattendant149.bookreader.desktop + +fun main() { + val startupSplash = DesktopStartupSplash.show() + launchEpistemeDesktopApplication(startupSplash) +} diff --git a/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt new file mode 100644 index 0000000..ef77e24 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/desktop/Main.kt @@ -0,0 +1,5152 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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.rememberWindowState +import org.dueattendant149.bookreader.shared.AppAction +import org.dueattendant149.bookreader.shared.AppFontPreference +import org.dueattendant149.bookreader.shared.BannerMessage +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.BookShelfRef +import org.dueattendant149.bookreader.shared.CustomFontItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.ImportedBookFile +import org.dueattendant149.bookreader.shared.LibraryAction +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.ReaderAiFeature +import org.dueattendant149.bookreader.shared.ReaderAiResultState +import org.dueattendant149.bookreader.shared.ReaderCloudTtsState +import org.dueattendant149.bookreader.shared.ReaderContextExtractor +import org.dueattendant149.bookreader.shared.RecapResult +import org.dueattendant149.bookreader.shared.ReaderExternalLookupAction +import org.dueattendant149.bookreader.shared.ReaderExtrasState +import org.dueattendant149.bookreader.shared.ReaderFeatureSurface +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.ReaderTtsCacheSummary +import org.dueattendant149.bookreader.shared.ReaderTtsChunk +import org.dueattendant149.bookreader.shared.ReaderTtsPlanner +import org.dueattendant149.bookreader.shared.ReaderTtsProgress +import org.dueattendant149.bookreader.shared.ReaderTtsReadScope +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedImportOutcomeCounts +import org.dueattendant149.bookreader.shared.SharedImportPlanner +import org.dueattendant149.bookreader.shared.SharedLibraryEditor +import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.SharedSettingsAction +import org.dueattendant149.bookreader.shared.SharedSettingsDestination +import org.dueattendant149.bookreader.shared.SharedSettingsHubInput +import org.dueattendant149.bookreader.shared.SharedSettingsPlatform +import org.dueattendant149.bookreader.shared.Shelf +import org.dueattendant149.bookreader.shared.ShelfRecord +import org.dueattendant149.bookreader.shared.ShelfType +import org.dueattendant149.bookreader.shared.SmartCollectionDefinition +import org.dueattendant149.bookreader.shared.SummarizationResult +import org.dueattendant149.bookreader.shared.externalLookupUrl +import org.dueattendant149.bookreader.shared.opds.OpdsAcquisition +import org.dueattendant149.bookreader.shared.opds.OpdsCatalog +import org.dueattendant149.bookreader.shared.opds.OpdsEntry +import org.dueattendant149.bookreader.shared.opds.OpdsStreamReference +import org.dueattendant149.bookreader.shared.opds.SharedOpdsController +import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState +import org.dueattendant149.bookreader.shared.opds.SharedOpdsStreamUri +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.reader.ReaderEngine +import org.dueattendant149.bookreader.shared.reader.ReaderImageReference +import org.dueattendant149.bookreader.shared.reader.ReaderSessionState +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataEditor +import org.dueattendant149.bookreader.shared.reader.SharedEpubMetadataUpdate +import org.dueattendant149.bookreader.shared.reader.SharedEpubPaginationCache +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoader +import org.dueattendant149.bookreader.shared.readerCloudTtsControlsModel +import org.dueattendant149.bookreader.shared.reduce +import org.dueattendant149.bookreader.shared.sharedSettingsHubModel +import org.dueattendant149.bookreader.shared.shouldApplyRemoteCloudBookMetadataUpdate +import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookContent +import org.dueattendant149.bookreader.shared.shouldUploadLocalCloudBookMetadataUpdate +import org.dueattendant149.bookreader.shared.ui.NonReaderLibraryTab +import org.dueattendant149.bookreader.shared.ui.SharedAboutScreen +import org.dueattendant149.bookreader.shared.ui.SharedAddToShelfDialog +import org.dueattendant149.bookreader.shared.ui.SharedAppShell +import org.dueattendant149.bookreader.shared.ui.SharedAppTab +import org.dueattendant149.bookreader.shared.ui.SharedAppTheme +import org.dueattendant149.bookreader.shared.ui.SharedAppThemeControls +import org.dueattendant149.bookreader.shared.ui.SharedAppThemeSettingsDialog +import org.dueattendant149.bookreader.shared.ui.SharedBookInfoDialog +import org.dueattendant149.bookreader.shared.ui.SharedConfirmDialog +import org.dueattendant149.bookreader.shared.ui.SharedCustomFontsScreen +import org.dueattendant149.bookreader.shared.ui.SharedHelpFeedbackScreen +import org.dueattendant149.bookreader.shared.ui.LocalSharedStringResolver +import org.dueattendant149.bookreader.shared.ui.SharedManageShelfBooksDialog +import org.dueattendant149.bookreader.shared.ui.SharedOpdsScreen +import org.dueattendant149.bookreader.shared.ui.SharedReaderModalOwnerWindowProvider +import org.dueattendant149.bookreader.shared.ui.SharedReaderTtsOverlayControls +import org.dueattendant149.bookreader.shared.ui.SharedSettingsHub +import org.dueattendant149.bookreader.shared.ui.SharedSupportProjectScreen +import org.dueattendant149.bookreader.shared.ui.SharedTextInputDialog +import org.dueattendant149.bookreader.shared.ui.readerString +import org.dueattendant149.bookreader.shared.withTtsReplacements +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.Component +import java.awt.EventQueue +import java.io.File +import java.net.URI +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +private const val DesktopReaderCloseDisposeSyncDelayMillis = 350L +private const val DesktopVerticalInitialPreparedHtmlChapterRadius = 2 +private const val DesktopLibraryOpenPersistDebounceMillis = 300L +private const val DesktopCloudContentRetryDelayMillis = 10_000L +private const val DesktopReaderPositionPersistDebounceMillis = 650L +private const val DesktopProgressEpsilon = 0.001f + +private enum class DesktopFeatureNoticeAction { + SIGN_IN, + OPEN_PRO +} + +private data class DesktopFeatureNotice( + val titleKey: String, + val titleFallback: String, + val messageKey: String, + val messageFallback: String, + val confirmKey: String = "action_ok", + val confirmFallback: String = "OK", + val action: DesktopFeatureNoticeAction? = null +) + +private data class DesktopFeatureNoticeState( + val notice: DesktopFeatureNotice, + val placement: DesktopFeatureNoticePlacement +) + +private data class DesktopCloudSyncCredentials( + val userId: String, + val idToken: String, + val driveAccessToken: String, + val deviceId: String +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EpistemeDesktopApp( + window: Component? = null, + appWindowPlacement: WindowPlacement, + readerFullscreen: Boolean, + onReaderFullscreenChange: (Boolean) -> Unit +) { + val desktopBuildProfile = remember { currentDesktopBuildProfile() } + val desktopLanguageSettingsStore = remember { DesktopLanguageSettingsStore() } + var desktopLanguageTag by remember { mutableStateOf(desktopLanguageSettingsStore.load().languageTag) } + val desktopStringLocale = remember(desktopLanguageTag) { desktopLocaleForLanguageTag(desktopLanguageTag) } + val desktopStringResolver = remember(desktopStringLocale) { loadDesktopStringResolver(locale = desktopStringLocale) } + fun desktopString(name: String, fallback: String, vararg args: Any?): String { + return desktopStringResolver.string(name, fallback, *args) + } + fun desktopQuantityString( + name: String, + quantity: Int, + fallbackOne: String, + fallbackOther: String, + vararg args: Any? + ): String { + return desktopStringResolver.quantityString(name, quantity, fallbackOne, fallbackOther, *args) + } + val featurePolicy = desktopBuildProfile.featurePolicy + val desktopAiKeySettingsAvailable = desktopBuildProfile.aiKeySettingsAvailable + val libraryProjector = remember { SharedLibraryStateProjector(DesktopFolderPathResolver) } + val readerEngine = remember { ReaderEngine() } + val libraryDatabase = remember { DesktopLibraryDatabase() } + val desktopBookImporter = remember { DesktopBookImporter() } + val customFontStore = remember { + DesktopCustomFontStore( + googleFontsDownloadAvailable = { featurePolicy.googleFontsDownload } + ) + } + val opdsRepository = remember { DesktopOpdsRepository() } + val opdsController = remember { + SharedOpdsController( + repository = opdsRepository, + idFactory = { UUID.randomUUID().toString() } + ) + } + val desktopCloudConfig = remember { loadDesktopCloudConfig() } + val desktopAuthRepository = remember { DesktopFirebaseAuthRepository(desktopCloudConfig) } + val desktopAccountProfileRepository = remember { DesktopAccountProfileRepository(desktopCloudConfig) } + val desktopCloudSyncSettingsStore = remember { DesktopCloudSyncSettingsStore() } + val initialDesktopCloudSyncSettings = remember { desktopCloudSyncSettingsStore.load() } + val initialDesktopAccountSession = remember(desktopBuildProfile, featurePolicy) { + if (featurePolicy.aiAndCloud && featurePolicy.networkAccess && !desktopBuildProfile.byokAiAvailable) { + desktopAuthRepository.currentSession() + } else { + null + } + } + val initialDesktopAccountProfile = remember(initialDesktopAccountSession?.user?.uid) { + initialDesktopAccountSession?.user?.uid?.let(desktopAccountProfileRepository::cachedProfile) + } + val desktopInstallationIdStore = remember { DesktopInstallationIdStore() } + val desktopFirestoreRepository = remember { DesktopFirestoreRepository(desktopCloudConfig) } + val desktopGoogleDriveRepository = remember { DesktopGoogleDriveRepository() } + val desktopCloudSync = remember { + DesktopCloudSync( + firestoreRepository = desktopFirestoreRepository, + driveRepository = desktopGoogleDriveRepository, + bookImporter = desktopBookImporter, + customFontStore = customFontStore + ) + } + val aiByokStore = remember { DesktopAiByokStore() } + var aiByokSettings by remember { + mutableStateOf(aiByokStore.load()) + } + val sanitizedAiByokSettings = aiByokSettings.toDesktopPersistableAiSettings() + val desktopByokCloudTtsAvailable = featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + sanitizedAiByokSettings.isByokCloudTtsAvailable + val desktopCreditCloudTtsControlsAvailable = + desktopBuildProfile.creditBackedCloudTtsControlsAvailable && desktopCloudConfig.isTtsWorkerConfigured + val desktopCloudTtsControlsAvailable = desktopByokCloudTtsAvailable || desktopCreditCloudTtsControlsAvailable + val desktopCloudTtsUsesCredits = desktopCreditCloudTtsControlsAvailable && !desktopByokCloudTtsAvailable + val initialLibrarySnapshot = remember { libraryDatabase.load().withDesktopDefaults() } + val scope = rememberCoroutineScope() + val webViewRuntimeState = remember { + DesktopWebViewRuntimeState(initialized = desktopEpubWebViewUsesNativeSwtBrowser()) + } + var readerCustomTextureIds by remember { mutableStateOf(DesktopReaderTextures.importedTextureIds()) } + val appWindowFullscreen = appWindowPlacement == WindowPlacement.Fullscreen + + EpistemeDesktopWindowDecorationEffect( + window = window, + hideDecoration = readerFullscreen && !appWindowFullscreen + ) + DesktopReaderFullscreenEffect( + window = window, + enabled = readerFullscreen && !appWindowFullscreen + ) + + var shelfRecords by remember { mutableStateOf(initialLibrarySnapshot.shelfRecords) } + var shelfRefs by remember { mutableStateOf(initialLibrarySnapshot.shelfRefs) } + var state by remember { + val initialState = initialLibrarySnapshot.toDesktopReaderScreenState().copy( + currentUser = initialDesktopAccountSession?.user, + isProUser = initialDesktopAccountProfile?.isProUser == true, + credits = initialDesktopAccountProfile?.credits ?: 0, + isSyncEnabled = initialDesktopCloudSyncSettings.isSyncEnabled, + isFolderSyncEnabled = initialDesktopCloudSyncSettings.isFolderSyncEnabled + ) + mutableStateOf( + libraryProjector.projectDesktopLibraryState( + state = initialState, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + ) + ) + } + var accountStatusMessage by remember { mutableStateOf(null) } + var accountBusy by remember { mutableStateOf(false) } + var accountRefreshRequestCount by remember { mutableStateOf(0) } + var desktopAccountProfileRefreshCompleted by remember { + mutableStateOf( + !featurePolicy.aiAndCloud || + desktopBuildProfile.byokAiAvailable || + initialDesktopAccountSession == null + ) + } + fun requestDesktopAccountRefreshAfterUsage(usage: DesktopPaidAiUsage = DesktopPaidAiUsage()) { + if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) return + scope.launch { + val nextCredits = desktopCreditsAfterPaidAiUsage(state.credits, usage.cost) + if (nextCredits != state.credits) { + state = libraryProjector.projectDesktopLibraryState( + state = state.copy(credits = nextCredits), + shelfRecords = shelfRecords, + shelfRefs = shelfRefs + ) + } + accountRefreshRequestCount++ + } + } + fun effectiveAiSettings(): ReaderAiByokSettings { + val sanitized = aiByokSettings.toDesktopPersistableAiSettings() + val byokCloudTtsAvailable = featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + sanitized.isByokCloudTtsAvailable + return if (desktopBuildProfile.byokAiAvailable) { + aiByokSettings.withDesktopFeaturePolicy(featurePolicy) + } else { + ReaderAiByokSettings( + geminiKey = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.geminiKey else "", + hideReaderAiFeatures = false, + ttsModel = if (featurePolicy.aiAndCloud && featurePolicy.networkAccess) sanitized.ttsModel else "", + ttsSpeakerId = sanitized.ttsSpeakerId, + serverBackedReaderAiFeatures = featurePolicy.aiAndCloud && featurePolicy.networkAccess, + serverBackedCloudTts = !byokCloudTtsAvailable && + desktopCreditCloudTtsControlsAvailable && + state.currentUser != null && + state.credits > 0 + ) + } + } + val desktopAiAdapter = remember(desktopBuildProfile) { + if (desktopBuildProfile.byokAiAvailable) { + DesktopByokAiAdapter( + settingsProvider = { effectiveAiSettings() }, + networkAccess = { featurePolicy.networkAccess } + ) + } else { + DesktopPaidAiAdapter( + config = desktopCloudConfig, + networkAccess = { featurePolicy.networkAccess }, + hideReaderAiFeatures = { effectiveAiSettings().hideReaderAiFeatures }, + currentAuthToken = { desktopAuthRepository.freshIdToken() }, + currentSignedIn = { state.currentUser != null }, + currentIsProUser = { state.isProUser }, + currentCredits = { state.credits }, + onUsageReported = ::requestDesktopAccountRefreshAfterUsage + ) + } + } + val desktopTtsAdapter = remember(desktopBuildProfile) { + DesktopGeminiCloudTtsAdapter( + settingsProvider = { effectiveAiSettings() }, + networkAccess = { featurePolicy.networkAccess }, + workerUrlProvider = { desktopCloudConfig.ttsWorkerUrl }, + authTokenProvider = { desktopAuthRepository.freshIdToken() }, + useWorkerProvider = { true }, + onWorkerUsageCompleted = { + requestDesktopAccountRefreshAfterUsage() + Unit + } + ) + } + val desktopSummaryCacheStore = remember { DesktopSummaryCacheStore() } + var selectedTab by remember { mutableStateOf(DesktopInitialAppTab) } + var selectedLibraryTab by remember { mutableStateOf(NonReaderLibraryTab.BOOKS) } + var customFonts by remember { + mutableStateOf(initialLibrarySnapshot.customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }) + } + var readerWindows by remember { mutableStateOf>(emptyList()) } + var reflowingPdfBookIds by remember { mutableStateOf>(emptySet()) } + val desktopEpubPaginationCache = remember { SharedEpubPaginationCache() } + var epubPaginationCacheGeneration by remember { mutableStateOf(0) } + var nextReaderOpenRequestId by remember { mutableStateOf(0L) } + var showCreateShelfDialog by remember { mutableStateOf(false) } + var createShelfBookIds by remember { mutableStateOf>(emptySet()) } + var createShelfClearsSelection by remember { mutableStateOf(false) } + var showCreateSmartShelfDialog by remember { mutableStateOf(false) } + var shelfToRename by remember { mutableStateOf(null) } + var shelfToDelete by remember { mutableStateOf(null) } + var folderToRemove by remember { mutableStateOf(null) } + var addToShelfBookIds by remember { mutableStateOf>(emptySet()) } + var addToShelfClearsSelection by remember { mutableStateOf(false) } + var shelfToManageBooks by remember { mutableStateOf(null) } + var showTagSelectionDialog by remember { mutableStateOf(false) } + var showAiByokSettingsDialog by remember { mutableStateOf(false) } + var showDesktopAppThemeSettingsDialog by remember { mutableStateOf(false) } + var showDesktopLanguageDialog by remember { mutableStateOf(false) } + var showClearBookCacheDialog by remember { mutableStateOf(false) } + var desktopFeatureNoticeState by remember { mutableStateOf(null) } + var settingsQuery by remember { mutableStateOf("") } + var settingsDestination by remember { mutableStateOf(SharedSettingsDestination.ROOT) } + var bookInfoDialogFor by remember { mutableStateOf(null) } + var bookInfoInitiallyEditing by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + var dropImportState by remember { mutableStateOf(DesktopDropImportState()) } + var opdsState by remember { mutableStateOf(opdsController.state) } + var desktopCloudSyncJob by remember { mutableStateOf(null) } + var desktopCloudContentRetryJob by remember { mutableStateOf(null) } + var pendingDesktopCloudSyncAfterActive by remember { mutableStateOf(false) } + val desktopBookCloudSyncJobs = remember { mutableMapOf() } + val pendingLibraryPersistJob = remember { AtomicReference(null) } + val desktopBookSidecarSaveJobs = remember { ConcurrentHashMap() } + var readerCloudDirtyBookIds by remember { mutableStateOf>(emptySet()) } + var readerCloudDirtyBaseTimestamps by remember { mutableStateOf>(emptyMap()) } + var readerCloudDirtySidecarBookIds by remember { mutableStateOf>(emptySet()) } + var readerCloudStalePositionGuards by remember { mutableStateOf>(emptyMap()) } + var closingReaderBookIds by remember { mutableStateOf>(emptySet()) } + var initialDesktopCloudSyncDone by remember { mutableStateOf(false) } + val readerWindowDefaults = remember(desktopBuildProfile) { epistemeDesktopWindowDefaults(desktopBuildProfile) } + val readerWindowStateStore = remember { + DesktopWindowStateStore(DesktopWindowStateStore.defaultReaderWindowStateFile()) + } + var savedReaderWindowState by remember { + mutableStateOf(readerWindowStateStore.load()?.toPersistableReaderWindowSnapshot()) + } + + fun showDesktopFeatureNotice( + notice: DesktopFeatureNotice, + readerWindowId: String? = null + ) { + desktopFeatureNoticeState = DesktopFeatureNoticeState( + notice = notice, + placement = desktopFeatureNoticePlacement(readerWindowId) + ) + } + + fun dismissDesktopFeatureNotice() { + desktopFeatureNoticeState = null + } + + fun projectState( + next: SharedReaderScreenState, + records: List = shelfRecords, + refs: List = shelfRefs + ): SharedReaderScreenState { + return libraryProjector.projectDesktopLibraryState( + state = next, + shelfRecords = records, + shelfRefs = refs + ) + } + + fun persistSnapshot( + projected: SharedReaderScreenState, + records: List = shelfRecords, + refs: List = shelfRefs, + fonts: List = customFonts, + persistDebounceMillis: Long = 0L + ) { + val snapshot = projected.toDesktopLibrarySnapshot( + shelfRecords = records, + shelfRefs = refs, + customFonts = fonts + ) + pendingLibraryPersistJob.getAndSet(null)?.cancel() + val persistJob = scope.launch(Dispatchers.IO) { + runCatching { + if (persistDebounceMillis > 0L) { + delay(persistDebounceMillis) + } + libraryDatabase.save(snapshot) + } + } + pendingLibraryPersistJob.set(persistJob) + persistJob.invokeOnCompletion { + pendingLibraryPersistJob.compareAndSet(persistJob, null) + } + } + + fun replaceLibrary( + next: SharedReaderScreenState, + records: List = shelfRecords, + refs: List = shelfRefs, + fonts: List = customFonts, + persistDebounceMillis: Long = 0L + ) { + shelfRecords = records + shelfRefs = refs + val projected = projectState(next, records, refs) + state = projected + persistSnapshot(projected, records, refs, fonts, persistDebounceMillis) + } + + fun updateState(next: SharedReaderScreenState, persistDebounceMillis: Long = 0L) { + val projected = projectState(next) + state = projected + persistSnapshot(projected, persistDebounceMillis = persistDebounceMillis) + } + + fun flushDesktopPersistenceBeforeDispose( + projected: SharedReaderScreenState, + records: List, + refs: List, + fonts: List + ) { + pendingLibraryPersistJob.getAndSet(null)?.cancel() + runCatching { + libraryDatabase.save( + projected.toDesktopLibrarySnapshot( + shelfRecords = records, + shelfRefs = refs, + customFonts = fonts + ) + ) + } + + val pendingSidecarBookIds = desktopBookSidecarSaveJobs.keys.toList() + if (pendingSidecarBookIds.isEmpty()) return + + desktopBookSidecarSaveJobs.values.forEach { it.cancel() } + desktopBookSidecarSaveJobs.clear() + val booksById = projected.rawLibraryBooks.associateBy { it.id } + pendingSidecarBookIds + .mapNotNull(booksById::get) + .filter { book -> + val sourceFolder = book.sourceFolder ?: return@filter false + projected.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true + } + .forEach { book -> + runCatching { DesktopLocalFolderSync.saveBookSidecars(book) } + } + } + + fun DesktopReaderWindowState.cancelReaderWork() { + when (val content = content) { + DesktopReaderWindowContent.Opening, + is DesktopReaderWindowContent.PasswordRequired, + is DesktopReaderWindowContent.Pdf -> Unit + is DesktopReaderWindowContent.Text -> content.ttsJob?.cancel() + } + } + + fun DesktopReaderWindowState.readerCloseContentLabel(): String { + return when (content) { + DesktopReaderWindowContent.Opening -> "opening" + is DesktopReaderWindowContent.PasswordRequired -> "password_required" + is DesktopReaderWindowContent.Pdf -> "pdf" + is DesktopReaderWindowContent.Text -> "text" + } + } + + fun DesktopReaderWindowState.closeReaderResources() { + logDesktopReaderClose( + "close_resources_begin windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()} fullscreen=$fullscreen" + ) + cancelReaderWork() + runCatching { + when (val content = content) { + DesktopReaderWindowContent.Opening, + is DesktopReaderWindowContent.PasswordRequired -> Unit + is DesktopReaderWindowContent.Pdf -> content.document.close() + is DesktopReaderWindowContent.Text -> Unit + } + }.onSuccess { + logDesktopReaderClose( + "close_resources_end windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()}" + ) + }.onFailure { error -> + logDesktopReaderClose( + "close_resources_fail windowId=${id.logPreview(80)} bookId=${bookId.logPreview(80)} " + + "content=${readerCloseContentLabel()} error=\"${error.message.orEmpty().logPreview(240)}\" " + + "type=${error.javaClass.simpleName}" + ) + throw error + } + } + + fun updateReaderWindow( + windowId: String, + transform: (DesktopReaderWindowState) -> DesktopReaderWindowState + ) { + readerWindows = readerWindows.map { window -> + if (window.id == windowId) transform(window) else window + } + } + + fun updateTextReaderWindow( + windowId: String, + transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text + ) { + readerWindows = readerWindows.replaceDesktopTextReaderContent(windowId, transform) + } + + fun textReaderWindowContent(windowId: String): DesktopReaderWindowContent.Text? { + return readerWindows.firstOrNull { it.id == windowId }?.content as? DesktopReaderWindowContent.Text + } + + fun saveReaderWindowStateSnapshot(snapshot: DesktopWindowStateSnapshot?) { + val persistable = snapshot?.toPersistableReaderWindowSnapshot() ?: return + savedReaderWindowState = persistable + scope.launch(Dispatchers.IO) { + runCatching { readerWindowStateStore.save(persistable) } + } + } + + fun markReaderCloudDirty( + bookId: String, + baseTimestamp: Long? = null, + sidecarsDirty: Boolean = false + ) { + if (bookId.isBlank()) return + if (bookId !in readerCloudDirtyBookIds) { + val resolvedBaseTimestamp = baseTimestamp + ?: state.rawLibraryBooks.firstOrNull { it.id == bookId }?.timestamp + ?: 0L + readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps + (bookId to resolvedBaseTimestamp) + logDesktopCloudSync { + "desktop.reader.dirty_start book=$bookId baseTs=$resolvedBaseTimestamp sidecarsDirty=$sidecarsDirty" + } + } + if (sidecarsDirty) { + readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds + bookId + } + readerCloudDirtyBookIds = readerCloudDirtyBookIds + bookId + } + + fun clearReaderCloudDirty(bookIds: Set) { + if (bookIds.isEmpty()) return + readerCloudDirtyBookIds = readerCloudDirtyBookIds - bookIds + readerCloudDirtyBaseTimestamps = readerCloudDirtyBaseTimestamps - bookIds + readerCloudDirtySidecarBookIds = readerCloudDirtySidecarBookIds - bookIds + } + + fun markReaderBooksClosing(bookIds: Set) { + if (bookIds.isEmpty()) return + closingReaderBookIds = closingReaderBookIds + bookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookIds + } + + fun downloadReaderImage(image: ReaderImageReference) { + val target = chooseSaveImageFile(image.suggestedDownloadFileName()) ?: return + runCatching { + target.parentFile?.mkdirs() + target.writeBytes(image.desktopImageBytes()) + }.onSuccess { + updateState(state.withBanner("Saved ${target.name}.")) + }.onFailure { error -> + updateState(state.withBanner(error.message ?: "Could not save image.", isError = true)) + } + } + + fun saveDesktopOriginalFile(book: BookItem) { + val source = book.path?.let(::File) + if (source?.isFile != true) { + updateState(state.withBanner("Original file is not available.", isError = true)) + return + } + val target = chooseSaveBookFile(book.desktopSuggestedOriginalFileName()) ?: return + runCatching { + target.parentFile?.mkdirs() + source.copyTo(target, overwrite = true) + }.onSuccess { + updateState(state.withBanner("Saved ${target.name}.")) + }.onFailure { error -> + updateState(state.withBanner(error.message ?: "Could not save file.", isError = true)) + } + } + + fun clearDesktopBookCache() { + scope.launch { + withContext(Dispatchers.IO) { + desktopEpubPaginationCache.clearAll() + SharedJvmBookLoader.clearCache() + } + epubPaginationCacheGeneration++ + updateState(state.withBanner("Book cache cleared. EPUB pagination will be recreated on demand.")) + } + } + + fun desktopCloudSyncAvailable(): Boolean { + return featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + !desktopBuildProfile.byokAiAvailable && + desktopCloudConfig.isAuthConfigured + } + + fun desktopAccountAvailable(): Boolean { + return featurePolicy.aiAndCloud && + featurePolicy.networkAccess && + !desktopBuildProfile.byokAiAvailable + } + + fun saveDesktopCloudSyncSettings( + syncEnabled: Boolean = state.isSyncEnabled, + folderSyncEnabled: Boolean = state.isFolderSyncEnabled + ) { + desktopCloudSyncSettingsStore.save( + DesktopCloudSyncSettings( + isSyncEnabled = syncEnabled, + isFolderSyncEnabled = folderSyncEnabled + ) + ) + } + + suspend fun refreshDesktopAccountProfile(showBanner: Boolean = false) { + if (!featurePolicy.aiAndCloud || desktopBuildProfile.byokAiAvailable) { + desktopAccountProfileRefreshCompleted = true + return + } + val session = desktopAuthRepository.restoreSavedSession() + if (session == null) { + if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) + updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) + desktopAccountProfileRefreshCompleted = true + return + } + val token = desktopAuthRepository.freshIdToken() + if (token.isNullOrBlank()) { + if (state.isSyncEnabled) saveDesktopCloudSyncSettings(syncEnabled = false) + updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) + desktopAccountProfileRefreshCompleted = true + return + } + runCatching { + desktopAccountProfileRepository.fetchProfile(session.user.uid, token) + }.onSuccess { profile -> + if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { + desktopAccountProfileRepository.saveFetchedProfile(session.user.uid, profile) + val nextSyncEnabled = state.isSyncEnabled && profile.isProUser + if (!nextSyncEnabled && state.isSyncEnabled) { + saveDesktopCloudSyncSettings(syncEnabled = false) + } + updateState( + state.copy( + currentUser = session.user, + isProUser = profile.isProUser, + credits = profile.credits, + isSyncEnabled = nextSyncEnabled + ) + ) + accountStatusMessage = if (profile.isProUser) { + "Account checked. Pro is unlocked." + } else { + "Account checked. Pro is not unlocked." + } + if (showBanner) updateState(state.withBanner("Account status refreshed.")) + } + }.onFailure { error -> + if (desktopAuthRepository.currentSession()?.user?.uid == session.user.uid) { + accountStatusMessage = error.message ?: "Could not check account status." + if (showBanner) updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) + } + } + desktopAccountProfileRefreshCompleted = true + } + + fun signInDesktopAccount() { + if (!desktopCloudConfig.isAuthConfigured) { + updateState(state.withBanner("Desktop Google sign-in is not configured for this build.", isError = true)) + return + } + scope.launch { + accountBusy = true + accountStatusMessage = "Waiting for Google sign-in..." + runCatching { + desktopAuthRepository.signIn(::openExternalUrl) + }.onSuccess { session -> + updateState(state.copy(currentUser = session.user, isProUser = false, credits = 0)) + accountStatusMessage = "Signed in. Checking account and credits..." + desktopAccountProfileRefreshCompleted = false + refreshDesktopAccountProfile() + }.onFailure { error -> + accountStatusMessage = error.message ?: "Google sign-in failed." + updateState(state.withBanner(accountStatusMessage.orEmpty(), isError = true)) + } + accountBusy = false + } + } + + suspend fun desktopCloudSyncCredentials(showBanner: Boolean): DesktopCloudSyncCredentials? { + if (!desktopCloudSyncAvailable()) { + if (showBanner) { + updateState(state.withBanner("Desktop cloud sync is not configured for this build.", isError = true)) + } + return null + } + + val session = desktopAuthRepository.restoreSavedSession() + val user = state.currentUser ?: session?.user + if (user == null) { + if (showBanner) updateState(state.withBanner("Sign in with Google to use cloud sync.", isError = true)) + return null + } + + if (!state.isProUser) { + if (showBanner) updateState(state.withBanner("A Pro account is required for cloud sync.", isError = true)) + return null + } + + val idToken = desktopAuthRepository.freshIdToken() + if (idToken.isNullOrBlank()) { + if (showBanner) updateState(state.withBanner("Sign in again to use cloud sync.", isError = true)) + return null + } + + val driveAccessToken = desktopAuthRepository.freshGoogleAccessToken() + if (driveAccessToken.isNullOrBlank()) { + if (showBanner) { + updateState(state.withBanner("Sign in again to grant Google Drive sync access.", isError = true)) + } + return null + } + + return DesktopCloudSyncCredentials( + userId = user.uid, + idToken = idToken, + driveAccessToken = driveAccessToken, + deviceId = desktopInstallationIdStore.getOrCreateId() + ) + } + + fun desktopCloudSyncCompleteMessage(result: DesktopCloudSyncResult): String { + val details = buildList { + if (result.uploadedBooks > 0) add("Uploaded ${result.uploadedBooks}.") + if (result.downloadedBooks > 0) add("Downloaded ${result.downloadedBooks}.") + if (result.pendingContentDownloads > 0) add("Waiting for ${result.pendingContentDownloads} upload(s) to finish.") + } + return if (details.isEmpty()) { + "Cloud sync complete." + } else { + "Cloud sync complete. ${details.joinToString(" ")}" + } + } + + fun syncDesktopCloud(showBanner: Boolean = false): Job { + desktopCloudSyncJob?.takeIf { it.isActive }?.let { + logDesktopCloudSync { "desktop.full_sync.reuse_active showBanner=$showBanner" } + return it + } + val job = scope.launch { + if (!state.isSyncEnabled) { + logDesktopCloudSync { "desktop.full_sync.skip reason=sync_disabled showBanner=$showBanner" } + return@launch + } + val credentials = desktopCloudSyncCredentials(showBanner) ?: run { + logDesktopCloudSync { "desktop.full_sync.skip reason=missing_credentials showBanner=$showBanner" } + return@launch + } + val snapshotState = state + val snapshotShelfRecords = shelfRecords + val snapshotShelfRefs = shelfRefs + val snapshotFonts = customFonts + logDesktopCloudSync { + "desktop.full_sync.start user=${credentials.userId} device=${credentials.deviceId} showBanner=$showBanner " + + "books=${snapshotState.rawLibraryBooks.size} shelves=${snapshotShelfRecords.size} folderSync=${snapshotState.isFolderSyncEnabled}" + } + + if (showBanner) { + updateState(state.copy(isRefreshing = true).withBanner("Cloud sync: checking library...")) + } + + runCatching { + withContext(Dispatchers.IO) { + desktopCloudSync.sync( + DesktopCloudSyncInput( + userId = credentials.userId, + idToken = credentials.idToken, + driveAccessToken = credentials.driveAccessToken, + deviceId = credentials.deviceId, + state = snapshotState, + shelfRecords = snapshotShelfRecords, + shelfRefs = snapshotShelfRefs, + customFonts = snapshotFonts, + includeFolderBooks = snapshotState.isFolderSyncEnabled + ) + ) + } + }.onSuccess { result -> + val openBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } + val snapshotBooksById = snapshotState.rawLibraryBooks.associateBy { it.id } + val syncedBooksById = result.state.rawLibraryBooks.associateBy { it.id } + val staleGuards = openBookIds.mapNotNull { bookId -> + val before = snapshotBooksById[bookId] ?: return@mapNotNull null + val after = syncedBooksById[bookId] ?: return@mapNotNull null + if (after.timestamp > before.timestamp && !before.hasSameCloudReaderPosition(after)) { + bookId to before + } else { + null + } + }.toMap() + if (staleGuards.isNotEmpty()) { + readerCloudStalePositionGuards = readerCloudStalePositionGuards + staleGuards + clearReaderCloudDirty(staleGuards.keys) + logDesktopCloudSync { + "desktop.full_sync.open_reader_guard books=${staleGuards.keys.joinToString()} " + + "reason=remote_advanced_while_reader_open" + } + } + logDesktopCloudSync { + "desktop.full_sync.success user=${credentials.userId} uploaded=${result.uploadedBooks} " + + "downloaded=${result.downloadedBooks} pendingContent=${result.pendingContentDownloads} " + + "books=${result.state.rawLibraryBooks.size}" + } + if (result.pendingContentDownloads <= 0) { + desktopCloudContentRetryJob?.cancel() + desktopCloudContentRetryJob = null + } else if (desktopCloudContentRetryJob?.isActive != true) { + desktopCloudContentRetryJob = scope.launch { + delay(DesktopCloudContentRetryDelayMillis) + if (state.isSyncEnabled) { + logDesktopCloudSync { "desktop.full_sync.content_retry pending=${result.pendingContentDownloads}" } + syncDesktopCloud(showBanner = false).join() + } + } + } + customFonts = result.customFonts + val syncedState = result.state.copy( + isSyncEnabled = state.isSyncEnabled, + isFolderSyncEnabled = state.isFolderSyncEnabled, + isRefreshing = false + ) + replaceLibrary( + next = if (showBanner) syncedState.withBanner(desktopCloudSyncCompleteMessage(result)) else syncedState, + records = result.shelfRecords, + refs = result.shelfRefs, + fonts = result.customFonts + ) + }.onFailure { error -> + logDesktopCloudSync { "desktop.full_sync.failed user=${credentials.userId} error=${error.message.orEmpty()}" } + val failed = state.copy(isRefreshing = false) + if (showBanner) { + updateState(failed.withBanner(error.message ?: "Cloud sync failed.", isError = true)) + } else { + updateState(failed) + } + } + } + desktopCloudSyncJob = job + job.invokeOnCompletion { + if (desktopCloudSyncJob == job) desktopCloudSyncJob = null + } + return job + } + + fun setDesktopCloudSyncEnabled(enabled: Boolean) { + if (enabled && !desktopCloudSyncAvailable()) { + updateState(state.withBanner("Desktop cloud sync is not configured for this build.", isError = true)) + return + } + if (enabled && state.currentUser == null) { + updateState(state.withBanner("Sign in with Google to use cloud sync.", isError = true)) + return + } + if (enabled && !state.isProUser) { + updateState(state.withBanner("A Pro account is required for cloud sync.", isError = true)) + return + } + + saveDesktopCloudSyncSettings(syncEnabled = enabled) + val next = state.reduce(AppAction.SyncEnabledChanged(enabled)) + updateState(next) + if (enabled) { + syncDesktopCloud(showBanner = true) + } + } + + fun setDesktopFolderSyncEnabled(enabled: Boolean) { + saveDesktopCloudSyncSettings(folderSyncEnabled = enabled) + val next = state.reduce(AppAction.FolderSyncEnabledChanged(enabled)) + updateState(next) + if (enabled && next.isSyncEnabled) { + syncDesktopCloud(showBanner = false) + } + } + + fun queueCloudBookMetadataSync( + book: BookItem, + uploadContent: Boolean = false, + debounce: Boolean = true, + dirtyBaseTimestamp: Long? = null, + forceUploadAnnotations: Boolean = false + ) { + if (!state.isSyncEnabled) return + if (isDesktopPdfReflowBookId(book.id)) return + if (book.sourceFolder != null) return + if (book.path?.startsWith("opds-pse") == true) return + if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return + + logDesktopCloudSync { + "desktop.book_queue.request uploadContent=$uploadContent debounce=$debounce dirtyBaseTs=$dirtyBaseTimestamp " + + "forceAnnotations=$forceUploadAnnotations ${book.desktopCloudSyncSummary()}" + } + desktopBookCloudSyncJobs.remove(book.id)?.cancel() + val job = scope.launch { + if (!uploadContent && debounce) delay(1_200L) + val credentials = desktopCloudSyncCredentials(showBanner = false) ?: run { + logDesktopCloudSync { "desktop.book_queue.skip reason=missing_credentials book=${book.id}" } + return@launch + } + val latestBook = state.rawLibraryBooks.firstOrNull { it.id == book.id } ?: run { + logDesktopCloudSync { "desktop.book_queue.skip reason=missing_local book=${book.id}" } + return@launch + } + if (isDesktopPdfReflowBookId(latestBook.id)) return@launch + if (latestBook.sourceFolder != null) return@launch + if (latestBook.path?.startsWith("opds-pse") == true) return@launch + if (SharedFileCapabilities.isManualOnlyReaderFileName(latestBook.displayName)) return@launch + + if (uploadContent) { + updateState(state.copy(uploadingBookIds = state.uploadingBookIds + latestBook.id)) + } + + try { + val remoteBook = withContext(Dispatchers.IO) { + desktopFirestoreRepository.getBookMetadata( + userId = credentials.userId, + bookId = latestBook.id, + idToken = credentials.idToken + ) + } + val localSidecarTimestamp = withContext(Dispatchers.IO) { + DesktopCloudSidecarSync.localAnnotationTimestamp(latestBook) + } + val hasLocalAnnotations = withContext(Dispatchers.IO) { + DesktopCloudSidecarSync.hasLocalAnnotationData(latestBook) + } + val remoteAnnotationDriveTimestamp = if (remoteBook?.hasAnnotations == true) { + withContext(Dispatchers.IO) { + desktopGoogleDriveRepository.getFileByName( + credentials.driveAccessToken, + desktopCloudAnnotationDriveFileName(latestBook.id) + )?.modifiedTimeMillis ?: 0L + } + } else { + 0L + } + val localReadingTimestamp = latestBook.effectiveCloudReadingPositionModifiedTimestamp() + val remoteReadingTimestamp = remoteBook?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L + val remoteAnnotationTimestamp = remoteBook?.effectiveCloudAnnotationModifiedTimestamp( + remoteAnnotationDriveTimestamp + ) ?: 0L + val latestBookForMetadata = if (remoteBook != null && remoteReadingTimestamp > localReadingTimestamp) { + latestBook.withCloudReadingPosition(remoteBook) + } else { + latestBook + } + val localFile = latestBook.path?.let(::File) + val localFileAvailable = localFile?.isFile == true + val localContentTimestamp = latestBook.fileContentModifiedTimestamp.takeIf { it > 0L } + ?: localFile?.takeIf { it.isFile }?.lastModified() + ?: 0L + val remoteChangedSinceDirtyStart = dirtyBaseTimestamp != null && + remoteBook != null && + remoteBook.lastModifiedTimestamp != dirtyBaseTimestamp + logDesktopCloudSync { + "desktop.book_queue.preflight book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + + "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + + latestBook.desktopCloudSyncSummary() + " " + + (remoteBook?.desktopCloudSyncSummary() ?: "remote=null") + + " localSidecarTs=$localSidecarTimestamp localContentTs=$localContentTimestamp" + } + logDesktopCloudAnnotations { + "desktop.queue.inspect book=${latestBook.id} dirtyBaseTs=$dirtyBaseTimestamp " + + "remoteChangedSinceDirtyStart=$remoteChangedSinceDirtyStart " + + "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " + + "localHas=$hasLocalAnnotations localSidecarTs=$localSidecarTimestamp " + + DesktopCloudSidecarSync.localAnnotationDebugSummary(latestBook) + } + if (remoteChangedSinceDirtyStart && !(forceUploadAnnotations && hasLocalAnnotations)) { + logDesktopCloudAnnotations { + "desktop.queue.skip_upload book=${latestBook.id} reason=remote_changed_since_dirty " + + "dirtyBaseTs=$dirtyBaseTimestamp remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L}" + } + logDesktopCloudSync { "desktop.book_queue.decision action=pull_remote_changed_since_dirty book=${latestBook.id}" } + syncDesktopCloud(showBanner = false).join() + return@launch + } + val canUploadMetadata = remoteBook == null || shouldUploadLocalCloudBookMetadataUpdate( + localModifiedTimestamp = latestBook.timestamp, + remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp + ) + val canUploadContent = when { + remoteBook?.isDeleted == true && canUploadMetadata -> localFileAvailable + uploadContent -> shouldUploadLocalCloudBookContent( + localFileAvailable = localFileAvailable, + localContentModifiedTimestamp = localContentTimestamp, + remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp + ) + else -> false + } + val canUploadAnnotations = (forceUploadAnnotations && hasLocalAnnotations) || + (hasLocalAnnotations && + (remoteBook == null || + !remoteBook.hasAnnotations || + localSidecarTimestamp > remoteAnnotationTimestamp)) + val shouldApplyRemote = remoteBook != null && shouldApplyRemoteCloudBookMetadataUpdate( + localModifiedTimestamp = latestBook.timestamp, + remoteModifiedTimestamp = remoteBook.lastModifiedTimestamp + ) + + if (remoteBook != null && !canUploadMetadata && !canUploadContent && !canUploadAnnotations) { + logDesktopCloudAnnotations { + "desktop.queue.no_upload book=${latestBook.id} canUploadAnnotations=$canUploadAnnotations " + + "canUploadMetadata=$canUploadMetadata shouldApplyRemote=$shouldApplyRemote " + + "remoteHas=${remoteBook.hasAnnotations} remoteTs=${remoteBook.lastModifiedTimestamp} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "localSidecarTs=$localSidecarTimestamp" + } + logDesktopCloudSync { + "desktop.book_queue.decision action=${if (remoteBook.isDeleted || shouldApplyRemote) "pull_remote" else "noop"} " + + "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + + "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" + } + if (remoteBook.isDeleted || shouldApplyRemote) { + syncDesktopCloud(showBanner = false).join() + } + return@launch + } + + val usesRemoteMetadataForUpload = !canUploadMetadata && shouldApplyRemote && remoteBook != null + val bookForUpload = if (usesRemoteMetadataForUpload && remoteBook != null) { + remoteBook.toDesktopBookItem(existing = latestBook).let { remoteMetadataBook -> + if (canUploadContent) { + remoteMetadataBook.copy(fileContentModifiedTimestamp = localContentTimestamp) + } else { + remoteMetadataBook + } + } + } else { + latestBookForMetadata + } + logDesktopCloudSync { + "desktop.book_queue.decision action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + + "book=${latestBook.id} canUploadMetadata=$canUploadMetadata canUploadContent=$canUploadContent " + + "canUploadAnnotations=$canUploadAnnotations shouldApplyRemote=$shouldApplyRemote" + } + logDesktopCloudAnnotations { + "desktop.queue.upload book=${latestBook.id} action=${if (usesRemoteMetadataForUpload) "upload_annotations_with_remote_metadata" else "upload_local"} " + + "canUploadAnnotations=$canUploadAnnotations canUploadMetadata=$canUploadMetadata " + + "remoteHas=${remoteBook?.hasAnnotations} remoteTs=${remoteBook?.lastModifiedTimestamp ?: 0L} " + + "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " + + "localSidecarTs=$localSidecarTimestamp" + } + val syncedBook = withContext(Dispatchers.IO) { + desktopCloudSync.uploadBookAndMetadata( + input = DesktopCloudSyncInput( + userId = credentials.userId, + idToken = credentials.idToken, + driveAccessToken = credentials.driveAccessToken, + deviceId = credentials.deviceId, + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + customFonts = customFonts, + includeFolderBooks = state.isFolderSyncEnabled + ), + book = bookForUpload, + uploadContent = canUploadContent, + uploadAnnotations = canUploadAnnotations, + remoteHasAnnotations = remoteBook?.hasAnnotations == true, + remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp, + remoteContentModifiedTimestamp = remoteBook?.fileContentModifiedTimestamp + ) + } ?: return@launch + + logDesktopCloudSync { + "desktop.book_queue.upload_success oldTs=${latestBook.timestamp} newTs=${syncedBook.timestamp} " + + syncedBook.desktopCloudSyncSummary("synced") + } + updateState( + state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { current -> + if (current.id == syncedBook.id && current.timestamp == latestBook.timestamp) { + syncedBook + } else { + current + } + } + ) + ) + } finally { + if (uploadContent) { + updateState(state.copy(uploadingBookIds = state.uploadingBookIds - latestBook.id)) + } + } + } + desktopBookCloudSyncJobs[book.id] = job + job.invokeOnCompletion { + if (desktopBookCloudSyncJobs[book.id] == job) { + desktopBookCloudSyncJobs.remove(book.id) + } + } + } + + fun syncClosedReaderBooksIfDirty(bookIds: Set) { + val dirtyBookIds = bookIds.intersect(readerCloudDirtyBookIds) + if (dirtyBookIds.isEmpty()) return + logDesktopCloudSync { "desktop.reader.close_dirty books=${dirtyBookIds.joinToString()} requested=${bookIds.joinToString()}" } + val dirtyBooks = dirtyBookIds.mapNotNull { bookId -> + state.rawLibraryBooks.firstOrNull { it.id == bookId } + ?.let { book -> + Triple( + book, + readerCloudDirtyBaseTimestamps[bookId], + bookId in readerCloudDirtySidecarBookIds + ) + } + } + clearReaderCloudDirty(dirtyBookIds) + dirtyBooks.forEach { (book, baseTimestamp, sidecarsDirty) -> + queueCloudBookMetadataSync( + book = book, + debounce = false, + dirtyBaseTimestamp = baseTimestamp, + forceUploadAnnotations = sidecarsDirty + ) + } + } + + fun syncClosedReaderBooksAfterDispose(bookIds: Set) { + if (bookIds.isEmpty()) return + scope.launch { + delay(DesktopReaderCloseDisposeSyncDelayMillis) + val stillClosedBookIds = bookIds + .filter { bookId -> readerWindows.none { it.bookId == bookId } } + .toSet() + closingReaderBookIds = closingReaderBookIds - bookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - stillClosedBookIds + syncClosedReaderBooksIfDirty(stillClosedBookIds) + clearReaderCloudDirty(stillClosedBookIds) + } + } + + fun closeReaderWindow(windowId: String) { + logDesktopReaderClose("close_window_request windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") + val closing = readerWindows.firstOrNull { it.id == windowId } ?: run { + logDesktopReaderClose("close_window_missing windowId=${windowId.logPreview(80)} openWindows=${readerWindows.size}") + return + } + val closingBookIds = setOf(closing.bookId) + val shouldStopTts = (closing.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + logDesktopReaderClose( + "close_window_begin windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + + "content=${closing.readerCloseContentLabel()} fullscreen=${closing.fullscreen} shouldStopTts=$shouldStopTts" + ) + if (desktopFeatureNoticeState?.placement?.readerWindowId == windowId) { + dismissDesktopFeatureNotice() + } + markReaderBooksClosing(closingBookIds) + closing.cancelReaderWork() + readerWindows = readerWindows.withoutDesktopReaderWindow(windowId) + logDesktopReaderClose( + "close_window_removed windowId=${closing.id.logPreview(80)} bookId=${closing.bookId.logPreview(80)} " + + "remainingWindows=${readerWindows.size}" + ) + updateState(state.reduce(AppAction.BookTabClosed(closing.bookId))) + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + syncClosedReaderBooksAfterDispose(closingBookIds) + } + + fun closeReaderWindowsForBookIds(bookIds: Set) { + if (bookIds.isEmpty()) return + val closing = readerWindows.filter { it.bookId in bookIds } + val closingBookIds = closing.mapTo(mutableSetOf()) { it.bookId } + val closingWindowIds = closing.mapTo(mutableSetOf()) { it.id } + val shouldStopTts = closing.any { window -> + (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + } + val targetNoticeWindowId = desktopFeatureNoticeState?.placement?.readerWindowId + if (targetNoticeWindowId != null && targetNoticeWindowId in closingWindowIds) { + dismissDesktopFeatureNotice() + } + markReaderBooksClosing(closingBookIds) + closing.forEach { it.cancelReaderWork() } + readerWindows = readerWindows.withoutDesktopReaderBookIds(bookIds) + if (closingBookIds.isNotEmpty()) { + var nextState = state + closingBookIds.forEach { bookId -> + nextState = nextState.reduce(AppAction.BookTabClosed(bookId)) + } + updateState(nextState) + } + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + closingReaderBookIds = closingReaderBookIds - closingBookIds + readerCloudStalePositionGuards = readerCloudStalePositionGuards - closingBookIds + clearReaderCloudDirty(closingBookIds) + } + + fun closeAllReaderWindows() { + val closingBookIds = readerWindows.mapTo(mutableSetOf()) { it.bookId } + val shouldStopTts = readerWindows.any { window -> + (window.content as? DesktopReaderWindowContent.Text)?.extrasState?.cloudTts?.let { + it.isLoading || it.isPlaying || it.isPaused + } == true + } + markReaderBooksClosing(closingBookIds) + readerWindows.forEach { it.cancelReaderWork() } + readerWindows = emptyList() + if (desktopFeatureNoticeState?.placement?.readerWindowId != null) { + dismissDesktopFeatureNotice() + } + updateState(state.reduce(AppAction.AllTabsClosed)) + if (shouldStopTts) { + scope.launch { desktopTtsAdapter.stop() } + } + syncClosedReaderBooksAfterDispose(closingBookIds) + } + + fun syncCloudShelfChange(record: ShelfRecord, refs: List, isDeleted: Boolean = false) { + if (!state.isSyncEnabled || record.isSmart) return + scope.launch { + val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch + withContext(Dispatchers.IO) { + desktopCloudSync.syncShelfChange( + userId = credentials.userId, + idToken = credentials.idToken, + deviceId = credentials.deviceId, + record = record, + refs = refs, + isDeleted = isDeleted + ) + } + } + } + + fun deleteBooksFromDesktopCloud(books: List) { + if (!state.isSyncEnabled || books.isEmpty()) return + scope.launch { + val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch + withContext(Dispatchers.IO) { + desktopCloudSync.deleteBooksFromCloud( + userId = credentials.userId, + idToken = credentials.idToken, + accessToken = credentials.driveAccessToken, + deviceId = credentials.deviceId, + books = books + ) + } + } + } + + fun deleteCustomFontFromDesktopCloud(font: CustomFontItem) { + if (!state.isSyncEnabled) return + scope.launch { + val credentials = desktopCloudSyncCredentials(showBanner = false) ?: return@launch + withContext(Dispatchers.IO) { + desktopCloudSync.deleteFontFromCloud( + userId = credentials.userId, + idToken = credentials.idToken, + accessToken = credentials.driveAccessToken, + font = font + ) + } + } + } + + fun queueFullCloudSyncAfterLocalChange() { + if (!state.isSyncEnabled) return + val active = desktopCloudSyncJob + if (active?.isActive == true) { + if (pendingDesktopCloudSyncAfterActive) return + pendingDesktopCloudSyncAfterActive = true + scope.launch { + active.join() + pendingDesktopCloudSyncAfterActive = false + if (state.isSyncEnabled) { + syncDesktopCloud(showBanner = false) + } + } + } else { + syncDesktopCloud(showBanner = false) + } + } + + fun updateAiByokSettings(next: ReaderAiByokSettings) { + val sanitized = next.toDesktopPersistableAiSettings() + if (sanitized.ttsSpeakerId != aiByokSettings.toDesktopPersistableAiSettings().ttsSpeakerId && desktopTtsAdapter.isPlaybackActive) { + scope.launch { + snackbarHostState.showSnackbar( + desktopString("desktop_stop_reading_change_voices", "Stop reading to change voices.") + ) + } + return + } + val settingsToSave = sanitized + logDesktopTts( + "settings_update keyPresent=${sanitized.geminiKey.isNotBlank()} " + + "ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\" speaker=\"${sanitized.ttsSpeakerId.desktopTtsPreview()}\" " + + "cloudAvailable=${sanitized.isCloudTtsAvailable}" + ) + + aiByokSettings = settingsToSave + readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> + content.copy( + extrasState = content.extrasState.copy( + cloudTts = content.extrasState.cloudTts.copy( + isAvailable = effectiveAiSettings().isCloudTtsAvailable, + errorMessage = null, + cacheSummary = desktopTtsAdapter.cacheSummary( + content.session.reader.book.title, + settingsToSave.ttsSpeakerId + ) + ) + ) + ) + } + runCatching { aiByokStore.save(settingsToSave) } + .onFailure { error -> + logDesktopTts("settings_save_failed error=\"${error.desktopTtsSummary()}\"") + scope.launch { + snackbarHostState.showSnackbar(error.message ?: "AI settings could not be saved securely.") + } + } + } + + fun textReaderTtsCacheSummary(content: DesktopReaderWindowContent.Text): ReaderTtsCacheSummary { + return desktopTtsAdapter.cacheSummary( + content.session.reader.book.title, + aiByokSettings.sanitized().ttsSpeakerId + ) + } + + fun readerCloudTtsStoppedState( + content: DesktopReaderWindowContent.Text, + statusMessage: String? = null, + errorMessage: String? = null + ) = ReaderCloudTtsState( + isAvailable = effectiveAiSettings().isCloudTtsAvailable, + statusMessage = statusMessage, + errorMessage = errorMessage, + cacheSummary = textReaderTtsCacheSummary(content) + ) + + fun cloudTtsUnavailableMessage(): String { + return if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { + desktopString( + "desktop_cloud_tts_unavailable", + "Cloud TTS unavailable" + ) + } else if (!desktopByokCloudTtsAvailable && !desktopCreditCloudTtsControlsAvailable) { + desktopString( + "desktop_cloud_tts_not_configured_desc", + "Cloud TTS is not configured for this desktop build." + ) + } else if (state.currentUser == null) { + desktopString("desktop_cloud_tts_sign_in_required_desc", "Sign in with Google to use cloud TTS.") + } else if (state.credits <= 0) { + desktopString( + "desktop_out_of_credits_android_purchase_desc", + "Out of credits. Pro and credits can only be purchased from the Android app." + ) + } else { + desktopString( + "desktop_cloud_tts_not_configured_desc", + "Cloud TTS is not configured for this desktop build." + ) + } + } + + fun desktopReadScopeLabel(readScope: ReaderTtsReadScope): String { + return when (readScope) { + ReaderTtsReadScope.PAGE -> desktopString("desktop_page", "Page") + ReaderTtsReadScope.CHAPTER -> desktopString("chapter", "Chapter") + ReaderTtsReadScope.BOOK -> desktopString("desktop_from_here", "From here") + } + } + + fun desktopFeatureNoticeForReaderAi(feature: ReaderAiFeature, text: String): DesktopFeatureNotice? { + if (desktopBuildProfile.byokAiAvailable) return null + if (!featurePolicy.networkAccess || !desktopCloudConfig.isAiWorkerConfigured) { + return desktopFeatureUnavailableNotice( + messageKey = "desktop_ai_not_configured_desc", + messageFallback = "Desktop AI is not configured for this build." + ) + } + if (feature == ReaderAiFeature.DEFINE && desktopReaderWordCount(text) > 1 && state.currentUser == null) { + return desktopSignInRequiredNotice( + messageKey = "desktop_sign_in_required_multi_word_dictionary_desc", + messageFallback = "Sign in with Google to use multi-word smart dictionary on desktop." + ) + } + if (feature == ReaderAiFeature.DEFINE && desktopReaderWordCount(text) > 1 && !state.isProUser) { + return desktopProRequiredNotice( + messageKey = "desktop_pro_required_multi_word_dictionary_desc", + messageFallback = "Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in." + ) + } + if (feature == ReaderAiFeature.SUMMARIZE && state.currentUser == null) { + return desktopSignInRequiredNotice( + messageKey = "desktop_sign_in_required_summaries_desc", + messageFallback = "Sign in with Google to use summaries on desktop." + ) + } + if (feature == ReaderAiFeature.SUMMARIZE && !state.isProUser && state.credits <= 0) { + return desktopOutOfCreditsNotice( + messageKey = "desktop_out_of_credits_summaries_desc", + messageFallback = "Using summaries needs credits on desktop. Pro and credits can only be purchased from the Android app." + ) + } + if (feature == ReaderAiFeature.RECAP && state.currentUser == null) { + return desktopSignInRequiredNotice( + messageKey = "desktop_sign_in_required_recaps_desc", + messageFallback = "Sign in with Google to use recaps on desktop." + ) + } + if (feature == ReaderAiFeature.RECAP && state.credits <= 0) { + return desktopOutOfCreditsNotice( + messageKey = "desktop_out_of_credits_recaps_desc", + messageFallback = "Using recaps needs credits on desktop. Pro and credits can only be purchased from the Android app." + ) + } + return null + } + + fun desktopFeatureNoticeForCloudTts(): DesktopFeatureNotice? { + if (!featurePolicy.aiAndCloud || !featurePolicy.networkAccess) { + return desktopFeatureUnavailableNotice( + messageKey = "desktop_cloud_tts_not_configured_desc", + messageFallback = "Cloud TTS is not configured for this desktop build." + ) + } + if (desktopByokCloudTtsAvailable) return null + if (!desktopCreditCloudTtsControlsAvailable) { + return desktopFeatureUnavailableNotice( + messageKey = "desktop_cloud_tts_not_configured_desc", + messageFallback = "Cloud TTS is not configured for this desktop build." + ) + } + if (state.currentUser == null) { + return desktopSignInRequiredNotice( + messageKey = "desktop_cloud_tts_sign_in_required_desc", + messageFallback = "Sign in with Google to use cloud TTS." + ) + } + if (state.credits <= 0) { + return desktopOutOfCreditsNotice( + messageKey = "desktop_out_of_credits_cloud_tts_desc", + messageFallback = "Using cloud TTS needs credits on desktop. Pro and credits can only be purchased from the Android app." + ) + } + return null + } + + fun openReaderExternalLookup(action: ReaderExternalLookupAction, text: String) { + if (!featurePolicy.externalLookup) return + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + openExternalUrl(externalLookupUrl(action, normalizedText.take(1800))) + } + + fun readerHubBookKey(content: DesktopReaderWindowContent.Text): String { + return content.book.id.ifBlank { + content.session.reader.book.id.ifBlank { content.session.reader.book.title.ifBlank { "Untitled" } } + } + } + + fun readerHubChapterIndex(content: DesktopReaderWindowContent.Text): Int { + return content.session.reader.currentPage?.chapterIndex + ?: content.session.reader.currentPageIndex + } + + fun readerHubChapterTitle( + content: DesktopReaderWindowContent.Text, + index: Int = readerHubChapterIndex(content) + ): String { + return content.session.reader.book.chapters.getOrNull(index)?.title?.takeIf { it.isNotBlank() } + ?: content.session.reader.currentPage?.chapterTitle?.takeIf { it.isNotBlank() } + ?: "Chapter ${index + 1}" + } + + fun readerHubChapterText( + content: DesktopReaderWindowContent.Text, + index: Int = readerHubChapterIndex(content) + ): String { + return content.session.reader.book.chapters.getOrNull(index)?.plainText?.trim().orEmpty() + } + + fun readerHubCurrentChapterText(content: DesktopReaderWindowContent.Text): String { + return ReaderContextExtractor.currentChapterText(content.session).trim() + .ifBlank { readerHubChapterText(content) } + .ifBlank { content.session.reader.currentPage?.text?.trim().orEmpty() } + } + + fun readerHubCurrentTextForRecap(content: DesktopReaderWindowContent.Text): String { + val chapterText = readerHubChapterText(content) + val endOffset = content.session.reader.currentPage?.endOffset ?: chapterText.length + return if (chapterText.isNotBlank()) { + chapterText.take(endOffset.coerceIn(0, chapterText.length)).trim() + .ifBlank { chapterText.take(500).trim() } + } else { + ReaderContextExtractor.textBeforeCurrentLocation(content.session).trim().takeLast(24_000) + } + } + + fun clearReaderHubSummary(windowId: String) { + updateTextReaderWindow(windowId) { content -> + content.copy(summaryResult = null, isSummaryLoading = false) + } + } + + fun clearReaderHubRecap(windowId: String) { + updateTextReaderWindow(windowId) { content -> + content.copy(recapResult = null, isRecapLoading = false, recapProgressMessage = null) + } + } + + fun generateReaderHubSummary(windowId: String, force: Boolean) { + val content = textReaderWindowContent(windowId) ?: return + val text = readerHubCurrentChapterText(content) + val chapterIndex = readerHubChapterIndex(content) + val chapterTitle = readerHubChapterTitle(content, chapterIndex) + val bookKey = readerHubBookKey(content) + if (text.isBlank()) { + updateTextReaderWindow(windowId) { + it.copy( + summaryResult = SummarizationResult( + error = desktopString("desktop_no_text_to_summarize", "There is no text to summarize.") + ) + ) + } + return + } + if (!force) { + desktopSummaryCacheStore.getSummary(bookKey, chapterIndex)?.let { cached -> + updateTextReaderWindow(windowId) { + it.copy(summaryResult = SummarizationResult(summary = cached, isCacheHit = true)) + } + return + } + } + desktopFeatureNoticeForReaderAi(ReaderAiFeature.SUMMARIZE, text)?.let { notice -> + showDesktopFeatureNotice(notice, readerWindowId = windowId) + return + } + updateTextReaderWindow(windowId) { it.copy(isSummaryLoading = true, summaryResult = null) } + scope.launch { + var streamedSummary = "" + var streamedCost: Double? = null + var streamedFreeRemaining: Int? = null + fun updateStreamingSummary(error: String? = null) { + updateTextReaderWindow(windowId) { current -> + current.copy( + summaryResult = SummarizationResult( + summary = streamedSummary.takeIf { it.isNotBlank() }, + error = error, + cost = streamedCost, + freeRemaining = streamedFreeRemaining + ) + ) + } + } + val result = desktopAiAdapter.summarizeStreaming( + text = text, + onUsageReceived = { cost, freeRemaining -> + cost?.let { streamedCost = it } + freeRemaining?.let { streamedFreeRemaining = it } + updateStreamingSummary() + }, + onUpdate = { chunk -> + streamedSummary += chunk + updateStreamingSummary() + } + ) + val finalSummary = result.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } + finalSummary?.let { summary -> + desktopSummaryCacheStore.saveSummary(bookKey, chapterIndex, chapterTitle, summary) + } + updateTextReaderWindow(windowId) { current -> + current.copy( + summaryResult = result.copy(summary = finalSummary), + isSummaryLoading = false + ) + } + desktopFeatureNoticeForError(result.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } + } + } + + fun generateReaderHubRecap(windowId: String) { + val content = textReaderWindowContent(windowId) ?: return + val currentText = readerHubCurrentTextForRecap(content) + if (currentText.isBlank()) { + updateTextReaderWindow(windowId) { it.copy(recapResult = RecapResult(error = "There is no reading context for a recap.")) } + return + } + desktopFeatureNoticeForReaderAi(ReaderAiFeature.RECAP, currentText)?.let { notice -> + showDesktopFeatureNotice(notice, readerWindowId = windowId) + return + } + val book = content.session.reader.book + val bookKey = readerHubBookKey(content) + val currentChapterIndex = readerHubChapterIndex(content).coerceIn(0, book.chapters.size.coerceAtLeast(1) - 1) + updateTextReaderWindow(windowId) { + it.copy( + isRecapLoading = true, + recapResult = null, + recapProgressMessage = "Checking past chapters..." + ) + } + scope.launch { + val pastSummaries = mutableListOf() + for (chapterIndex in 0 until currentChapterIndex) { + updateTextReaderWindow(windowId) { it.copy(recapProgressMessage = "Analyzing Chapter ${chapterIndex + 1}...") } + val cached = desktopSummaryCacheStore.getSummary(bookKey, chapterIndex) + if (!cached.isNullOrBlank()) { + pastSummaries += cached + continue + } + val latest = textReaderWindowContent(windowId) ?: return@launch + val chapterText = readerHubChapterText(latest, chapterIndex) + if (chapterText.length <= 100) continue + val summary = desktopAiAdapter.summarize(chapterText) + summary.summary?.takeIf { it.isNotBlank() }?.let { generated -> + val title = readerHubChapterTitle(latest, chapterIndex) + desktopSummaryCacheStore.saveSummary(bookKey, chapterIndex, title, generated) + pastSummaries += generated + } + if (summary.error != null) { + desktopFeatureNoticeForError(summary.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } + } + delay(500) + } + + updateTextReaderWindow(windowId) { it.copy(recapProgressMessage = "Generating recap...") } + val recap = (desktopAiAdapter as? DesktopPaidAiAdapter) + ?.recapWithContext(pastSummaries, currentText) + ?: desktopAiAdapter.recap( + buildString { + pastSummaries.forEachIndexed { index, summary -> + append("Past chapter ${index + 1} summary:\n") + append(summary) + append("\n\n") + } + append(currentText) + } + ) + updateTextReaderWindow(windowId) { + it.copy( + recapResult = recap, + isRecapLoading = false, + recapProgressMessage = null + ) + } + desktopFeatureNoticeForError(recap.error)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } + } + } + + fun isReaderAiResultVisible(content: DesktopReaderWindowContent.Text, requestId: Long): Boolean = + content.readerAiResultRequestId == requestId && content.dismissedReaderAiResultRequestId != requestId + + fun updateReaderAiResult(windowId: String, requestId: Long, aiResult: ReaderAiResultState) { + updateTextReaderWindow(windowId) { content -> + if (isReaderAiResultVisible(content, requestId)) { + content.copy(extrasState = content.extrasState.copy(aiResult = aiResult)) + } else { + content + } + } + } + + fun runReaderAiAction(windowId: String, feature: ReaderAiFeature, text: String) { + val content = textReaderWindowContent(windowId) ?: return + val normalizedText = text.trim() + if (normalizedText.isBlank()) return + if (!effectiveAiSettings().areReaderAiFeaturesAvailable) return + desktopFeatureNoticeForReaderAi(feature, normalizedText)?.let { notice -> + showDesktopFeatureNotice(notice, readerWindowId = windowId) + return + } + val aiResultRequestId = content.readerAiResultRequestId + 1 + updateTextReaderWindow(windowId) { + it.copy( + readerAiResultRequestId = aiResultRequestId, + dismissedReaderAiResultRequestId = null + ) + } + updateReaderAiResult( + windowId, + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + isLoading = true + ) + ) + scope.launch { + val result = when (feature) { + ReaderAiFeature.DEFINE -> { + var streamedDefinition = "" + val latest = textReaderWindowContent(windowId) ?: return@launch + val definition = desktopAiAdapter.defineStreaming( + text = normalizedText.take(2400), + context = ReaderContextExtractor.currentPageText(latest.session), + onUpdate = { chunk -> + streamedDefinition += chunk + updateReaderAiResult( + windowId, + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + text = streamedDefinition, + isLoading = true + ) + ) + } + ) + (definition.definition?.takeIf { it.isNotBlank() } ?: streamedDefinition) to definition.error + } + ReaderAiFeature.SUMMARIZE -> { + var streamedSummary = "" + var streamedCost: Double? = null + var streamedFreeRemaining: Int? = null + fun updateStreamingSummary() { + val partial = SummarizationResult( + summary = streamedSummary.takeIf { it.isNotBlank() }, + cost = streamedCost, + freeRemaining = streamedFreeRemaining + ) + updateTextReaderWindow(windowId) { current -> + current.copy(summaryResult = partial) + } + updateReaderAiResult( + windowId, + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + text = streamedSummary, + isLoading = true + ) + ) + } + val summary = desktopAiAdapter.summarizeStreaming( + text = normalizedText, + onUsageReceived = { cost, freeRemaining -> + cost?.let { streamedCost = it } + freeRemaining?.let { streamedFreeRemaining = it } + updateStreamingSummary() + }, + onUpdate = { chunk -> + streamedSummary += chunk + updateStreamingSummary() + } + ) + val finalSummary = summary.summary?.takeIf { it.isNotBlank() } ?: streamedSummary.takeIf { it.isNotBlank() } + finalSummary?.let { generated -> + val latest = textReaderWindowContent(windowId) ?: return@let + desktopSummaryCacheStore.saveSummary( + readerHubBookKey(latest), + readerHubChapterIndex(latest), + readerHubChapterTitle(latest), + generated + ) + } + updateTextReaderWindow(windowId) { current -> + current.copy(summaryResult = summary.copy(summary = finalSummary)) + } + finalSummary to summary.error + } + ReaderAiFeature.RECAP -> { + val recap = desktopAiAdapter.recap(normalizedText) + updateTextReaderWindow(windowId) { current -> + current.copy(recapResult = recap) + } + recap.recap to recap.error + } + } + updateReaderAiResult( + windowId, + aiResultRequestId, + ReaderAiResultState( + title = feature.displayName, + text = result.first.orEmpty(), + errorMessage = result.second, + isLoading = false + ) + ) + val latest = textReaderWindowContent(windowId) + if (latest != null && isReaderAiResultVisible(latest, aiResultRequestId)) { + desktopFeatureNoticeForError(result.second)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } + } + } + } + + fun isDesktopFolderLocalSyncEnabled(sourceFolder: String?): Boolean { + if (sourceFolder.isNullOrBlank()) return false + return state.syncedFolders.firstOrNull { it.uriString == sourceFolder }?.localSyncEnabled ?: true + } + + fun syncBookSidecars(book: BookItem, debounceMillis: Long = 0L) { + if (book.sourceFolder.isNullOrBlank()) { + logDesktopFolderSync("bookSidecars.skipNoFolder book=${book.id}") + return + } + if (!isDesktopFolderLocalSyncEnabled(book.sourceFolder)) { + logDesktopFolderSync( + "bookSidecars.skipDisabled book=${book.id} " + + "sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" + ) + return + } + logDesktopFolderSync( + "bookSidecars.request book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\"" + ) + desktopBookSidecarSaveJobs.remove(book.id)?.cancel() + val saveJob = scope.launch(Dispatchers.IO) { + if (debounceMillis > 0L) { + delay(debounceMillis) + } + DesktopLocalFolderSync.saveBookSidecars(book) + } + desktopBookSidecarSaveJobs[book.id] = saveJob + saveJob.invokeOnCompletion { + desktopBookSidecarSaveJobs.remove(book.id, saveJob) + } + } + + fun scheduleFolderMetadataExtraction(sourceFolders: Set) { + val enabledSourceFolders = sourceFolders.filterTo(mutableSetOf()) { isDesktopFolderLocalSyncEnabled(it) } + if (enabledSourceFolders.isEmpty()) return + val snapshotBooks = state.rawLibraryBooks + val originalBooksById = snapshotBooks + .filter { it.sourceFolder in enabledSourceFolders } + .associateBy { it.id } + if (originalBooksById.isEmpty()) return + + scope.launch { + val metadataResult = withContext(Dispatchers.IO) { + DesktopFolderMetadataExtractor.enrichFolderBooks( + books = snapshotBooks, + sourceFolders = enabledSourceFolders + ) + } + if (metadataResult.stats.updatedBooks <= 0) return@launch + + val enrichedBooksById = metadataResult.books + .filter { it.sourceFolder in enabledSourceFolders } + .associateBy { it.id } + val booksToSave = mutableListOf() + val mergedBooks = state.rawLibraryBooks.map { current -> + val enriched = enrichedBooksById[current.id] + ?.takeIf { current.sourceFolder in enabledSourceFolders } + ?: return@map current + val merged = current.withDesktopImportMetadata( + enriched = enriched, + original = originalBooksById[current.id] + ) + if (merged != current) booksToSave += merged + merged + } + if (booksToSave.isEmpty()) return@launch + + updateState(state.copy(rawLibraryBooks = mergedBooks)) + withContext(Dispatchers.IO) { + booksToSave.forEach { syncBook -> + DesktopLocalFolderSync.saveBookSidecars(syncBook) + } + } + } + } + + fun BookItem.matchesIncomingReaderPosition( + pageIndex: Int, + progress: Float, + session: ReaderSessionState? + ): Boolean { + val savedPage = if (type == FileType.PDF || type == FileType.PPTX || SharedFileCapabilities.isComicArchive(type)) { + lastPageIndex + } else { + readerPosition?.pageIndex ?: lastPageIndex + } + val savedProgress = progressPercentage + val progressMatches = savedProgress != null && kotlin.math.abs(savedProgress - progress) < 0.001f + val locatorMatches = session == null || readerPosition == session.navigationLocator + return savedPage == pageIndex && progressMatches && locatorMatches + } + + fun shouldIgnoreStaleReaderEcho( + bookId: String, + pageIndex: Int, + progress: Float, + session: ReaderSessionState? + ): Boolean { + val guard = readerCloudStalePositionGuards[bookId] ?: return false + if (guard.matchesIncomingReaderPosition(pageIndex, progress, session)) { + logDesktopPositionTrace { + "event=persist_skip_stale_echo bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" + } + logDesktopCloudSync { + "desktop.reader.position_skip_stale_echo book=$bookId page=$pageIndex progress=$progress " + + guard.desktopCloudSyncSummary("guard") + } + return true + } + readerCloudStalePositionGuards = readerCloudStalePositionGuards - bookId + logDesktopPositionTrace { + "event=persist_stale_guard_cleared bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "guardLocator=${guard.readerPosition.desktopPositionTraceSummary()}" + } + logDesktopCloudSync { + "desktop.reader.position_guard_cleared book=$bookId page=$pageIndex progress=$progress" + } + return false + } + + fun updateBookReadingState( + bookId: String, + pageIndex: Int, + progress: Float, + session: ReaderSessionState? = null, + pdfViewport: SharedPdfReaderViewport? = null + ) { + val hasOpenReaderWindow = readerWindows.any { it.bookId == bookId } + val previousBook = state.rawLibraryBooks.firstOrNull { it.id == bookId } + logDesktopPositionTrace { + "event=persist_request bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress " + + "hasSession=${session != null} mode=${session?.reader?.settings?.readingMode ?: "none"} " + + "openWindow=$hasOpenReaderWindow closing=${bookId in closingReaderBookIds} " + + "incomingLocator=${session?.navigationLocator.desktopPositionTraceSummary()} " + + "previousPage=${previousBook?.lastPageIndex ?: "null"} " + + "previousProgress=${previousBook?.progressPercentage ?: "null"} " + + "previousLocator=${previousBook?.readerPosition.desktopPositionTraceSummary()}" + } + if (!hasOpenReaderWindow && bookId !in closingReaderBookIds) { + logDesktopPositionTrace { + "event=persist_skip_closed bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + logDesktopCloudSync { + "desktop.reader.position_skip_closed book=$bookId page=$pageIndex progress=$progress" + } + return + } + if (shouldIgnoreStaleReaderEcho(bookId, pageIndex, progress, session)) return + + var updatedBook: BookItem? = null + var shouldSyncSidecars = false + var dirtyBaseTimestamp: Long? = null + if (previousBook == null) { + logDesktopPositionTrace { + "event=persist_skip_missing_book bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + return + } + val textReaderSettings = session?.reader?.settings + val updatedTextReaderDefaults = textReaderSettings + ?.takeIf { it != state.readerDefaultSettings } + val readerPosition = session?.navigationLocator + val nextReaderSettings = textReaderSettings ?: previousBook.readerSettings + val nextBookmarks = session?.bookmarks ?: previousBook.readerBookmarks + val nextHighlights = session?.highlights ?: previousBook.readerHighlights + val nextPdfViewport = pdfViewport ?: previousBook.pdfReaderViewport + val progressChanged = previousBook.progressPercentage + ?.let { kotlin.math.abs(it - progress) >= DesktopProgressEpsilon } + ?: true + val isReaderDirty = + previousBook.lastPageIndex != pageIndex || + progressChanged || + previousBook.readerPosition != readerPosition || + previousBook.readerSettings != nextReaderSettings || + previousBook.readerBookmarks != nextBookmarks || + previousBook.readerHighlights != nextHighlights || + previousBook.pdfReaderViewport != nextPdfViewport + if (!isReaderDirty && updatedTextReaderDefaults == null) { + logDesktopPositionTrace { + "event=persist_skip_unchanged bookId=\"${bookId.logPreview(80)}\" page=$pageIndex progress=$progress" + } + return + } + val stateWithReaderDefaults = if (updatedTextReaderDefaults != null) { + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.TEXT, + updatedTextReaderDefaults + ) + } else { + state + } + val next = stateWithReaderDefaults.copy( + readerDefaultSettings = textReaderSettings ?: state.readerDefaultSettings, + rawLibraryBooks = stateWithReaderDefaults.rawLibraryBooks.map { book -> + if (book.id == bookId) { + shouldSyncSidecars = isReaderDirty + if (isReaderDirty && book.id !in readerCloudDirtyBookIds) { + dirtyBaseTimestamp = book.timestamp + } + if (isReaderDirty) { + val now = System.currentTimeMillis() + book.copy( + progressPercentage = progress, + timestamp = now, + isRecent = true, + lastPageIndex = pageIndex, + readerPosition = readerPosition, + readerSettings = nextReaderSettings, + readerBookmarks = nextBookmarks, + readerHighlights = nextHighlights, + pdfReaderViewport = nextPdfViewport, + readingPositionModifiedTimestamp = now + ).also { updatedBook = it } + } else { + book + } + } else { + book + } + } + ) + updateState( + next, + persistDebounceMillis = if (isReaderDirty) DesktopReaderPositionPersistDebounceMillis else 0L + ) + logDesktopPositionTrace { + val saved = updatedBook + "event=persist_done bookId=\"${bookId.logPreview(80)}\" updated=${saved != null} " + + "requestedPage=$pageIndex requestedProgress=$progress " + + "savedPage=${saved?.lastPageIndex ?: "null"} savedProgress=${saved?.progressPercentage ?: "null"} " + + "savedLocator=${saved?.readerPosition.desktopPositionTraceSummary()} " + + "shouldSyncSidecars=$shouldSyncSidecars dirtyBaseTimestamp=${dirtyBaseTimestamp ?: "null"}" + } + if (updatedTextReaderDefaults != null) { + readerWindows = readerWindows.map { windowState -> + val content = windowState.content + if (content is DesktopReaderWindowContent.Text && + content.session.reader.settings != updatedTextReaderDefaults + ) { + windowState.copy( + content = content.copy( + session = readerEngine.updateSettings(content.session, updatedTextReaderDefaults) + ) + ) + } else { + windowState + } + } + } + if (shouldSyncSidecars) { + updatedBook?.let { book -> + syncBookSidecars(book, debounceMillis = DesktopReaderPositionPersistDebounceMillis) + } + markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) + } + } + + fun updateBookReaderSettings(bookId: String, settings: ReaderSettings) { + val pdfSettings = settings.toDesktopPdfReaderSettings() + var updatedBook: BookItem? = null + var dirtyBaseTimestamp: Long? = null + val stateWithPdfDefaults = state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.PDF, + pdfSettings + ) + val next = stateWithPdfDefaults.copy( + rawLibraryBooks = stateWithPdfDefaults.rawLibraryBooks.map { book -> + if (book.id == bookId) { + if (book.id !in readerCloudDirtyBookIds) { + dirtyBaseTimestamp = book.timestamp + } + book.copy( + timestamp = System.currentTimeMillis(), + isRecent = true, + readerSettings = pdfSettings + ).also { updatedBook = it } + } else { + book + } + } + ) + updateState(next) + updatedBook?.let(::syncBookSidecars) + markReaderCloudDirty(bookId, baseTimestamp = dirtyBaseTimestamp) + } + + fun importDesktopReaderTexture(settings: ReaderSettings): ReaderSettings? { + val source = chooseReaderTextureFile() ?: return null + val textureId = DesktopReaderTextures.importTexture(source) ?: return null + readerCustomTextureIds = DesktopReaderTextures.importedTextureIds() + return settings.copy(textureId = textureId) + } + + fun stopReaderCloudTts(windowId: String? = null) { + logDesktopTts("reader_stop_requested") + val targetWindowIds = readerWindows + .filter { window -> windowId == null || window.id == windowId } + .map { it.id } + .toSet() + readerWindows + .filter { window -> window.id in targetWindowIds } + .mapNotNull { window -> window.content as? DesktopReaderWindowContent.Text } + .forEach { it.ttsJob?.cancel() } + scope.launch { + desktopTtsAdapter.stop() + readerWindows = readerWindows.map { window -> + val content = window.content + if (window.id in targetWindowIds && content is DesktopReaderWindowContent.Text) { + window.copy( + content = content.copy( + ttsJob = null, + extrasState = content.extrasState.copy( + cloudTts = readerCloudTtsStoppedState( + content, + statusMessage = desktopString("desktop_stopped", "Stopped") + ) + ) + ) + ) + } else { + window + } + } + } + } + + fun signOutDesktopAccount() { + desktopAuthRepository.signOut() + desktopAccountProfileRepository.clearCachedProfiles() + desktopAccountProfileRefreshCompleted = true + stopReaderCloudTts() + saveDesktopCloudSyncSettings(syncEnabled = false) + updateState(state.copy(currentUser = null, isProUser = false, credits = 0, isSyncEnabled = false)) + accountStatusMessage = "Signed out." + } + + fun pauseResumeReaderCloudTts(windowId: String) { + val content = textReaderWindowContent(windowId) ?: return + val current = content.extrasState.cloudTts + if (current.isPaused) { + scope.launch { + desktopTtsAdapter.resume() + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = latest.extrasState.cloudTts.copy( + isPaused = false, + isPlaying = true, + statusMessage = latest.extrasState.cloudTts.progress.currentPositionLabel + ?: desktopString("label_reading", "Reading") + ) + ) + ) + } + } + } else if (current.isPlaying) { + scope.launch { + desktopTtsAdapter.pause() + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = latest.extrasState.cloudTts.copy( + isPlaying = false, + isPaused = true, + statusMessage = desktopString("desktop_paused", "Paused") + ) + ) + ) + } + } + } + } + + fun clearReaderCloudTtsCache(windowId: String) { + val content = textReaderWindowContent(windowId) ?: return + desktopTtsAdapter.clearBookCacheForSpeaker(content.session.reader.book.title, aiByokSettings.sanitized().ttsSpeakerId) + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = latest.extrasState.cloudTts.copy( + statusMessage = desktopString("desktop_voice_cache_cleared", "Voice cache cleared"), + cacheSummary = textReaderTtsCacheSummary(latest) + ) + ) + ) + } + } + + fun startReaderCloudTts( + windowId: String, + readScope: ReaderTtsReadScope, + chunks: List, + startChunkIndex: Int = 0, + restartActive: Boolean = false, + applyReplacements: Boolean = true + ) { + val content = textReaderWindowContent(windowId) ?: return + val replacementBookId = content.book.id.ifBlank { content.session.reader.book.title } + val sourceChunks = chunks.filter { it.text.isNotBlank() } + logDesktopTtsStartTrace { + "event=desktop_start_request windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "incomingChunks=${chunks.size} sourceChunks=${sourceChunks.size} startChunkIndex=$startChunkIndex " + + "restartActive=$restartActive applyReplacements=$applyReplacements " + + "currentPage=${content.session.reader.currentPageIndex} sessionLocator=${content.session.navigationLocator.desktopPositionTraceSummary(160)} " + + "incomingFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "sourceFirst=${sourceChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" + } + val ttsChunks = if (applyReplacements) { + sourceChunks.withTtsReplacements(state.readerTtsReplacementPreferences, replacementBookId) + } else { + sourceChunks + } + logDesktopTtsStartTrace { + "event=desktop_start_prepared windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "ttsChunks=${ttsChunks.size} boundedStart=${startChunkIndex.coerceIn(0, ttsChunks.lastIndex.coerceAtLeast(0))} " + + "first=${ttsChunks.firstOrNull().desktopTtsStartTraceSummary(160)} " + + "second=${ttsChunks.getOrNull(1).desktopTtsStartTraceSummary(160)}" + } + val settings = aiByokSettings.sanitized() + val currentCloudTts = content.extrasState.cloudTts + logDesktopTts( + "reader_sequence_toggle scope=${readScope.name} chunks=${ttsChunks.size} " + + "isPlaying=${currentCloudTts.isPlaying} isLoading=${currentCloudTts.isLoading} " + + "keyPresent=${settings.geminiKey.isNotBlank()} ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" " + + "available=${desktopTtsAdapter.isAvailable}" + ) + val ttsActive = currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused + if (ttsActive && !restartActive) { + stopReaderCloudTts(windowId) + return + } + if (ttsActive) { + content.ttsJob?.cancel() + } + if (ttsChunks.isEmpty()) { + logDesktopTts("reader_sequence_ignored reason=blank_text scope=${readScope.name}") + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = latest.extrasState.cloudTts.copy( + errorMessage = desktopString("desktop_no_text_here_to_read", "There is no text here to read."), + cacheSummary = textReaderTtsCacheSummary(latest) + ) + ) + ) + } + return + } + if (!desktopTtsAdapter.isAvailable) { + logDesktopTts("reader_sequence_blocked reason=adapter_unavailable") + desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = cloudTtsUnavailableMessage(), + cacheSummary = textReaderTtsCacheSummary(latest) + ) + ) + ) + } + return + } + readerWindows = readerWindows.map { window -> + val textContent = window.content as? DesktopReaderWindowContent.Text + if (window.id != windowId && textContent != null) { + textContent.ttsJob?.cancel() + window.copy( + content = textContent.copy( + ttsJob = null, + extrasState = textContent.extrasState.copy( + cloudTts = readerCloudTtsStoppedState( + textContent, + statusMessage = desktopString("desktop_stopped", "Stopped") + ) + ) + ) + ) + } else { + window + } + } + val ttsSessionId = System.currentTimeMillis() + val boundedStartChunkIndex = startChunkIndex.coerceIn(0, ttsChunks.lastIndex) + val playbackChunks = ttsChunks.drop(boundedStartChunkIndex) + logDesktopTtsStartTrace { + "event=desktop_playback_window windowId=\"${windowId.logPreview(80)}\" scope=${readScope.name} " + + "boundedStart=$boundedStartChunkIndex playbackChunks=${playbackChunks.size} " + + "playbackFirst=${playbackChunks.firstOrNull().desktopTtsStartTraceSummary(160)}" + } + val initialProgress = ReaderTtsProgress( + sessionId = ttsSessionId, + scope = readScope, + chunks = ttsChunks, + currentChunkIndex = boundedStartChunkIndex - 1 + ) + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isLoading = true, + statusMessage = desktopString( + "desktop_preparing_scope_format", + "Preparing %1\$s", + desktopReadScopeLabel(readScope) + ), + progress = initialProgress, + cacheSummary = textReaderTtsCacheSummary(latest) + ) + ) + ) + } + fun updateTextReaderTtsSession(transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text) { + updateTextReaderWindow(windowId) { latest -> + if (latest.extrasState.cloudTts.progress.sessionId == ttsSessionId) { + transform(latest) + } else { + latest + } + } + } + val ttsJob = scope.launch { + runCatching { + logDesktopTts( + "reader_sequence_start scope=${readScope.name} chunks=${ttsChunks.size} " + + "startChunk=${boundedStartChunkIndex + 1}" + ) + desktopTtsAdapter.speakChunks(content.session.reader.book.title, readScope, playbackChunks) { relativeIndex -> + if (!isActive) throw kotlinx.coroutines.CancellationException("Reader cloud TTS stopped") + val index = boundedStartChunkIndex + relativeIndex + val chunk = ttsChunks[index] + val progress = initialProgress.copy(currentChunkIndex = index) + val latest = textReaderWindowContent(windowId) + ?: throw kotlinx.coroutines.CancellationException("Reader window closed") + if (latest.session.reader.currentPageIndex != chunk.pageIndex) { + val updatedSession = readerEngine.goToPage(latest.session, chunk.pageIndex) + updateTextReaderWindow(windowId) { current -> current.copy(session = updatedSession) } + updateBookReadingState( + bookId = latest.book.id, + pageIndex = updatedSession.reader.currentPageIndex, + progress = updatedSession.reader.progress, + session = updatedSession + ) + } + updateTextReaderTtsSession { current -> + current.copy( + extrasState = current.extrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = true, + isPlaying = true, + statusMessage = progress.currentPositionLabel + ?: desktopString("label_reading", "Reading"), + progress = progress, + cacheSummary = textReaderTtsCacheSummary(current) + ) + ) + ) + } + logDesktopTts( + "reader_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + + "page=${chunk.pageIndex + 1} chapter=${chunk.chapterIndex} offsets=${chunk.startOffset}..${chunk.endOffset} " + + "sourceCfi=\"${chunk.sourceCfi.orEmpty().logPreview()}\" chars=${chunk.text.length} " + + "text=\"${chunk.text.logPreview()}\"" + ) + logDesktopTtsStartTrace { + "event=desktop_chunk_start scope=${readScope.name} index=${index + 1}/${ttsChunks.size} " + + "chunk=${chunk.desktopTtsStartTraceSummary(180)}" + } + } + }.onFailure { error -> + logDesktopTts("reader_sequence_failed error=\"${error.desktopTtsSummary()}\"") + updateTextReaderTtsSession { latest -> + if (error is kotlinx.coroutines.CancellationException) { + latest.copy( + ttsJob = null, + extrasState = latest.extrasState.copy( + cloudTts = readerCloudTtsStoppedState( + latest, + statusMessage = desktopString("desktop_stopped", "Stopped") + ) + ) + ) + } else { + desktopFeatureNoticeForError(error.message)?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } + latest.copy( + ttsJob = null, + extrasState = latest.extrasState.copy( + cloudTts = readerCloudTtsStoppedState( + latest, + errorMessage = error.message + ?: desktopString("desktop_cloud_tts_failed", "Cloud TTS failed.") + ) + ) + ) + } + } + }.onSuccess { + logDesktopTts("reader_sequence_success chunks=${ttsChunks.size}") + updateTextReaderTtsSession { latest -> + latest.copy( + ttsJob = null, + extrasState = latest.extrasState.copy( + cloudTts = readerCloudTtsStoppedState( + latest, + statusMessage = desktopString("desktop_finished", "Finished") + ) + ) + ) + } + } + } + updateTextReaderWindow(windowId) { latest -> latest.copy(ttsJob = ttsJob) } + } + + fun skipReaderCloudTtsChunk(windowId: String, delta: Int) { + val content = textReaderWindowContent(windowId) ?: return + val progress = content.extrasState.cloudTts.progress + if (progress.chunks.isEmpty()) return + val currentIndex = progress.currentChunkIndex.takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + delta).coerceIn(0, progress.chunks.lastIndex) + if (targetIndex == currentIndex) return + startReaderCloudTts( + windowId = windowId, + readScope = progress.scope, + chunks = progress.chunks, + startChunkIndex = targetIndex, + restartActive = true, + applyReplacements = false + ) + } + + fun locateReaderCloudTtsChunk(windowId: String) { + val content = textReaderWindowContent(windowId) ?: return + val chunk = content.extrasState.cloudTts.progress.currentChunk ?: return + val updatedSession = readerEngine.goToPage(content.session, chunk.pageIndex) + updateTextReaderWindow(windowId) { current -> current.copy(session = updatedSession) } + updateBookReadingState( + bookId = content.book.id, + pageIndex = updatedSession.reader.currentPageIndex, + progress = updatedSession.reader.progress, + session = updatedSession + ) + } + + fun toggleReaderCloudTts(windowId: String, text: String, locator: ReaderLocator? = null) { + val content = textReaderWindowContent(windowId) ?: return + val normalizedText = text.trim() + val settings = aiByokSettings.sanitized() + val currentCloudTts = content.extrasState.cloudTts + logDesktopTts( + "reader_toggle textChars=${normalizedText.length} isPlaying=${currentCloudTts.isPlaying} " + + "isLoading=${currentCloudTts.isLoading} keyPresent=${settings.geminiKey.isNotBlank()} " + + "ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" available=${desktopTtsAdapter.isAvailable}" + ) + if (currentCloudTts.isPlaying || currentCloudTts.isLoading || currentCloudTts.isPaused) { + stopReaderCloudTts(windowId) + return + } + if (normalizedText.isBlank()) { + logDesktopTts("reader_toggle_ignored reason=blank_text") + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = latest.extrasState.cloudTts.copy( + errorMessage = desktopString( + "desktop_no_text_on_page_to_read", + "There is no text on this page to read." + ), + cacheSummary = textReaderTtsCacheSummary(latest) + ) + ) + ) + } + return + } + if (!desktopTtsAdapter.isAvailable) { + logDesktopTts("reader_toggle_blocked reason=adapter_unavailable") + desktopFeatureNoticeForCloudTts()?.let { showDesktopFeatureNotice(it, readerWindowId = windowId) } + updateTextReaderWindow(windowId) { latest -> + latest.copy( + extrasState = latest.extrasState.copy( + cloudTts = ReaderCloudTtsState( + isAvailable = false, + errorMessage = cloudTtsUnavailableMessage(), + cacheSummary = textReaderTtsCacheSummary(latest) + ) + ) + ) + } + return + } + val locatorChunks = locator + ?.takeIf { it.startOffset != null || !it.cfi.isNullOrBlank() } + ?.let { selectionLocator -> + ReaderTtsPlanner.chunksFromCurrentLocation( + content.session.copy(navigationLocator = selectionLocator) + ).takeIf { it.isNotEmpty() } + } + val page = locator + ?.pageIndex + ?.let { content.session.reader.pages.getOrNull(it) } + ?: content.session.reader.currentPage + val selectionChunks = locatorChunks ?: if (page != null) { + ReaderTtsPlanner.chunksForText( + text = normalizedText, + pageIndex = locator?.pageIndex ?: page.pageIndex, + chapterIndex = locator?.chapterIndex ?: page.chapterIndex, + chapterTitle = page.chapterTitle, + sourceStartOffset = locator?.startOffset ?: page.startOffset + ) + } else { + ReaderTtsPlanner.chunksForText( + text = normalizedText, + pageIndex = content.session.reader.currentPageIndex, + chapterIndex = 0, + chapterTitle = desktopString("desktop_selection", "Selection") + ) + } + startReaderCloudTts( + windowId = windowId, + readScope = if (locatorChunks != null) ReaderTtsReadScope.BOOK else ReaderTtsReadScope.PAGE, + chunks = selectionChunks + ) + } + + fun finishImportFiles( + files: List, + failedCount: Int, + onImported: (List) -> Unit = {} + ) { + val importStart = System.currentTimeMillis() + val existingIds = state.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } + val importPlan = SharedImportPlanner.plan( + files = files, + existingBookIds = existingIds, + platform = ReaderPlatform.DESKTOP, + nowMillis = importStart + ) + val counts = SharedImportOutcomeCounts( + addedCount = importPlan.importedCount, + duplicateCount = importPlan.duplicateCount, + unsupportedCount = importPlan.unsupportedCount, + failedCount = failedCount + ) + if (files.isEmpty() && failedCount > 0) { + updateState( + state.withBanner( + desktopQuantityString( + "desktop_import_failed_file_count", + failedCount, + "Could not import %1\$d file.", + "Could not import %1\$d files.", + failedCount + ), + isError = true + ) + ) + return + } + if (importPlan.supportedFiles.isEmpty() && files.isNotEmpty()) { + updateState( + state.withBanner( + "No supported desktop reader files were selected. " + + "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.", + isError = true + ) + ) + return + } + val next = state.copy(rawLibraryBooks = importPlan.importedBooks + state.rawLibraryBooks) + .let { + when { + counts.addedCount > 0 && (counts.unsupportedCount > 0 || counts.failedCount > 0) -> { + val skippedCount = counts.unsupportedCount + counts.failedCount + val importedMessage = desktopQuantityString( + "desktop_imported_file_count", + counts.addedCount, + "Imported %1\$d file.", + "Imported %1\$d files.", + counts.addedCount + ) + val skippedMessage = desktopQuantityString( + "desktop_skipped_file_count", + skippedCount, + "Skipped %1\$d file.", + "Skipped %1\$d files.", + skippedCount + ) + it.withBanner( + desktopString( + "desktop_import_result_pair", + "%1\$s %2\$s", + importedMessage, + skippedMessage + ) + ) + } + counts.addedCount > 0 -> it.withBanner( + desktopQuantityString( + "desktop_imported_file_count", + counts.addedCount, + "Imported %1\$d file.", + "Imported %1\$d files.", + counts.addedCount + ) + ) + counts.duplicateCount > 0 -> it.withBanner("Those files are already in the library.") + counts.failedCount > 0 -> it.withBanner( + desktopQuantityString( + "desktop_import_failed_file_count", + counts.failedCount, + "Could not import %1\$d file.", + "Could not import %1\$d files.", + counts.failedCount + ), + isError = true + ) + else -> it + } + } + updateState(next) + onImported(importPlan.importedBooks) + importPlan.importedBooks.forEach { imported -> + queueCloudBookMetadataSync(imported, uploadContent = true) + } + val targetBookIds = importPlan.importedBooks.mapTo(mutableSetOf()) { it.id } + if (targetBookIds.isEmpty()) return + val originalTargetBooksById = next.rawLibraryBooks + .filter { it.id in targetBookIds } + .associateBy { it.id } + + scope.launch { + val metadataResult = withContext(Dispatchers.IO) { + DesktopFolderMetadataExtractor.enrichImportedBooks( + books = next.rawLibraryBooks, + importedBookIds = targetBookIds + ) + } + if (metadataResult.stats.updatedBooks > 0) { + val enrichedBooksById = metadataResult.books + .filter { it.id in targetBookIds } + .associateBy { it.id } + updateState( + state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { book -> + val enriched = enrichedBooksById[book.id] ?: return@map book + book.withDesktopImportMetadata( + enriched = enriched, + original = originalTargetBooksById[book.id] + ).copy(timestamp = System.currentTimeMillis()) + } + ) + ) + enrichedBooksById.values.forEach { enriched -> + queueCloudBookMetadataSync(enriched, uploadContent = false) + } + } + } + } + + fun importFiles(files: List, onImported: (List) -> Unit = {}) { + if (files.isEmpty()) return + updateState( + state.withBanner( + desktopQuantityString( + "desktop_importing_file_count", + files.size, + "Importing %1\$d file...", + "Importing %1\$d files...", + files.size + ) + ) + ) + scope.launch { + val preparedImport = withContext(Dispatchers.IO) { + desktopBookImporter.prepareImports(files) + } + finishImportFiles( + files = preparedImport.files, + failedCount = preparedImport.failedCount, + onImported = onImported + ) + } + } + + fun syncLocalFolders( + targetFolder: File? = null, + showBanner: Boolean = true, + metadataOnly: Boolean = false + ) { + val mode = if (metadataOnly) "metadata" else "full" + logDesktopFolderSync( + "ui.sync.request mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " + + "showBanner=$showBanner linkedFolders=${state.syncedFolders.size} books=${state.rawLibraryBooks.size}" + ) + if (targetFolder == null && state.syncedFolders.isEmpty()) { + logDesktopFolderSync("ui.sync.skipNoFolders mode=$mode") + updateState(state.withBanner("No local folders are linked yet.", isError = true)) + return + } + if (targetFolder == null && state.syncedFolders.none { it.localSyncEnabled }) { + logDesktopFolderSync("ui.sync.skipNoEnabledFolders mode=$mode") + updateState(state.withBanner("No local folders have sync enabled.", isError = true)) + return + } + + val snapshotState = state + val snapshotShelfRefs = shelfRefs + if (showBanner) { + val message = if (metadataOnly) { + "Folder sync: updating metadata..." + } else { + "Folder sync: scanning local folders..." + } + updateState(state.withBanner(message)) + } + + scope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + DesktopLocalFolderSync.sync( + state = snapshotState, + shelfRefs = snapshotShelfRefs, + targetFolder = targetFolder, + metadataOnly = metadataOnly, + extractMetadata = false + ) + } + }.onFailure { error -> + logDesktopFolderSync("ui.sync.failed mode=$mode error=${error.folderSyncSummary()}") + if (showBanner) { + updateState(state.withBanner(error.message ?: "Folder sync failed.", isError = true)) + } + }.getOrNull() ?: return@launch + val failedCount = result.failedFolders.size + val stats = result.stats + val metadataStats = result.metadataStats + val message = when { + failedCount > 0 && stats.supportedFiles == 0 -> + desktopQuantityString( + "desktop_folder_sync_failed_folder_count", + failedCount, + "Folder sync failed for %1\$d folder.", + "Folder sync failed for %1\$d folders.", + failedCount + ) + failedCount > 0 -> + desktopQuantityString( + "desktop_folder_sync_skipped_folder_count", + failedCount, + "Folder sync finished with %1\$d folder skipped.", + "Folder sync finished with %1\$d folders skipped.", + failedCount + ) + metadataOnly -> + "Folder metadata sync complete." + else -> + "Folder sync complete: ${stats.newBooks} new, ${stats.updatedBooks + stats.remoteMetadataUpdates + metadataStats.updatedBooks} updated, ${stats.removedBooks} removed." + } + logDesktopFolderSync( + "ui.sync.result mode=$mode failed=$failedCount message=\"${message.folderSyncPreview()}\" " + + "new=${stats.newBooks} updated=${stats.updatedBooks} remoteUpdates=${stats.remoteMetadataUpdates} " + + "removed=${stats.removedBooks} metadataExtracted=${metadataStats.updatedBooks}" + ) + val completedState = desktopFolderSyncCompletedState( + state = result.state, + message = message, + failedFolderCount = failedCount, + showBanner = showBanner + ) + replaceLibrary( + completedState, + refs = result.shelfRefs + ) + if (!metadataOnly) { + scheduleFolderMetadataExtraction(result.processedFolderUris.toSet()) + } + val existingBookIds = completedState.rawLibraryBooks.mapTo(mutableSetOf()) { it.id } + readerWindows = readerWindows.mapNotNull { window -> + val migratedBookId = result.idMigrations[window.bookId] ?: window.bookId + if (migratedBookId !in existingBookIds) { + window.cancelReaderWork() + null + } else if (migratedBookId != window.bookId) { + val migratedContent = when (val content = window.content) { + DesktopReaderWindowContent.Opening -> content + is DesktopReaderWindowContent.PasswordRequired -> content.copy( + book = content.book.copy(id = migratedBookId) + ) + is DesktopReaderWindowContent.Pdf -> content.copy( + book = content.book.copy(id = migratedBookId) + ) + is DesktopReaderWindowContent.Text -> content.copy( + book = content.book.copy(id = migratedBookId) + ) + } + window.copy( + id = migratedBookId, + opening = window.opening.copy(bookId = migratedBookId), + content = migratedContent + ) + } else { + window + } + } + queueFullCloudSyncAfterLocalChange() + } + } + + fun syncFolderMetadata(showBanner: Boolean = true) { + syncLocalFolders(showBanner = showBanner, metadataOnly = true) + } + + fun scanSyncedFolders(showBanner: Boolean = true) { + syncLocalFolders(showBanner = showBanner, metadataOnly = false) + } + + fun syncDesktopLibrary(showBanner: Boolean = true) { + val hasCloud = state.isSyncEnabled + val hasFolders = state.syncedFolders.any { it.localSyncEnabled } + if (!hasCloud && !hasFolders) { + updateState(state.withBanner("No sync methods are active.", isError = true)) + return + } + if (hasFolders) { + scanSyncedFolders(showBanner = showBanner) + } else if (hasCloud) { + syncDesktopCloud(showBanner = showBanner) + } + } + + fun importFolder(folder: File) { + logDesktopFolderSync("ui.importFolder.request folder=\"${folder.absolutePath.folderSyncPreview()}\"") + if (!DesktopLocalFolderSync.hasSupportedFiles(folder)) { + logDesktopFolderSync("ui.importFolder.skipNoSupportedFiles folder=\"${folder.absolutePath.folderSyncPreview()}\"") + updateState(state.withBanner("That folder does not contain any supported desktop reader files.", isError = true)) + return + } + syncLocalFolders(targetFolder = folder) + } + + fun importCustomFont(file: File?): CustomFontItem? { + val source = file ?: return null + return customFontStore.importFont(source) + .onSuccess { font -> + customFonts = (customFonts.filterNot { it.id == font.id } + font) + .filterNot { it.isDeleted } + .sortedBy { it.displayName.lowercase() } + updateState(state.withBanner("Imported ${font.displayName}.")) + queueFullCloudSyncAfterLocalChange() + } + .onFailure { error -> + updateState(state.withBanner(error.message ?: "Could not import font.", isError = true)) + } + .getOrNull() + } + + fun downloadGoogleFont(fontName: String, onComplete: () -> Unit) { + if (!featurePolicy.googleFontsDownload) { + updateState(state.withBanner("Google Fonts download is unavailable in this desktop build.", isError = true)) + onComplete() + return + } + scope.launch { + val result = withContext(Dispatchers.IO) { + customFontStore.downloadGoogleFont(fontName) + } + result + .onSuccess { font -> + customFonts = (customFonts.filterNot { it.id == font.id } + font) + .filterNot { it.isDeleted } + .sortedBy { it.displayName.lowercase() } + updateState(state.withBanner("${font.displayName} downloaded successfully.")) + queueFullCloudSyncAfterLocalChange() + } + .onFailure { error -> + updateState(state.withBanner(error.message ?: "Could not download $fontName.", isError = true)) + } + onComplete() + } + } + + fun deleteCustomFont(font: CustomFontItem) { + customFontStore.deleteFont(font) + customFonts = customFonts.filterNot { it.id == font.id } + val resetAppFont = state.appFontPreference.referencesCustomFont(font.id) + val clearedSettings = state.rawLibraryBooks.map { book -> + val settings = book.readerSettings + if (settings?.customFontPath == font.path) { + book.copy(readerSettings = settings.copy(fontFamily = "Default", customFontPath = null)) + } else { + book + } + } + readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> + if (content.session.reader.settings.customFontPath == font.path) { + content.copy( + session = readerEngine.updateSettings( + content.session, + content.session.reader.settings.copy(fontFamily = "Default", customFontPath = null) + ) + ) + } else { + content + } + } + val nextState = state.copy( + rawLibraryBooks = clearedSettings, + appFontPreference = if (resetAppFont) AppFontPreference.System else state.appFontPreference + ) + updateState(nextState.withBanner("Deleted ${font.displayName}.")) + deleteCustomFontFromDesktopCloud(font) + } + + fun removeSelectedBooks() { + val booksToRemove = state.rawLibraryBooks.filter { it.id in state.selectedBookIds } + closeReaderWindowsForBookIds(booksToRemove.mapTo(mutableSetOf()) { it.id }) + SharedLibraryEditor.removeSelectedBooks(state, shelfRecords, shelfRefs)?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + deleteBooksFromDesktopCloud(booksToRemove) + } + } + + fun createShelf(name: String) { + SharedLibraryEditor.createShelf(state, shelfRecords, shelfRefs, name, System.currentTimeMillis())?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.lastOrNull { record -> record.name == name.trim() } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + + fun createShelfWithBooks(name: String, bookIds: Set, clearSelection: Boolean = true) { + SharedLibraryEditor.createShelfWithBooks( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + name = name, + bookIds = bookIds, + clearSelection = clearSelection, + nowMillis = System.currentTimeMillis() + )?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.lastOrNull { record -> record.name == name.trim() } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + + fun createSmartShelf(name: String, definition: SmartCollectionDefinition) { + SharedLibraryEditor.createSmartShelf(state, shelfRecords, shelfRefs, name, definition, System.currentTimeMillis())?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } + } + + fun renameShelf(shelf: Shelf, name: String) { + val previousRecord = shelfRecords.firstOrNull { it.id == shelf.id } + SharedLibraryEditor.renameShelf(state, shelfRecords, shelfRefs, shelf, name)?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.firstOrNull { record -> record.id == shelf.id } + ?.let { record -> + if (previousRecord != null && previousRecord.name != record.name) { + syncCloudShelfChange(previousRecord, shelfRefs, isDeleted = true) + } + syncCloudShelfChange(record, result.shelfRefs) + } + } + } + + fun deleteShelf(shelf: Shelf) { + val record = shelfRecords.firstOrNull { it.id == shelf.id } + val result = SharedLibraryEditor.deleteShelf(state, shelfRecords, shelfRefs, shelf) + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + record?.let { syncCloudShelfChange(it, shelfRefs, isDeleted = true) } + } + + fun addSelectedBooksToShelf(shelfId: String) { + SharedLibraryEditor.addSelectedBooksToShelf(state, shelfRecords, shelfRefs, shelfId, System.currentTimeMillis())?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.firstOrNull { record -> record.id == shelfId } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + + fun addBooksToShelves(bookIds: Set, shelfIds: Set, clearSelection: Boolean) { + val targetShelfIds = shelfIds.filterTo(linkedSetOf()) { SharedLibraryEditor.canMutateShelf(it) } + SharedLibraryEditor.addBooksToShelves( + state = state, + shelfRecords = shelfRecords, + shelfRefs = shelfRefs, + bookIds = bookIds, + shelfIds = targetShelfIds, + clearSelection = clearSelection, + nowMillis = System.currentTimeMillis() + )?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + targetShelfIds.forEach { shelfId -> + result.shelfRecords.firstOrNull { record -> record.id == shelfId } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + } + + fun replaceShelfBooks(shelf: Shelf, bookIds: Set) { + SharedLibraryEditor.replaceShelfBooks(state, shelfRecords, shelfRefs, shelf.id, bookIds, System.currentTimeMillis())?.let { result -> + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.shelfRecords.firstOrNull { record -> record.id == shelf.id } + ?.let { record -> syncCloudShelfChange(record, result.shelfRefs) } + } + } + + fun tagSelectedBooks(tagName: String) { + SharedLibraryEditor.tagSelectedBooks(state, shelfRecords, shelfRefs, tagName, System.currentTimeMillis())?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + } + } + + fun applyBookMetadataUpdate(updated: BookItem) { + val result = SharedLibraryEditor.updateBookMetadata(state, shelfRecords, shelfRefs, updated, System.currentTimeMillis()) + replaceLibrary(result.state, records = result.shelfRecords, refs = result.shelfRefs) + result.state.rawLibraryBooks.firstOrNull { it.id == updated.id }?.let { book -> + syncBookSidecars(book) + queueCloudBookMetadataSync(book, uploadContent = book.fileContentModifiedTimestamp > 0L) + } + } + + fun writeDesktopEpubMetadata(original: BookItem, updated: BookItem): BookItem { + val file = File(original.path ?: error("Book path is missing.")) + require(file.isFile && file.canWrite()) { "EPUB file is not writable." } + val backup = File( + File(desktopUserDataRoot(), "metadata_backups").apply { mkdirs() }, + "${original.id.toDesktopSafeFileName()}.epub" + ) + val snapshot = SharedEpubMetadataEditor.rewriteInPlace( + source = file, + backup = backup, + update = SharedEpubMetadataUpdate( + title = updated.title, + author = updated.author, + description = updated.description, + seriesName = updated.seriesName, + seriesIndex = updated.seriesIndex + ) + ) + return updated.copy( + title = snapshot.title ?: updated.title, + author = snapshot.author, + description = snapshot.description, + seriesName = snapshot.seriesName, + seriesIndex = snapshot.seriesIndex, + originalTitle = original.originalTitle ?: original.title, + originalAuthor = original.originalAuthor ?: original.author, + originalSeriesName = original.originalSeriesName ?: original.seriesName, + originalSeriesIndex = original.originalSeriesIndex ?: original.seriesIndex, + originalDescription = original.originalDescription ?: original.description, + fileSize = file.length(), + fileContentModifiedTimestamp = file.lastModified() + ) + } + + fun updateBookMetadata(updated: BookItem) { + val original = state.rawLibraryBooks.firstOrNull { it.id == updated.id } + if (original != null && original.type == FileType.EPUB && original.hasEmbeddedMetadataChange(updated)) { + scope.launch { + val rewritten = runCatching { + withContext(Dispatchers.IO) { + writeDesktopEpubMetadata(original, updated) + } + } + rewritten.onSuccess(::applyBookMetadataUpdate) + .onFailure { error -> + logDesktopDiagnostic("EpistemeDesktopMetadata") { + "epub_metadata_update_failed book=${updated.id} error=\"${error.message.orEmpty().logPreview()}\"" + } + updateState(state.copy(bannerMessage = BannerMessage("Could not update EPUB metadata."))) + } + } + return + } + + applyBookMetadataUpdate(updated) + } + + fun recordBookOpened(bookId: String) { + val now = System.currentTimeMillis() + val next = SharedLibraryEditor.markBookOpened(state, bookId, now) + val openedState = next.reduce(AppAction.BookTabOpened(bookId)) + updateState(openedState, persistDebounceMillis = DesktopLibraryOpenPersistDebounceMillis) + openedState.rawLibraryBooks.firstOrNull { it.id == bookId }?.let { book -> + syncBookSidecars(book, debounceMillis = DesktopLibraryOpenPersistDebounceMillis) + } + } + + fun scheduleOpenedBookMetadataExtraction(book: BookItem) { + scope.launch { + val enriched = withContext(Dispatchers.IO) { + DesktopFolderMetadataExtractor.enrichOpenedBook(book) + } + if (enriched == book) return@launch + updateState( + state.copy( + rawLibraryBooks = state.rawLibraryBooks.map { current -> + if (current.id == book.id) { + current.withDesktopImportMetadata(enriched = enriched, original = book) + .copy(timestamp = System.currentTimeMillis()) + } else { + current + } + } + ) + ) + state.rawLibraryBooks.firstOrNull { it.id == book.id }?.let { + markReaderCloudDirty(it.id, baseTimestamp = book.timestamp) + } + } + } + + fun schedulePdfEmbeddedAnnotationsLoad(windowId: String, document: DesktopPdfDocument) { + scope.launch { + delay(650L) + val stillOpen = readerWindows.any { window -> + window.id == windowId && + (window.content as? DesktopReaderWindowContent.Pdf)?.document?.handleId == document.handleId + } + if (!stillOpen) return@launch + val annotations = withContext(Dispatchers.IO) { + DesktopPdfium.loadEmbeddedAnnotations(document) + } + val stillCurrent = readerWindows.any { window -> + window.id == windowId && + (window.content as? DesktopReaderWindowContent.Pdf)?.document?.handleId == document.handleId + } + if (stillCurrent) { + document.replaceEmbeddedAnnotations(annotations) + } + } + } + + fun exitReaderTo(tab: SharedAppTab) { + if (tab == SharedAppTab.SHELVES) { + selectedLibraryTab = NonReaderLibraryTab.SHELVES + selectedTab = SharedAppTab.LIBRARY + } else { + selectedTab = tab.takeUnless { it == SharedAppTab.READER } ?: SharedAppTab.LIBRARY + } + } + + fun selectAppTab(tab: SharedAppTab) { + val nextTab = when { + tab == SharedAppTab.CATALOGS && !featurePolicy.opdsCatalogs -> SharedAppTab.LIBRARY + tab == SharedAppTab.PRO && !desktopAccountAvailable() -> SharedAppTab.LIBRARY + tab == SharedAppTab.SHELVES -> { + selectedLibraryTab = NonReaderLibraryTab.SHELVES + SharedAppTab.LIBRARY + } + else -> tab + } + if (nextTab == SharedAppTab.SETTINGS) { + settingsQuery = "" + settingsDestination = SharedSettingsDestination.ROOT + } + if (nextTab == SharedAppTab.READER) { + state.activeTabBookId?.let { bookId -> + readerWindows = readerWindows.focusDesktopReaderWindow(bookId) + } + selectedTab = SharedAppTab.LIBRARY + } else { + selectedTab = nextTab + } + } + + fun focusDesktopAppWindow() { + val ownerWindow = (window as? java.awt.Window) + ?: window?.let { javax.swing.SwingUtilities.getWindowAncestor(it) } + ?: return + EventQueue.invokeLater { + if (!ownerWindow.isDisplayable || !ownerWindow.isShowing) return@invokeLater + if (ownerWindow is java.awt.Frame && ownerWindow.extendedState and java.awt.Frame.ICONIFIED != 0) { + ownerWindow.extendedState = ownerWindow.extendedState and java.awt.Frame.ICONIFIED.inv() + } + ownerWindow.toFront() + ownerWindow.requestFocus() + ownerWindow.requestFocusInWindow() + } + } + + fun confirmDesktopFeatureNotice(notice: DesktopFeatureNotice) { + dismissDesktopFeatureNotice() + when (notice.action) { + DesktopFeatureNoticeAction.SIGN_IN -> signInDesktopAccount() + DesktopFeatureNoticeAction.OPEN_PRO -> { + selectAppTab(SharedAppTab.PRO) + focusDesktopAppWindow() + } + null -> Unit + } + } + + fun applyReaderOpenResult(result: DesktopReaderOpenResult) { + val applyStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_start") + + " result=${result.openTraceKind()}" + } + val window = readerWindows.firstOrNull { it.opening.requestId == result.opening.requestId } + if (window == null) { + if (result is DesktopReaderOpenResult.Pdf) { + result.document.close() + } + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_missing_window") + + " result=${result.openTraceKind()} durationMs=${applyStartedAt.elapsedOpenTraceMs()}" + } + return + } + + when (result) { + is DesktopReaderOpenResult.Failure -> { + readerWindows = readerWindows.withoutDesktopReaderWindow(window.id) + updateState(state.withBanner(result.message, isError = true)) + } + + is DesktopReaderOpenResult.PasswordRequired -> { + readerWindows = readerWindows.withDesktopReaderWindowContent( + requestId = result.opening.requestId, + content = DesktopReaderWindowContent.PasswordRequired( + book = result.book, + attemptedPassword = result.attemptedPassword + ) + ) + } + + is DesktopReaderOpenResult.Pdf -> { + readerWindows = readerWindows.withDesktopReaderWindowContent( + requestId = result.opening.requestId, + content = DesktopReaderWindowContent.Pdf( + book = result.book, + document = result.document + ) + ) + recordBookOpened(result.book.id) + if (result.book.type == FileType.PDF) { + schedulePdfEmbeddedAnnotationsLoad(window.id, result.document) + } + } + + is DesktopReaderOpenResult.Text -> { + val cloudTts = ReaderCloudTtsState( + isAvailable = effectiveAiSettings().isCloudTtsAvailable, + cacheSummary = desktopTtsAdapter.cacheSummary( + result.session.reader.book.title, + aiByokSettings.sanitized().ttsSpeakerId + ) + ) + readerWindows = readerWindows.withDesktopReaderWindowContent( + requestId = result.opening.requestId, + content = DesktopReaderWindowContent.Text( + book = result.book, + session = result.session, + extrasState = ReaderExtrasState(cloudTts = cloudTts) + ) + ) + recordBookOpened(result.book.id) + } + } + logDesktopReaderOpenTrace { + result.opening.openTracePrefix("desktop_apply_done") + + " result=${result.openTraceKind()} windowId=\"${window.id.logPreview(80)}\" " + + "durationMs=${applyStartedAt.elapsedOpenTraceMs()} openWindows=${readerWindows.size}" + } + } + + fun openReader( + book: BookItem, + password: String? = null, + force: Boolean = false, + returnTabOverride: SharedAppTab? = null + ) { + val desktopReaderSurface = SharedFileCapabilities.surfaceFor(book.type, ReaderPlatform.DESKTOP) + if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER) { + val path = book.path + if (path.isNullOrBlank()) { + updateState( + state.withBanner( + "This ${SharedFileCapabilities.displayNameFor(book.type)} does not have a local path.", + isError = true + ) + ) + return + } + val streamReference = SharedOpdsStreamUri.parse(path) + if (streamReference != null && !featurePolicy.opdsCatalogs) { + updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true)) + return + } + } else if ( + desktopReaderSurface == ReaderFeatureSurface.EPUB_READER || + desktopReaderSurface == ReaderFeatureSurface.TEXT_READER + ) { + } else { + updateState( + state.withBanner( + "${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later. " + + "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are available on desktop." + ) + ) + return + } + + scheduleOpenedBookMetadataExtraction(book) + + val opening = DesktopReaderOpening( + requestId = ++nextReaderOpenRequestId, + bookId = book.id, + title = book.cardTitleForMessage(), + formatLabel = SharedFileCapabilities.displayNameFor(book.type), + returnTab = returnTabOverride + ?: selectedTab.takeUnless { it == SharedAppTab.READER } + ?: SharedAppTab.LIBRARY, + password = password + ) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_request") + + " type=${book.type} surface=$desktopReaderSurface force=$force " + + "path=\"${book.path.orEmpty().logPreview(180)}\"" + } + val readerDefaultSettings = state.readerDefaultSettings + val previousWindowCount = readerWindows.size + if (force) { + readerWindows.firstOrNull { it.bookId == book.id }?.let { existingWindow -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_force_cancel_existing") + + " windowId=\"${existingWindow.id.logPreview(80)}\"" + } + existingWindow.cancelReaderWork() + } + } + val openDecision = readerWindows.openOrFocusDesktopReaderWindow(opening, force) + readerWindows = openDecision.windows + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_window_decision") + + " shouldStart=${openDecision.shouldStartOpen} force=$force " + + "previousWindows=$previousWindowCount nextWindows=${openDecision.windows.size}" + } + if (!openDecision.shouldStartOpen) { + recordBookOpened(book.id) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_focus_existing_done") + } + return + } + + scope.launch { + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_coroutine_start") + } + val result = withContext(Dispatchers.IO) { + val ioStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_start") + + " surface=$desktopReaderSurface" + } + runCatching { + when (desktopReaderSurface) { + ReaderFeatureSurface.PDF_VIEWER -> { + val pdfStartedAt = System.nanoTime() + val path = book.path.orEmpty() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_pdf_load_start") + + " type=${book.type} path=\"${path.logPreview(180)}\" " + + "passwordSupplied=${!opening.password.isNullOrEmpty()}" + } + val streamReference = SharedOpdsStreamUri.parse(path) + val document = if (streamReference != null) { + DesktopPdfium.loadOpdsStream( + path = path, + title = book.title?.takeIf { it.isNotBlank() } ?: book.displayName, + reference = streamReference, + catalog = opdsRepository.catalogById(streamReference.catalogId) + ) + } else { + val readerFile = File(path) + when (book.type) { + FileType.PDF -> DesktopPdfium.load( + readerFile, + password = opening.password, + loadEmbeddedAnnotations = false + ) + FileType.PPTX -> DesktopPdfium.loadPptx(readerFile) + else -> DesktopPdfium.loadComic(readerFile, book.type) + } + } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_pdf_load_done") + + " type=${book.type} durationMs=${pdfStartedAt.elapsedOpenTraceMs()} " + + "pages=${document.pageCount}" + } + DesktopReaderOpenResult.Pdf(opening, book, document) + } + + ReaderFeatureSurface.EPUB_READER, + ReaderFeatureSurface.TEXT_READER -> { + val path = book.path?.takeIf { it.isNotBlank() } ?: error("Book path is missing.") + val readerFile = File(path) + val settingsStartedAt = System.nanoTime() + val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings) + val semanticMode = desktopEpubBookLoadSemanticMode(restoredSettings) + val preparedHtmlChapterRange = if (semanticMode == SharedJvmBookLoadSemanticMode.SKIP) { + val initialChapter = book.readerPosition?.chapterIndex?.takeIf { it >= 0 } ?: 0 + (initialChapter - DesktopVerticalInitialPreparedHtmlChapterRadius).coerceAtLeast(0).. + (initialChapter + DesktopVerticalInitialPreparedHtmlChapterRadius) + } else { + null + } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_settings_restored") + + " durationMs=${settingsStartedAt.elapsedOpenTraceMs()} " + + "mode=${restoredSettings.readingMode} semanticMode=${semanticMode.name} " + + "preparedHtmlChapters=${preparedHtmlChapterRange?.let { "${it.first}..${it.last}" } ?: "all"} " + + "fontSize=${restoredSettings.fontSize} textAlign=${restoredSettings.textAlign} " + + "pageWidth=${restoredSettings.pageWidth}" + } + val loadStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_text_load_start") + + " type=${book.type} semanticMode=${semanticMode.name} fileBytes=${readerFile.length()} " + + "path=\"${path.logPreview(180)}\"" + } + val loadedBook = SharedJvmBookLoader.load( + file = readerFile, + type = book.type, + titleOverride = book.title?.takeIf { it.isNotBlank() }, + authorOverride = book.author?.takeIf { it.isNotBlank() }, + semanticMode = semanticMode, + preparedHtmlChapterRange = preparedHtmlChapterRange + ) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_text_load_done") + + " durationMs=${loadStartedAt.elapsedOpenTraceMs()} " + + "loadedTitle=\"${loadedBook.title.logPreview(120)}\" " + + "chapters=${loadedBook.chapters.size} pagesBeforeSession=n/a " + + "textChars=${loadedBook.chapters.sumOf { it.plainText.length }} " + + "htmlChars=${loadedBook.chapters.sumOf { it.htmlContent.length }} " + + "semanticBlocks=${loadedBook.chapters.sumOf { it.semanticBlocks.size }} " + + "cssFiles=${loadedBook.css.size} cssChars=${loadedBook.css.values.sumOf { it.length }}" + } + val sessionStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_session_create_start") + + " initialPage=${book.lastPageIndex ?: 0} hasLocator=${book.readerPosition != null} " + + "locator=${book.readerPosition.desktopPositionTraceSummary(70)} " + + "bookmarks=${book.readerBookmarks.size} highlights=${book.readerHighlights.size}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_start") + + " initialPage=${book.lastPageIndex ?: 0} storedProgress=${book.progressPercentage ?: "null"} " + + "storedLocator=${book.readerPosition.desktopPositionTraceSummary()} " + + "mode=${restoredSettings.readingMode}" + } + val restoredSession = readerEngine.createSession( + book = loadedBook, + settings = restoredSettings, + initialPageIndex = book.lastPageIndex ?: 0, + initialLocator = book.readerPosition, + bookmarks = book.readerBookmarks, + highlights = book.readerHighlights + ) + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_session_create_done") + + " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + + "pages=${restoredSession.reader.pages.size} " + + "currentPage=${restoredSession.reader.currentPageIndex + 1} " + + "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary(70)} " + + "visiblePages=${restoredSession.reader.visiblePages.map { it.pageIndex + 1 }}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_session_done") + + " durationMs=${sessionStartedAt.elapsedOpenTraceMs()} " + + "page=${restoredSession.reader.currentPageIndex} pages=${restoredSession.reader.pages.size} " + + "navigationLocator=${restoredSession.navigationLocator.desktopPositionTraceSummary()}" + } + val restoredProgress = book.progressPercentage + val session = if (book.readerPosition == null && book.lastPageIndex == null && restoredProgress != null) { + val progressStartedAt = System.nanoTime() + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_progress_restore_start") + + " progress=$restoredProgress" + } + readerEngine.goToProgress(restoredSession, restoredProgress.coerceIn(0f, 100f) / 100f) + .also { restored -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_progress_restore_done") + + " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + + "currentPage=${restored.reader.currentPageIndex + 1} " + + "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary(70)}" + } + logDesktopPositionTrace { + opening.openTracePrefix("desktop_position_restore_progress_done") + + " durationMs=${progressStartedAt.elapsedOpenTraceMs()} " + + "page=${restored.reader.currentPageIndex} " + + "navigationLocator=${restored.navigationLocator.desktopPositionTraceSummary()}" + } + } + } else { + restoredSession + } + DesktopReaderOpenResult.Text(opening, book, session) + } + + else -> error("${SharedFileCapabilities.displayNameFor(book.type)} reader support comes later.") + } + }.getOrElse { error -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_exception") + + " durationMs=${ioStartedAt.elapsedOpenTraceMs()} " + + "error=\"${error.message.orEmpty().logPreview(240)}\"" + } + if (desktopReaderSurface == ReaderFeatureSurface.PDF_VIEWER && + book.type == FileType.PDF && + error.isDesktopPdfPasswordException() + ) { + DesktopReaderOpenResult.PasswordRequired( + opening = opening, + book = book, + attemptedPassword = !opening.password.isNullOrEmpty() + ) + } else { + DesktopReaderOpenResult.Failure( + opening = opening, + book = book, + message = "Could not open ${SharedFileCapabilities.displayNameFor(book.type)}: " + + (error.message ?: "unknown error") + ) + } + }.also { loadedResult -> + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_io_done") + + " result=${loadedResult.openTraceKind()} durationMs=${ioStartedAt.elapsedOpenTraceMs()}" + } + } + } + logDesktopReaderOpenTrace { + opening.openTracePrefix("desktop_open_result_ready") + + " result=${result.openTraceKind()}" + } + applyReaderOpenResult(result) + } + } + + fun requestPdfReflow( + sourceBook: BookItem, + document: DesktopPdfDocument, + pageIndex: Int + ) { + if (document.formatLabel != "PDF") return + val reflowBookId = desktopPdfReflowBookId(sourceBook.id) + val existingReflowBook = state.rawLibraryBooks.firstOrNull { book -> + book.id == reflowBookId && + book.path?.takeIf { it.isNotBlank() }?.let { File(it).isFile } == true + } + if (existingReflowBook != null) { + openReader( + existingReflowBook.copy(lastPageIndex = pageIndex), + force = true + ) + return + } + if (sourceBook.id in reflowingPdfBookIds) return + + reflowingPdfBookIds = reflowingPdfBookIds + sourceBook.id + updateState(state.withBanner("Generating Text View...")) + scope.launch { + try { + val originalTitle = sourceBook.title?.takeIf { it.isNotBlank() } + ?: sourceBook.displayName.substringBeforeLast('.', sourceBook.displayName) + .takeIf { it.isNotBlank() } + ?: document.title + val destination = desktopBookImporter.createBookFile( + desktopPdfReflowFileName(sourceBook.id, originalTitle) + ) + val generated = withContext(Dispatchers.IO) { + DesktopPdfReflowGenerator.generateHtmlFile( + document = document, + destFile = destination, + startPage = 1, + onProgress = {} + ) + } + if (!generated || !destination.isFile || destination.length() <= 0L) { + runCatching { destination.delete() } + updateState(state.withBanner("Text view generation failed.", isError = true)) + return@launch + } + + val reflowBook = desktopPdfReflowBookItem( + sourceBook = sourceBook, + generatedFile = destination, + nowMillis = System.currentTimeMillis(), + initialPageIndex = pageIndex + ) + updateState( + state.copy( + rawLibraryBooks = listOf(reflowBook) + state.rawLibraryBooks.filterNot { it.id == reflowBook.id } + ).withBanner("Generated Text View.") + ) + openReader( + reflowBook, + force = true + ) + } catch (error: Throwable) { + updateState( + state.withBanner( + error.message ?: "Text view generation failed.", + isError = true + ) + ) + } finally { + reflowingPdfBookIds = reflowingPdfBookIds - sourceBook.id + } + } + } + + fun removeFolder(shelf: Shelf) { + val removedBookIds = shelf.books.mapTo(mutableSetOf()) { it.id } + closeReaderWindowsForBookIds(removedBookIds) + SharedLibraryEditor.removeFolder(state, shelfRecords, shelfRefs, shelf)?.let { + replaceLibrary(it.state, records = it.shelfRecords, refs = it.shelfRefs) + queueFullCloudSyncAfterLocalChange() + } + } + + fun closeReaderTab(book: BookItem) { + readerWindows.firstOrNull { it.bookId == book.id }?.let { window -> + closeReaderWindow(window.id) + } ?: updateState(state.reduce(AppAction.BookTabClosed(book.id))) + } + + fun closeAllReaderTabs() { + closeAllReaderWindows() + } + + fun importAndOpenBook() { + val file = chooseBookFile() ?: return + val importedFile = file.toDesktopImportedBookFile() + val type = importedFile.desktopFileType() + if (type !in DesktopBookFileTypes) { + updateState( + state.withBanner( + "No supported desktop reader file was selected. " + + "${SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)} are supported.", + isError = true + ) + ) + return + } + importFiles(listOf(importedFile)) { importedBooks -> + importedBooks.firstOrNull()?.let(::openReader) + } + } + + fun importAndOpenPdf() { + val file = choosePdfFile() ?: return + importFiles(listOf(file.toDesktopImportedBookFile())) { importedBooks -> + importedBooks.firstOrNull()?.let(::openReader) + } + } + + fun emitOpds(next: org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState) { + opdsState = next + } + + fun openOpdsCatalog(catalog: OpdsCatalog) { + if (!featurePolicy.opdsCatalogs) return + scope.launch { + opdsController.openCatalog(catalog, ::emitOpds) + } + } + + fun openOpdsFeedUrl(url: String) { + if (!featurePolicy.opdsCatalogs) return + scope.launch { + opdsController.openFeedUrl(url, ::emitOpds) + } + } + + fun navigateOpdsBack() { + scope.launch { + opdsController.navigateBack(::emitOpds) + } + } + + fun searchOpds(query: String) { + if (!featurePolicy.opdsCatalogs) return + scope.launch { + opdsController.search(query, ::emitOpds) + } + } + + fun loadNextOpdsPage() { + if (!featurePolicy.opdsCatalogs) return + scope.launch { + opdsController.loadNextPage(::emitOpds) + } + } + + fun removeOpdsCatalog(catalog: OpdsCatalog) { + emitOpds(opdsController.removeCatalog(catalog.id)) + val streamBookIds = state.rawLibraryBooks + .filter { book -> SharedOpdsStreamUri.parse(book.path)?.catalogId == catalog.id } + .mapTo(mutableSetOf()) { it.id } + if (streamBookIds.isNotEmpty()) { + closeReaderWindowsForBookIds(streamBookIds) + updateState( + state.copy( + rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in streamBookIds }, + openTabIds = state.openTabIds.filterNot { it in streamBookIds }, + activeTabBookId = state.activeTabBookId?.takeUnless { it in streamBookIds } + ).withBanner( + desktopQuantityString( + "desktop_opds_removed_stream_book_count", + streamBookIds.size, + "Removed %1\$d streamed OPDS book from that catalog.", + "Removed %1\$d streamed OPDS books from that catalog.", + streamBookIds.size + ) + ) + ) + } + } + + fun downloadOpdsBook(entry: OpdsEntry, acquisition: OpdsAcquisition) { + if (!featurePolicy.opdsCatalogs) { + updateState(state.withBanner("OPDS downloads are unavailable in this desktop build.", isError = true)) + return + } + val catalog = opdsState.currentCatalog + scope.launch { + emitOpds(opdsController.updateDownloadState(entry.id, SharedOpdsDownloadState(true, 0f))) + val result = runCatching { + opdsRepository.downloadBook(entry, acquisition, catalog) { progress -> + scope.launch { + if (opdsController.state.downloadingState[entry.id]?.isDownloading == true) { + emitOpds(opdsController.updateDownloadState(entry.id, SharedOpdsDownloadState(true, progress))) + } + } + } + } + emitOpds(opdsController.updateDownloadState(entry.id, null)) + result.onSuccess { file -> + importFiles(listOf(file.toDesktopImportedBookFile())) + updateState(state.withBanner("Downloaded ${file.name} from OPDS.")) + }.onFailure { error -> + updateState( + state.withBanner( + "Could not download ${entry.title}: ${error.message ?: "unknown error"}", + isError = true + ) + ) + } + } + } + + fun streamOpdsBook(entry: OpdsEntry, catalog: OpdsCatalog?) { + if (!featurePolicy.opdsCatalogs) { + updateState(state.withBanner("OPDS streams are unavailable in this desktop build.", isError = true)) + return + } + val pageCount = entry.pseCount + val urlTemplate = entry.pseUrlTemplate + if (pageCount == null || pageCount <= 0 || urlTemplate.isNullOrBlank()) { + updateState(state.withBanner("This OPDS entry does not expose a readable stream.", isError = true)) + return + } + val reference = OpdsStreamReference( + id = entry.id.ifBlank { "${entry.title}:$urlTemplate" }, + count = pageCount, + urlTemplate = urlTemplate, + catalogId = catalog?.id + ) + val uriString = SharedOpdsStreamUri.build(reference) + val now = System.currentTimeMillis() + val streamBook = BookItem( + id = uriString, + path = uriString, + type = FileType.CBZ, + displayName = entry.title, + timestamp = now, + title = entry.title, + author = entry.author, + fileSize = 0L + ) + if (state.rawLibraryBooks.none { it.id == streamBook.id }) { + updateState(state.copy(rawLibraryBooks = state.rawLibraryBooks + streamBook)) + } + openReader(streamBook) + } + + val latestReaderWindows by rememberUpdatedState(readerWindows) + val latestStateForDispose by rememberUpdatedState(state) + val latestShelfRecordsForDispose by rememberUpdatedState(shelfRecords) + val latestShelfRefsForDispose by rememberUpdatedState(shelfRefs) + val latestCustomFontsForDispose by rememberUpdatedState(customFonts) + DisposableEffect(Unit) { + onDispose { + flushDesktopPersistenceBeforeDispose( + projected = latestStateForDispose, + records = latestShelfRecordsForDispose, + refs = latestShelfRefsForDispose, + fonts = latestCustomFontsForDispose + ) + latestReaderWindows.forEach { it.closeReaderResources() } + } + } + + DesktopFileDropTarget( + window = window, + onFilesDropped = ::importFiles, + onDragStateChange = { dropImportState = it } + ) + + LaunchedEffect(Unit) { + if (featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable) { + refreshDesktopAccountProfile(showBanner = false) + } + } + + LaunchedEffect(accountRefreshRequestCount) { + if (accountRefreshRequestCount > 0 && featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable) { + refreshDesktopAccountProfile(showBanner = false) + } + } + + LaunchedEffect(state.isSyncEnabled, state.currentUser?.uid, state.isProUser, desktopAccountProfileRefreshCompleted) { + if ( + !initialDesktopCloudSyncDone && + desktopAccountProfileRefreshCompleted && + state.isSyncEnabled && + state.currentUser != null && + state.isProUser + ) { + initialDesktopCloudSyncDone = true + syncDesktopCloud(showBanner = false).join() + } + } + + LaunchedEffect(Unit) { + if (state.syncedFolders.any { it.localSyncEnabled }) { + scanSyncedFolders(showBanner = false) + } + } + + LaunchedEffect(state.bannerMessage) { + state.bannerMessage?.let { banner -> + snackbarHostState.showSnackbar(banner.text?.let(desktopStringResolver::sharedText) ?: banner.message) + updateState(state.reduce(AppAction.BannerDismissed)) + } + } + + LaunchedEffect(aiByokSettings, state.currentUser, state.credits) { + readerWindows = readerWindows.replaceAllDesktopTextReaderContent { content -> + content.copy( + extrasState = content.extrasState.copy( + cloudTts = content.extrasState.cloudTts.copy( + isAvailable = effectiveAiSettings().isCloudTtsAvailable, + errorMessage = null, + cacheSummary = textReaderTtsCacheSummary(content) + ) + ) + ) + } + } + + val desktopAppFontFamily = remember(state.appFontPreference, customFonts) { + state.appFontPreference.toDesktopAppFontFamily(customFonts) + } + + CompositionLocalProvider(LocalSharedStringResolver provides desktopStringResolver) { + SharedAppTheme( + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + appFontFamily = desktopAppFontFamily + ) { + val appThemeControls: @Composable () -> Unit = { + SharedAppThemeControls( + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + customAppThemes = state.customAppThemes, + onThemeModeChanged = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, + onContrastOptionChanged = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, + onTextDimFactorLightChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, + onTextDimFactorDarkChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, + onSeedColorChanged = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, + onCustomThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, + onCustomThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) } + ) + } + EpistemeDesktopWindowChromeEffect( + window = window, + captionColor = MaterialTheme.colorScheme.surface, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + SharedAppShell( + selectedTab = selectedTab, + snackbarHostState = snackbarHostState, + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + customAppThemes = state.customAppThemes, + isTabsEnabled = state.isTabsEnabled, + featurePolicy = featurePolicy, + currentUser = if (desktopAccountAvailable()) state.currentUser else null, + accountAvailable = desktopAccountAvailable(), + isOssBuild = desktopBuildProfile.isOssOffline, + isProUser = state.isProUser, + isSyncEnabled = state.isSyncEnabled, + syncAvailable = desktopCloudSyncAvailable(), + onSignInRequested = if (desktopAccountAvailable()) { + ::signInDesktopAccount + } else { + null + }, + accountAvatar = { user, modifier -> + DesktopProfileAvatar(user = user, modifier = modifier) + }, + onSyncEnabledChange = { enabled -> + setDesktopCloudSyncEnabled(enabled) + }, + onTabSelected = { tab -> + selectAppTab(tab) + }, + onImportFiles = { importFiles(chooseFiles()) }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, + onSyncRequested = { syncDesktopLibrary() }, + onFolderMetadataSyncRequested = { syncFolderMetadata() }, + onAppThemeModeChange = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, + onAppContrastOptionChange = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, + onAppTextDimFactorLightChange = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, + onAppTextDimFactorDarkChange = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, + onAppSeedColorChange = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, + onCustomAppThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, + onCustomAppThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) }, + onTabsEnabledChange = { enabled -> + if (!enabled) closeAllReaderWindows() + updateState(state.reduce(AppAction.TabsEnabledChanged(enabled))) + }, + onAiSettingsRequested = if (desktopAiKeySettingsAvailable) { + { showAiByokSettingsDialog = true } + } else { + null + } + ) { tab -> + when (tab) { + SharedAppTab.SETTINGS -> SharedSettingsHub( + model = sharedSettingsHubModel( + SharedSettingsHubInput( + platform = SharedSettingsPlatform.DESKTOP, + featurePolicy = featurePolicy, + isDebugBuild = false, + isSignedIn = state.currentUser != null, + isProUser = state.isProUser, + accountAvailable = featurePolicy.aiAndCloud && !desktopBuildProfile.byokAiAvailable, + includeAccountAuthActions = false, + syncAvailable = desktopCloudSyncAvailable(), + folderSyncAvailable = true, + aiSettingsAvailable = desktopAiKeySettingsAvailable, + includeLanguage = true, + includeScreenCaptureProtection = false, + includeExternalFileBehavior = false, + includeStrictFileFilter = false, + includeReaderTabs = false, + includeHideReaderAi = false, + isTabsEnabled = state.isTabsEnabled, + isSyncEnabled = state.isSyncEnabled, + isFolderSyncEnabled = state.isFolderSyncEnabled, + hideReaderAi = false, + languageTitle = desktopString("options_language", "Language"), + languageSummary = selectedDesktopLanguageOption(desktopLanguageTag).let { option -> + desktopString(option.labelKey, option.fallbackLabel) + } + ) + ), + query = settingsQuery, + onQueryChange = { settingsQuery = it }, + destination = settingsDestination, + onDestinationChange = { settingsDestination = it }, + readerDefaultSettings = state.readerDefaultSettings, + onReaderDefaultSettingsChange = { settings -> + updateState( + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.TEXT, + settings + ) + ) + }, + pdfReaderDefaultSettings = state.pdfReaderDefaultSettings, + onPdfReaderDefaultSettingsChange = { settings -> + updateState( + state.withDesktopReaderEngineDefaultSettings( + DesktopReaderSettingsEngine.PDF, + settings + ) + ) + }, + readerToolbarPreferences = state.readerToolbarPreferences, + onReaderToolbarPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) + }, + ttsReplacementPreferences = state.readerTtsReplacementPreferences, + onTtsReplacementPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) + }, + customFonts = customFonts, + onPickCustomFont = { importCustomFont(chooseFontFile())?.path }, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = ::importDesktopReaderTexture, + onAction = { action -> + when (action) { + SharedSettingsAction.APP_THEME -> showDesktopAppThemeSettingsDialog = true + SharedSettingsAction.TABS_TOGGLE -> { + if (state.isTabsEnabled) closeAllReaderWindows() + updateState(state.reduce(AppAction.TabsEnabledChanged(!state.isTabsEnabled))) + } + SharedSettingsAction.FOLDER_SYNC -> setDesktopFolderSyncEnabled(!state.isFolderSyncEnabled) + SharedSettingsAction.AI_SETTINGS -> if (desktopAiKeySettingsAvailable) showAiByokSettingsDialog = true + SharedSettingsAction.SIGN_IN -> signInDesktopAccount() + SharedSettingsAction.SIGN_OUT -> signOutDesktopAccount() + SharedSettingsAction.HIDE_READER_AI -> Unit + SharedSettingsAction.CUSTOM_FONTS -> selectAppTab(SharedAppTab.CUSTOM_FONTS) + SharedSettingsAction.HELP_FEEDBACK -> selectAppTab(SharedAppTab.FEEDBACK) + SharedSettingsAction.SUPPORT -> selectAppTab(SharedAppTab.SUPPORT) + SharedSettingsAction.ABOUT -> selectAppTab(SharedAppTab.ABOUT) + SharedSettingsAction.CLEAR_BOOK_CACHE -> showClearBookCacheDialog = true + SharedSettingsAction.CLEAR_REFLOW_CACHE, + SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA, + SharedSettingsAction.TEST_PANEL_DETECTION, + SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION, + SharedSettingsAction.EXPORT_LOGS, + SharedSettingsAction.DEBUG_ACTIONS, + SharedSettingsAction.DEVICE_MANAGEMENT, + SharedSettingsAction.RECENT_LIMIT, + SharedSettingsAction.STRICT_FILE_FILTER, + SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME, + SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR, + SharedSettingsAction.SCREEN_CAPTURE_PROTECTION, + SharedSettingsAction.TTS_SETTINGS, + SharedSettingsAction.PDF_READER_DEFAULTS, + SharedSettingsAction.TEXT_READER_DEFAULTS, + SharedSettingsAction.READER_TOOLBAR, + SharedSettingsAction.TTS_REPLACEMENTS, + SharedSettingsAction.LOCAL_OVERRIDE_NOTE -> Unit + SharedSettingsAction.LANGUAGE -> showDesktopLanguageDialog = true + SharedSettingsAction.CLOUD_SYNC -> setDesktopCloudSyncEnabled(!state.isSyncEnabled) + } + } + ) + + SharedAppTab.PRO -> DesktopProScreen( + user = state.currentUser, + isProUser = state.isProUser, + credits = state.credits, + authConfigured = desktopCloudConfig.isAuthConfigured, + isBusy = accountBusy, + statusMessage = accountStatusMessage, + onSignIn = ::signInDesktopAccount, + onSignOut = ::signOutDesktopAccount, + onRefresh = { + scope.launch { + accountBusy = true + refreshDesktopAccountProfile(showBanner = true) + accountBusy = false + } + } + ) + + SharedAppTab.LIBRARY -> LibraryScreen( + state = state, + selectedLibraryTab = selectedLibraryTab, + onLibraryTabChange = { selectedLibraryTab = it }, + onStateChange = ::updateState, + onImportBooks = { + importFiles(chooseFiles()) + }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, + onRead = ::openReader, + onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, + onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, + onRemoveSelected = ::removeSelectedBooks, + onShowBookInfo = { + bookInfoInitiallyEditing = false + bookInfoDialogFor = it + }, + onEditBook = { + bookInfoInitiallyEditing = true + bookInfoDialogFor = it + }, + onCreateShelf = { + createShelfBookIds = emptySet() + createShelfClearsSelection = false + showCreateShelfDialog = true + }, + onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, + onCreateSmartShelf = { showCreateSmartShelfDialog = true }, + onRenameShelf = { shelfToRename = it }, + onDeleteShelf = { shelfToDelete = it }, + onRemoveFolder = { folderToRemove = it }, + onTagSelectedBooks = { showTagSelectionDialog = true }, + onAddSelectedBooksToShelf = { + addToShelfBookIds = state.selectedBookIds + addToShelfClearsSelection = true + }, + onAddBooksToShelf = { bookIds -> + addToShelfBookIds = bookIds + addToShelfClearsSelection = false + }, + onManageShelfBooks = { shelfToManageBooks = it }, + onSyncFolderMetadata = { syncFolderMetadata() }, + onScanFolders = { scanSyncedFolders() }, + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, + onSaveOriginalFile = ::saveDesktopOriginalFile + ) + + SharedAppTab.SHELVES -> LibraryScreen( + state = state, + selectedLibraryTab = NonReaderLibraryTab.SHELVES, + onLibraryTabChange = { + selectedLibraryTab = it + selectedTab = SharedAppTab.LIBRARY + }, + onStateChange = ::updateState, + onImportBooks = { + importFiles(chooseFiles()) + }, + onImportFolder = { chooseFolder()?.let(::importFolder) }, + onRead = ::openReader, + onSelect = { id -> updateState(state.reduce(LibraryAction.BookSelectionToggled(id))) }, + onClearSelection = { updateState(state.reduce(LibraryAction.SelectionCleared)) }, + onRemoveSelected = ::removeSelectedBooks, + onShowBookInfo = { + bookInfoInitiallyEditing = false + bookInfoDialogFor = it + }, + onEditBook = { + bookInfoInitiallyEditing = true + bookInfoDialogFor = it + }, + onCreateShelf = { + createShelfBookIds = emptySet() + createShelfClearsSelection = false + showCreateShelfDialog = true + }, + onCreateShelfWithBooks = { name, bookIds -> createShelfWithBooks(name, bookIds) }, + onCreateSmartShelf = { showCreateSmartShelfDialog = true }, + onRenameShelf = { shelfToRename = it }, + onDeleteShelf = { shelfToDelete = it }, + onRemoveFolder = { folderToRemove = it }, + onTagSelectedBooks = { showTagSelectionDialog = true }, + onAddSelectedBooksToShelf = { + addToShelfBookIds = state.selectedBookIds + addToShelfClearsSelection = true + }, + onAddBooksToShelf = { bookIds -> + addToShelfBookIds = bookIds + addToShelfClearsSelection = false + }, + onManageShelfBooks = { shelfToManageBooks = it }, + onSyncFolderMetadata = { syncFolderMetadata() }, + onScanFolders = { scanSyncedFolders() }, + onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }, + onSaveOriginalFile = ::saveDesktopOriginalFile + ) + + SharedAppTab.CATALOGS -> { + if (featurePolicy.opdsCatalogs) { + SharedOpdsScreen( + state = opdsState, + localLibraryBooks = state.rawLibraryBooks, + onOpenCatalog = ::openOpdsCatalog, + onOpenFeedUrl = ::openOpdsFeedUrl, + onNavigateBack = ::navigateOpdsBack, + onSearch = ::searchOpds, + onLoadNextPage = ::loadNextOpdsPage, + onAddCatalog = { title, url, username, password -> + emitOpds(opdsController.addCatalog(title, url, username, password)) + }, + onUpdateCatalog = { id, title, url, username, password -> + emitOpds(opdsController.updateCatalog(id, title, url, username, password)) + }, + onRemoveCatalog = ::removeOpdsCatalog, + onDownloadBook = ::downloadOpdsBook, + onReadBook = ::openReader, + onStreamBook = ::streamOpdsBook, + onClearError = { emitOpds(opdsController.clearError()) }, + coverContent = { entry, modifier -> + DesktopOpdsCoverImage( + entry = entry, + catalog = opdsState.currentCatalog, + modifier = modifier + ) + } + ) + } else { + Box(Modifier.fillMaxSize()) + } + } + + SharedAppTab.CUSTOM_FONTS -> SharedCustomFontsScreen( + fonts = customFonts, + appFontPreference = state.appFontPreference, + onAppFontPreferenceChange = { preference -> + updateState(state.reduce(AppAction.AppFontPreferenceChanged(preference))) + }, + onImportFont = { importCustomFont(chooseFontFile()) }, + onDeleteFont = ::deleteCustomFont, + googleFontsAvailable = featurePolicy.googleFontsDownload, + getGoogleFonts = { customFontStore.loadGoogleFontsList() }, + onDownloadGoogleFont = ::downloadGoogleFont, + fontFamilyForPreview = { font -> font.toDesktopPreviewFontFamily() } + ) + + SharedAppTab.FEEDBACK -> SharedHelpFeedbackScreen( + onOpenGitHubIssues = { openExternalUrl(EpistemeIssuesUrl) }, + onEmailSupport = { + val subject = desktopFeedbackSubject(desktopBuildProfile).urlEncode() + openExternalUrl("mailto:$EpistemeSupportEmail?subject=$subject") + } + ) + + SharedAppTab.SUPPORT -> SharedSupportProjectScreen( + onOpenGitHubSponsors = { openExternalUrl(EpistemeGitHubSponsorsUrl) }, + onOpenPatreon = { openExternalUrl(EpistemePatreonUrl) } + ) + + SharedAppTab.ABOUT -> SharedAboutScreen( + versionName = desktopAppVersionName(), + buildLabel = desktopBuildProfile.buildLabel, + onOpenSource = if (featurePolicy.projectLinks) { + { openExternalUrl(EpistemeSourceUrl) } + } else { + null + }, + onOpenIssues = if (featurePolicy.projectLinks) { + { openExternalUrl(EpistemeIssuesUrl) } + } else { + null + }, + onOpenPrivacyPolicy = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.privacyPolicyUrl) } + } else { + null + }, + onOpenTerms = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.termsUrl) } + } else { + null + }, + onOpenLicenses = if (featurePolicy.projectLinks) { + { openExternalUrl(desktopBuildProfile.legalLinks.licensesUrl) } + } else { + null + } + ) + + SharedAppTab.READER -> Box(Modifier.fillMaxSize()) + } + } + DesktopDropImportOverlay(dropImportState) + } + + readerWindows.forEach { readerWindow -> + key(readerWindow.id, readerWindow.surfaceResetId) { + val restoredReaderWindowState = savedReaderWindowState + val windowState = rememberWindowState( + placement = restoredReaderWindowState?.toReaderWindowPlacement() ?: WindowPlacement.Floating, + position = WindowPosition(Alignment.Center), + size = restoredReaderWindowState?.toWindowSize(DesktopReaderWindowDefaultSize) + ?: DesktopReaderWindowDefaultSize + ) + Window( + onCloseRequest = { + logDesktopReaderClose( + "window_on_close_request windowId=${readerWindow.id.logPreview(80)} " + + "bookId=${readerWindow.bookId.logPreview(80)} fullscreen=${readerWindow.fullscreen}" + ) + if (!readerWindow.fullscreen) { + saveReaderWindowStateSnapshot(DesktopWindowStateSnapshot.fromWindowState(windowState)) + } + closeReaderWindow(readerWindow.id) + }, + title = desktopString("desktop_label_pair_format", "%1\$s - %2\$s", readerWindow.title, readerWindowDefaults.title), + state = windowState, + icon = painterResource(readerWindowDefaults.iconResourcePath) + ) { + val readerAwtWindow = this.window + val latestReaderWindowForDispose by rememberUpdatedState(readerWindow) + DisposableEffect(readerWindow.id) { + onDispose { + logDesktopReaderClose( + "window_dispose_effect windowId=${latestReaderWindowForDispose.id.logPreview(80)} " + + "bookId=${latestReaderWindowForDispose.bookId.logPreview(80)} " + + "content=${latestReaderWindowForDispose.readerCloseContentLabel()}" + ) + latestReaderWindowForDispose.closeReaderResources() + } + } + DesktopWindowStatePersistenceEffect( + windowState = windowState, + store = readerWindowStateStore, + enabled = !readerWindow.fullscreen, + transformSnapshot = { it.toPersistableReaderWindowSnapshot() }, + onSnapshotSaved = { savedReaderWindowState = it } + ) + DisposableEffect(readerAwtWindow, readerWindowDefaults.minimumSize) { + readerAwtWindow.minimumSize = readerWindowDefaults.minimumSize + onDispose {} + } + LaunchedEffect(readerWindow.focusRequestId) { + EventQueue.invokeLater { + val awtWindow = readerAwtWindow as? java.awt.Window ?: return@invokeLater + if (awtWindow is java.awt.Frame && awtWindow.extendedState and java.awt.Frame.ICONIFIED != 0) { + awtWindow.extendedState = awtWindow.extendedState and java.awt.Frame.ICONIFIED.inv() + } + awtWindow.toFront() + awtWindow.requestFocus() + awtWindow.requestFocusInWindow() + } + } + EpistemeDesktopWindowDecorationEffect( + window = readerAwtWindow, + hideDecoration = readerWindow.fullscreen && windowState.placement != WindowPlacement.Fullscreen + ) + DesktopReaderFullscreenEffect( + window = readerAwtWindow, + enabled = readerWindow.fullscreen && windowState.placement != WindowPlacement.Fullscreen + ) + SharedAppTheme( + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + appFontFamily = desktopAppFontFamily + ) { + EpistemeDesktopWindowChromeEffect( + window = readerAwtWindow, + captionColor = MaterialTheme.colorScheme.surfaceVariant, + textColor = MaterialTheme.colorScheme.onSurface, + borderColor = MaterialTheme.colorScheme.background + ) + SharedReaderModalOwnerWindowProvider(ownerWindow = readerAwtWindow) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + when (val content = readerWindow.content) { + DesktopReaderWindowContent.Opening -> { + val openingBook = state.rawLibraryBooks.firstOrNull { it.id == readerWindow.bookId } + DesktopReaderOpeningScreen( + opening = readerWindow.opening, + readerSettings = openingBook?.let { resolvedDesktopReaderSettings(it, state.readerDefaultSettings) } + ) + } + + is DesktopReaderWindowContent.PasswordRequired -> { + DesktopReaderOpeningScreen( + opening = readerWindow.opening, + readerSettings = resolvedDesktopReaderSettings(content.book, state.readerDefaultSettings) + ) + DesktopPdfPasswordDialog( + title = content.book.displayName, + isError = content.attemptedPassword, + onDismiss = { closeReaderWindow(readerWindow.id) }, + onConfirm = { enteredPassword -> + openReader( + book = content.book, + password = enteredPassword, + force = true, + returnTabOverride = readerWindow.opening.returnTab + ) + } + ) + } + + is DesktopReaderWindowContent.Pdf -> { + val activePdfBook = state.rawLibraryBooks.firstOrNull { it.id == content.book.id } + ?: content.book + val activePdfReflowBookId = desktopPdfReflowBookId(activePdfBook.id) + val activePdfHasReflowFile = state.rawLibraryBooks.any { book -> + book.id == activePdfReflowBookId && + book.path?.takeIf { it.isNotBlank() }?.let { File(it).isFile } == true + } + PdfReaderScreen( + document = content.document, + initialPageIndex = activePdfBook.lastPageIndex ?: 0, + initialViewport = activePdfBook.pdfReaderViewport, + initialReaderSettings = resolvedDesktopReaderSettings( + activePdfBook, + state.pdfReaderDefaultSettings + ), + onReturnToLibrary = null, + onFullscreenChange = { enabled -> + updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } + }, + appThemeControls = appThemeControls, + onPageStateChange = { page, progress, viewport -> + updateBookReadingState( + bookId = content.book.id, + pageIndex = page, + progress = progress, + pdfViewport = viewport + ) + }, + onReaderSettingsChange = { settings -> + updateBookReaderSettings(content.book.id, settings) + }, + pdfHighlighterPalette = state.pdfHighlighterPalette, + onPdfHighlighterPaletteChange = { palette -> + updateState(state.reduce(AppAction.PdfHighlighterPaletteChanged(palette))) + }, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, + customTextureIds = readerCustomTextureIds, + onImportTexture = ::importDesktopReaderTexture, + onLocalSidecarsChanged = { + state.rawLibraryBooks.firstOrNull { it.id == content.book.id }?.let { book -> + syncBookSidecars(book) + markReaderCloudDirty( + bookId = book.id, + baseTimestamp = book.timestamp, + sidecarsDirty = true + ) + queueCloudBookMetadataSync( + book = book, + debounce = true, + dirtyBaseTimestamp = book.timestamp, + forceUploadAnnotations = true + ) + } + }, + aiByokSettings = effectiveAiSettings(), + aiAdapter = desktopAiAdapter, + ttsAdapter = desktopTtsAdapter, + ttsReplacementPreferences = state.readerTtsReplacementPreferences, + onTtsReplacementPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) + }, + summaryCacheStore = desktopSummaryCacheStore, + credits = state.credits, + showPaidCredits = desktopCloudTtsUsesCredits, + onAiByokSettingsChange = ::updateAiByokSettings, + featurePolicy = featurePolicy, + cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, + onReaderAiEntitlementRequired = { feature, text -> + desktopFeatureNoticeForReaderAi(feature, text)?.let { notice -> + showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) + true + } ?: false + }, + onCloudTtsEntitlementRequired = { + desktopFeatureNoticeForCloudTts()?.let { notice -> + showDesktopFeatureNotice(notice, readerWindowId = readerWindow.id) + true + } ?: false + }, + onPaidFeatureError = { errorMessage -> + desktopFeatureNoticeForError(errorMessage)?.let { + showDesktopFeatureNotice(it, readerWindowId = readerWindow.id) + } + }, + hasReflowFile = activePdfHasReflowFile, + isReflowingThisBook = activePdfBook.id in reflowingPdfBookIds, + onReflowAction = if (content.document.formatLabel == "PDF") { + { pageIndex -> requestPdfReflow(activePdfBook, content.document, pageIndex) } + } else { + null + } + ) + } + + is DesktopReaderWindowContent.Text -> { + var previousTextReaderMode by remember(readerWindow.id) { + mutableStateOf(content.session.reader.settings.readingMode) + } + LaunchedEffect(content.session.reader.settings.readingMode) { + val previousMode = previousTextReaderMode + val currentMode = content.session.reader.settings.readingMode + previousTextReaderMode = currentMode + if ( + shouldResetDesktopTextReaderWindowSurface( + previousMode = previousMode, + currentMode = currentMode, + usesNativeWebView = desktopEpubWebViewUsesNativeSwtBrowser() + ) + ) { + if (!readerWindow.fullscreen) { + saveReaderWindowStateSnapshot( + DesktopWindowStateSnapshot.fromWindowState(windowState) + ) + } + logReaderModeSwitch( + "window_surface_reset_request windowId=${readerWindow.id.logPreview()} " + + "bookId=${readerWindow.bookId.logPreview()} previousMode=$previousMode currentMode=$currentMode " + + "surfaceResetId=${readerWindow.surfaceResetId} fullscreen=${readerWindow.fullscreen} " + + "windowState=${windowState.size.width.value.formatLogFloat()}x" + + "${windowState.size.height.value.formatLogFloat()} placement=${windowState.placement}" + ) + updateReaderWindow(readerWindow.id) { currentWindow -> + currentWindow.copy( + surfaceResetId = currentWindow.surfaceResetId + 1, + focusRequestId = currentWindow.focusRequestId + 1 + ) + } + } + } + LaunchedEffect( + readerWindow.id, + content.session.reader.book.id, + content.session.reader.currentPage?.chapterIndex + ) { + clearReaderHubSummary(readerWindow.id) + clearReaderHubRecap(readerWindow.id) + } + DesktopReaderScreen( + session = content.session, + readerEngine = readerEngine, + onSessionChange = { updated -> + updateTextReaderWindow(readerWindow.id) { current -> + current.copy(session = updated) + } + updateBookReadingState( + bookId = content.book.id, + pageIndex = updated.reader.currentPageIndex, + progress = updated.reader.progress, + session = updated + ) + }, + onReturnToLibrary = null, + onFullscreenChange = { enabled -> + updateReaderWindow(readerWindow.id) { it.copy(fullscreen = enabled) } + }, + readerAwtWindow = readerAwtWindow, + toolbarPreferences = state.readerToolbarPreferences, + onToolbarPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderToolbarPreferencesChanged(preferences))) + }, + appThemeControls = appThemeControls, + customReaderThemes = state.customReaderThemes, + onCustomReaderThemesChange = { themes -> + updateState(state.reduce(AppAction.CustomReaderThemesChanged(themes))) + }, + highlightPalette = state.readerHighlightPalette, + onHighlightPaletteChange = { palette -> + updateState(state.reduce(AppAction.ReaderHighlightPaletteChanged(palette))) + }, + ttsReplacementPreferences = state.readerTtsReplacementPreferences, + ttsReplacementBookId = content.book.id.ifBlank { content.session.reader.book.title }, + onTtsReplacementPreferencesChange = { preferences -> + updateState(state.reduce(AppAction.ReaderTtsReplacementPreferencesChanged(preferences))) + }, + onPickCustomFont = { + importCustomFont(chooseFontFile())?.path + }, + customFonts = customFonts, + readerExtrasState = content.extrasState, + aiByokSettings = effectiveAiSettings(), + externalLookupAvailable = featurePolicy.externalLookup, + cloudTtsControlsAvailable = desktopCloudTtsControlsAvailable, + onExternalLookup = ::openReaderExternalLookup, + onAiAction = { feature, text -> + runReaderAiAction(readerWindow.id, feature, text) + }, + onAiResultDismiss = { + updateTextReaderWindow(readerWindow.id) { current -> + current.copy( + dismissedReaderAiResultRequestId = current.readerAiResultRequestId, + extrasState = current.extrasState.copy(aiResult = ReaderAiResultState()) + ) + } + }, + onCloudTtsToggle = { text, locator -> toggleReaderCloudTts(readerWindow.id, text, locator) }, + onCloudTtsStart = { readScope, chunks -> + startReaderCloudTts(readerWindow.id, readScope, chunks) + }, + onCloudTtsPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, + onCloudTtsStop = { stopReaderCloudTts(readerWindow.id) }, + onCloudTtsClearCache = { clearReaderCloudTtsCache(readerWindow.id) }, + onCloudTtsVoiceChange = { voiceId -> + updateAiByokSettings(effectiveAiSettings().copy(ttsSpeakerId = voiceId)) + }, + onOpenAiHub = { + updateTextReaderWindow(readerWindow.id) { current -> + current.copy(showAiHub = true) + } + }, + onDownloadReaderImage = ::downloadReaderImage, + readerTextureDataUri = DesktopReaderTextures::dataUriFor, + readerCustomTextureIds = readerCustomTextureIds, + onImportReaderTexture = ::importDesktopReaderTexture, + bottomChromeExtraContent = { + if (desktopCloudTtsControlsAvailable) { + val settings = effectiveAiSettings() + var isTtsOverlayCollapsed by remember(readerWindow.id) { mutableStateOf(false) } + val ttsControls = readerCloudTtsControlsModel(content.extrasState.cloudTts) + if (ttsControls.isVisible) { + SharedReaderTtsOverlayControls( + settings = settings, + cloudTts = content.extrasState.cloudTts, + credits = state.credits, + showCredits = desktopCloudTtsUsesCredits, + isCollapsed = isTtsOverlayCollapsed, + onCollapseChange = { isTtsOverlayCollapsed = it }, + onPauseResume = { pauseResumeReaderCloudTts(readerWindow.id) }, + onSkipPrevious = { skipReaderCloudTtsChunk(readerWindow.id, -1) }, + onSkipNext = { skipReaderCloudTtsChunk(readerWindow.id, 1) }, + onLocateCurrentChunk = { locateReaderCloudTtsChunk(readerWindow.id) }, + onClose = { stopReaderCloudTts(readerWindow.id) }, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp, bottom = 4.dp) + ) + } + } + }, + webViewRuntimeState = webViewRuntimeState, + webViewNetworkAccessEnabled = featurePolicy.networkAccess, + epubPaginationCache = desktopEpubPaginationCache, + epubPaginationCacheGeneration = epubPaginationCacheGeneration, + useDetachedChromeLayer = true, + useDetachedPanelLayer = true + ) + + if (content.showAiHub) { + DesktopAiHubSheet( + bookKey = readerHubBookKey(content), + bookTitle = content.session.reader.book.title.ifBlank { + desktopString("desktop_untitled", "Untitled") + }, + itemIndex = readerHubChapterIndex(content), + itemTitle = readerHubChapterTitle(content), + summaryCacheStore = desktopSummaryCacheStore, + summaryResult = content.summaryResult, + isSummaryLoading = content.isSummaryLoading, + recapResult = content.recapResult, + isRecapLoading = content.isRecapLoading, + recapProgressMessage = content.recapProgressMessage, + onGenerateSummary = { force -> + generateReaderHubSummary(readerWindow.id, force) + }, + onClearSummary = { clearReaderHubSummary(readerWindow.id) }, + onGenerateRecap = { generateReaderHubRecap(readerWindow.id) }, + onClearRecap = { clearReaderHubRecap(readerWindow.id) }, + onDismiss = { + updateTextReaderWindow(readerWindow.id) { current -> + current.copy(showAiHub = false) + } + }, + credits = state.credits, + showCredits = desktopCloudTtsUsesCredits + ) + } + } + } + desktopFeatureNoticeState + ?.takeIf { it.placement.rendersInReaderWindow(readerWindow.id) } + ?.let { noticeState -> + DesktopFeatureNoticeDialog( + notice = noticeState.notice, + onDismiss = ::dismissDesktopFeatureNotice, + onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } + ) + } + } + } + } + } + } + } + + desktopFeatureNoticeState + ?.takeIf { it.placement.rendersInMainWindow() } + ?.let { noticeState -> + DesktopFeatureNoticeDialog( + notice = noticeState.notice, + onDismiss = ::dismissDesktopFeatureNotice, + onConfirm = { confirmDesktopFeatureNotice(noticeState.notice) } + ) + } + + if (showAiByokSettingsDialog && desktopAiKeySettingsAvailable) { + DesktopAiByokSettingsDialog( + settings = aiByokSettings, + secureStorageAvailable = aiByokStore.isSecureStorageAvailable, + onSettingsChange = ::updateAiByokSettings, + onDismiss = { showAiByokSettingsDialog = false } + ) + } + + if (showDesktopAppThemeSettingsDialog) { + SharedAppThemeSettingsDialog( + appThemeMode = state.appThemeMode, + appContrastOption = state.appContrastOption, + appTextDimFactorLight = state.appTextDimFactorLight, + appTextDimFactorDark = state.appTextDimFactorDark, + appSeedColor = state.appSeedColor, + customAppThemes = state.customAppThemes, + onThemeModeChanged = { mode -> updateState(state.reduce(AppAction.AppThemeChanged(mode))) }, + onContrastOptionChanged = { option -> updateState(state.reduce(AppAction.AppContrastChanged(option))) }, + onTextDimFactorLightChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorLightChanged(factor))) }, + onTextDimFactorDarkChanged = { factor -> updateState(state.reduce(AppAction.AppTextDimFactorDarkChanged(factor))) }, + onSeedColorChanged = { color -> updateState(state.reduce(AppAction.AppSeedColorChanged(color))) }, + onCustomThemeAdded = { theme -> updateState(state.reduce(AppAction.CustomAppThemeAdded(theme))) }, + onCustomThemeDeleted = { themeId -> updateState(state.reduce(AppAction.CustomAppThemeDeleted(themeId))) }, + onDismiss = { showDesktopAppThemeSettingsDialog = false } + ) + } + + if (showDesktopLanguageDialog) { + DesktopLanguageDialog( + selectedLanguageTag = desktopLanguageTag, + onLanguageSelected = { languageTag -> + desktopLanguageTag = languageTag + desktopLanguageSettingsStore.save(DesktopLanguageSettings(languageTag)) + }, + onDismiss = { showDesktopLanguageDialog = false } + ) + } + + if (showClearBookCacheDialog) { + SharedConfirmDialog( + title = readerString("options_clear_book_cache", "Clear book cache"), + body = readerString( + "desktop_clear_book_cache_desc", + "Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened." + ), + confirmLabel = readerString("action_clear", "Clear"), + onDismiss = { showClearBookCacheDialog = false }, + onConfirm = { + clearDesktopBookCache() + showClearBookCacheDialog = false + } + ) + } + + if (showCreateShelfDialog) { + SharedTextInputDialog( + title = readerString("create_new_shelf", "Create shelf"), + label = readerString("shelf_name_hint", "Shelf name"), + initialValue = "", + confirmLabel = readerString("action_create", "Create"), + onDismiss = { + showCreateShelfDialog = false + createShelfBookIds = emptySet() + createShelfClearsSelection = false + }, + onConfirm = { name -> + if (createShelfBookIds.isEmpty()) { + createShelf(name) + } else { + createShelfWithBooks(name, createShelfBookIds, clearSelection = createShelfClearsSelection) + } + showCreateShelfDialog = false + createShelfBookIds = emptySet() + createShelfClearsSelection = false + } + ) + } + + if (showCreateSmartShelfDialog) { + SmartShelfDialog( + onDismiss = { showCreateSmartShelfDialog = false }, + onConfirm = { name, definition -> + createSmartShelf(name, definition) + showCreateSmartShelfDialog = false + } + ) + } + + shelfToRename?.let { shelf -> + SharedTextInputDialog( + title = readerString("dialog_rename_shelf", "Rename shelf"), + label = readerString("shelf_name_hint", "Shelf name"), + initialValue = shelf.name, + confirmLabel = readerString("action_rename", "Rename"), + onDismiss = { shelfToRename = null }, + onConfirm = { name -> + renameShelf(shelf, name) + shelfToRename = null + } + ) + } + + shelfToDelete?.let { shelf -> + SharedConfirmDialog( + title = readerString("menu_delete_shelf", "Delete shelf"), + body = readerString("desktop_delete_shelf_desc", "Delete \"%1\$s\"? Books stay in your library.", shelf.name), + confirmLabel = readerString("action_delete", "Delete"), + onDismiss = { shelfToDelete = null }, + onConfirm = { + deleteShelf(shelf) + shelfToDelete = null + } + ) + } + + folderToRemove?.let { folder -> + SharedConfirmDialog( + title = readerString("menu_remove_folder", "Remove folder"), + body = desktopQuantityString( + "desktop_remove_folder_desc_with_book_count", + folder.bookCount, + "Remove \"%1\$s\" and its %2\$d book from the app? Files on disk will not be deleted.", + "Remove \"%1\$s\" and its %2\$d books from the app? Files on disk will not be deleted.", + folder.name, + folder.bookCount + ), + confirmLabel = readerString("action_remove", "Remove"), + onDismiss = { folderToRemove = null }, + onConfirm = { + removeFolder(folder) + folderToRemove = null + } + ) + } + + if (addToShelfBookIds.isNotEmpty()) { + SharedAddToShelfDialog( + shelves = state.shelves.filter { it.type == ShelfType.MANUAL && it.id != "unshelved" }, + onDismiss = { + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false + }, + onCreateShelf = { + createShelfBookIds = addToShelfBookIds + createShelfClearsSelection = addToShelfClearsSelection + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false + showCreateShelfDialog = true + }, + onShelvesSelected = { shelfIds -> + addBooksToShelves(addToShelfBookIds, shelfIds, clearSelection = addToShelfClearsSelection) + addToShelfBookIds = emptySet() + addToShelfClearsSelection = false + } + ) + } + + shelfToManageBooks?.let { shelf -> + SharedManageShelfBooksDialog( + shelf = shelf, + books = state.rawLibraryBooks, + onDismiss = { shelfToManageBooks = null }, + onSave = { bookIds -> + replaceShelfBooks(shelf, bookIds) + shelfToManageBooks = null + } + ) + } + + if (showTagSelectionDialog) { + SharedTextInputDialog( + title = readerString("desktop_tag_selected_books", "Tag selected books"), + label = readerString("desktop_tag_name", "Tag name"), + initialValue = state.allTags.firstOrNull()?.name.orEmpty(), + confirmLabel = readerString("action_apply", "Apply"), + onDismiss = { showTagSelectionDialog = false }, + onConfirm = { name -> + tagSelectedBooks(name) + showTagSelectionDialog = false + } + ) + } + + bookInfoDialogFor?.let { book -> + val canEditEmbeddedMetadata = book.type == FileType.EPUB && + book.path?.let { File(it).isFile && File(it).canWrite() } == true + val canRenameDisplayName = book.type != FileType.EPUB + SharedBookInfoDialog( + book = book, + knownTags = state.allTags, + initiallyEditing = bookInfoInitiallyEditing && (canEditEmbeddedMetadata || canRenameDisplayName), + canEditEmbeddedMetadata = canEditEmbeddedMetadata, + canRenameDisplayName = canRenameDisplayName, + canRestoreEmbeddedMetadata = canEditEmbeddedMetadata, + onDismiss = { + bookInfoInitiallyEditing = false + bookInfoDialogFor = null + }, + onSave = { updated -> + updateBookMetadata(updated) + bookInfoInitiallyEditing = false + bookInfoDialogFor = null + }, + onRestore = { restored -> + updateBookMetadata(restored) + bookInfoInitiallyEditing = false + bookInfoDialogFor = null + } + ) + } + } + } +} + +@Composable +private fun DesktopFeatureNoticeDialog( + notice: DesktopFeatureNotice, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(readerString(notice.titleKey, notice.titleFallback)) }, + text = { Text(readerString(notice.messageKey, notice.messageFallback)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(readerString(notice.confirmKey, notice.confirmFallback)) + } + }, + dismissButton = if (notice.action != null) { + { + TextButton(onClick = onDismiss) { + Text(readerString("action_not_now", "Not now")) + } + } + } else { + null + } + ) +} + +private fun desktopSignInRequiredNotice( + messageKey: String, + messageFallback: String +): DesktopFeatureNotice { + return DesktopFeatureNotice( + titleKey = "sign_in_required", + titleFallback = "Sign in required", + messageKey = messageKey, + messageFallback = messageFallback, + confirmKey = "drawer_sign_in", + confirmFallback = "Sign in", + action = DesktopFeatureNoticeAction.SIGN_IN + ) +} + +private fun desktopOutOfCreditsNotice( + messageKey: String, + messageFallback: String +): DesktopFeatureNotice { + return DesktopFeatureNotice( + titleKey = "dialog_out_of_credits_title", + titleFallback = "Out of credits", + messageKey = messageKey, + messageFallback = messageFallback, + confirmKey = "desktop_view_pro_and_credits", + confirmFallback = "View account & credits", + action = DesktopFeatureNoticeAction.OPEN_PRO + ) +} + +private fun desktopProRequiredNotice( + messageKey: String, + messageFallback: String +): DesktopFeatureNotice { + return DesktopFeatureNotice( + titleKey = "desktop_pro_required", + titleFallback = "Pro required", + messageKey = messageKey, + messageFallback = messageFallback, + confirmKey = "desktop_view_pro_and_credits", + confirmFallback = "View account & credits", + action = DesktopFeatureNoticeAction.OPEN_PRO + ) +} + +private fun desktopFeatureUnavailableNotice( + messageKey: String, + messageFallback: String +): DesktopFeatureNotice { + return DesktopFeatureNotice( + titleKey = "desktop_feature_unavailable", + titleFallback = "Feature unavailable", + messageKey = messageKey, + messageFallback = messageFallback + ) +} + +private fun desktopFeatureNoticeForError(errorMessage: String?): DesktopFeatureNotice? { + val message = errorMessage?.trim().orEmpty() + if (message.isBlank()) return null + return when { + message.contains("INSUFFICIENT_CREDITS", ignoreCase = true) || + message.contains("Out of credits", ignoreCase = true) || + message.contains("HTTP 402", ignoreCase = true) || + message.contains("status code 402", ignoreCase = true) || + message.contains("SUMMARY_LIMIT", ignoreCase = true) || + (message.contains("free summar", ignoreCase = true) && message.contains("limit", ignoreCase = true)) || + message.contains("needs credits", ignoreCase = true) || + message.contains("This action needs credits", ignoreCase = true) -> + desktopOutOfCreditsNotice( + messageKey = "desktop_out_of_credits_generic_feature_desc", + messageFallback = "Using this feature needs credits on desktop. Pro and credits can only be purchased from the Android app." + ) + + message.contains("Sign in", ignoreCase = true) || + message.contains("HTTP 401", ignoreCase = true) || + message.contains("status code 401", ignoreCase = true) || + message.contains("Authentication required", ignoreCase = true) -> + desktopSignInRequiredNotice( + messageKey = "desktop_sign_in_required_generic_feature_desc", + messageFallback = "Sign in with Google to use this feature on desktop." + ) + + message.contains("requires Pro", ignoreCase = true) || + message.contains("REQUIRES_PRO", ignoreCase = true) -> + desktopProRequiredNotice( + messageKey = "desktop_pro_required_generic_feature_desc", + messageFallback = "This feature requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in." + ) + + else -> null + } +} + +private fun desktopReaderWordCount(text: String): Int { + return text.trim().split(Regex("\\s+")).count { it.isNotBlank() } +} + +private fun ReaderImageReference.desktopImageBytes(): ByteArray { + val trimmedSource = source.trim() + if (trimmedSource.startsWith("data:", ignoreCase = true)) { + val commaIndex = trimmedSource.indexOf(',') + require(commaIndex > 0 && trimmedSource.substring(0, commaIndex).contains(";base64", ignoreCase = true)) { + "This image data could not be decoded." + } + return Base64.getMimeDecoder().decode(trimmedSource.substring(commaIndex + 1)) + } + + val file = runCatching { + if (trimmedSource.startsWith("file:", ignoreCase = true)) { + File(URI(trimmedSource)) + } else { + File(trimmedSource) + } + }.getOrElse { + File(trimmedSource) + } + require(file.isFile) { "Could not find the source image file." } + return file.readBytes() +} diff --git a/desktopApp/src/desktopMain/resources/episteme.ico b/desktopApp/src/desktopMain/resources/episteme.ico new file mode 100644 index 0000000..a786429 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/episteme.ico differ diff --git a/desktopApp/src/desktopMain/resources/episteme_icon.png b/desktopApp/src/desktopMain/resources/episteme_icon.png new file mode 100644 index 0000000..2063689 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/episteme_icon.png differ diff --git a/desktopApp/src/desktopMain/resources/google_fonts.json b/desktopApp/src/desktopMain/resources/google_fonts.json new file mode 100644 index 0000000..52a81bc --- /dev/null +++ b/desktopApp/src/desktopMain/resources/google_fonts.json @@ -0,0 +1,2083 @@ +[ + "42dot Sans", + "ABeeZee", + "ADLaM Display", + "AR One Sans", + "Abel", + "Abhaya Libre", + "Aboreto", + "Abril Fatface", + "Abyssinica SIL", + "Aclonica", + "Acme", + "Actor", + "Adamina", + "Advent Pro", + "Adwaita Mono", + "Adwaita Sans", + "Afacad", + "Afacad Flux", + "Agbalumo", + "Agdasima", + "Agu Display", + "Aguafina Script", + "Aileron", + "Akatab", + "Akaya Kanadaka", + "Akaya Telivigala", + "Akronim", + "Akshar", + "Aladin", + "Alan Sans", + "Alata", + "Alatsi", + "Albert Sans", + "Aldrich", + "Alef", + "Alegreya", + "Alegreya SC", + "Alegreya Sans", + "Alegreya Sans SC", + "Aleo", + "Alex Brush", + "Alexandria", + "Alfa Slab One", + "Alice", + "Alike", + "Alike Angular", + "Alkalami", + "Alkatra", + "Allan", + "Allerta", + "Allerta Stencil", + "Allison", + "Allkin", + "Allura", + "Almarai", + "Almendra", + "Almendra Display", + "Almendra SC", + "Alumni Sans", + "Alumni Sans Collegiate One", + "Alumni Sans Inline One", + "Alumni Sans Pinstripe", + "Alumni Sans SC", + "Alyamama", + "Amarante", + "Amaranth", + "Amarna", + "Amatic SC", + "Amethysta", + "Amiko", + "Amiri", + "Amiri Quran", + "Amita", + "Anaheim", + "Ancizar Sans", + "Ancizar Serif", + "Andada Pro", + "Andika", + "Anek Bangla", + "Anek Devanagari", + "Anek Gujarati", + "Anek Gurmukhi", + "Anek Kannada", + "Anek Latin", + "Anek Malayalam", + "Anek Odia", + "Anek Tamil", + "Anek Telugu", + "Angkor", + "Annapurna SIL", + "Annie Use Your Telescope", + "Anonymous Pro", + "Anta", + "Antic", + "Antic Didone", + "Antic Slab", + "Anton", + "Anton SC", + "Antonio", + "Anuphan", + "Anybody", + "Aoboshi One", + "Apfel Grotezk", + "Arapey", + "Arbutus", + "Arbutus Slab", + "Architects Daughter", + "Archivo", + "Archivo Black", + "Archivo Narrow", + "Are You Serious", + "Aref Ruqaa", + "Aref Ruqaa Ink", + "Argentum Sans", + "Arima", + "Arima Madurai", + "Arimo", + "Arizonia", + "Armata", + "Arsenal", + "Arsenal SC", + "Artifika", + "Arvo", + "Arya", + "Asap", + "Asap Condensed", + "Asar", + "Asimovian", + "Asset", + "Assistant", + "Asta Sans", + "Astloch", + "Asul", + "Athiti", + "Atkinson Hyperlegible", + "Atkinson Hyperlegible Mono", + "Atkinson Hyperlegible Next", + "Atma", + "Atomic Age", + "Aubrey", + "Audiowide", + "Autour One", + "Average", + "Average Sans", + "Averia Gruesa Libre", + "Averia Libre", + "Averia Sans Libre", + "Averia Serif Libre", + "Azeret Mono", + "B612", + "B612 Mono", + "BBH Bartle", + "BBH Bogle", + "BBH Hegarty", + "BBH Sans Bartle", + "BBH Sans Bogle", + "BBH Sans Hegarty", + "BIZ UDGothic", + "BIZ UDMincho", + "BIZ UDPGothic", + "BIZ UDPMincho", + "BJ Cree", + "BJCree", + "Babylonica", + "Bacasime Antique", + "Bad Script", + "Badeen Display", + "Bagel Fat One", + "Bagnard", + "Bagnard Sans", + "Bahiana", + "Bahianita", + "Bai Jamjuree", + "Bakbak One", + "Ballet", + "Baloo 2", + "Baloo Bhai 2", + "Baloo Bhaijaan 2", + "Baloo Bhaina 2", + "Baloo Chettan 2", + "Baloo Da 2", + "Baloo Paaji 2", + "Baloo Tamma 2", + "Baloo Tammudu 2", + "Baloo Thambi 2", + "Balsamiq Sans", + "Balthazar", + "Bangers", + "Barlow", + "Barlow Condensed", + "Barlow Semi Condensed", + "Barriecito", + "Barrio", + "Basic", + "Baskervville", + "Baskervville SC", + "Battambang", + "Baumans", + "Bayon", + "Be Vietnam Pro", + "Beau Rivage", + "Bebas Neue", + "Beiruti", + "Belanosima", + "Belgrano", + "Bellefair", + "Belleza", + "Bellota", + "Bellota Text", + "BenchNine", + "Benne", + "Bentham", + "Berkshire Swash", + "Besley", + "Betania Patmos", + "Betania Patmos GDL", + "Betania Patmos In", + "Betania Patmos In GDL", + "Beth Ellen", + "Bevan", + "BhuTuka Expanded One", + "Big Shoulders", + "Big Shoulders Display", + "Big Shoulders Inline", + "Big Shoulders Inline Display", + "Big Shoulders Inline Text", + "Big Shoulders Stencil", + "Big Shoulders Stencil Display", + "Big Shoulders Stencil Text", + "Big Shoulders Text", + "Bigelow Rules", + "Bigshot One", + "Bilbo", + "Bilbo Swash Caps", + "BioRhyme", + "BioRhyme Expanded", + "Birthstone", + "Birthstone Bounce", + "Biryani", + "Bitcount", + "Bitcount Grid Double", + "Bitcount Grid Double Ink", + "Bitcount Grid Single", + "Bitcount Grid Single Ink", + "Bitcount Ink", + "Bitcount Prop Double", + "Bitcount Prop Double Ink", + "Bitcount Prop Single", + "Bitcount Prop Single Ink", + "Bitcount Single", + "Bitcount Single Ink", + "Bitter", + "Black And White Picture", + "Black Han Sans", + "Black Ops One", + "Blackout Midnight", + "Blackout Sunrise", + "Blackout Two AM", + "Blaka", + "Blaka Hollow", + "Blaka Ink", + "Blinker", + "Bluu Next", + "Bodoni Moda", + "Bodoni Moda SC", + "Bokor", + "Boldonse", + "Bona Nova", + "Bona Nova SC", + "Bonbon", + "Bonheur Royale", + "Boogaloo", + "Borel", + "Bowlby One", + "Bowlby One SC", + "Bpmf Huninn", + "Bpmf Iansui", + "Bpmf Zihi Kai Std", + "Braah One", + "Bravura", + "Bravura Text", + "Brawler", + "Bree Serif", + "Bricolage Grotesque", + "Briem Hand", + "Bruno Ace", + "Bruno Ace SC", + "Brygada 1918", + "Bubblegum Sans", + "Bubbler One", + "Buda", + "Buenard", + "Bungee", + "Bungee Hairline", + "Bungee Inline", + "Bungee Outline", + "Bungee Shade", + "Bungee Spice", + "Bungee Tint", + "Butcherman", + "Butterfly Kids", + "Bytesized", + "Cabin", + "Cabin Condensed", + "Cabin Sketch", + "Cactus Classical Serif", + "Caesar Dressing", + "Cagliostro", + "Cairo", + "Cairo Play", + "Cal Sans", + "Caladea", + "Calistoga", + "Calligraffitti", + "Cambay", + "Cambo", + "Candal", + "Cantarell", + "Cantata One", + "Cantora One", + "Caprasimo", + "Capriola", + "Caramel", + "Carattere", + "Cardo", + "Carlito", + "Carme", + "Carrois Gothic", + "Carrois Gothic SC", + "Carter One", + "Cascadia Code", + "Cascadia Mono", + "Castoro", + "Castoro Titling", + "Catamaran", + "Caudex", + "Cause", + "Caveat", + "Caveat Brush", + "Cedarville Cursive", + "Ceviche One", + "Chakra Petch", + "Changa", + "Changa One", + "Chango", + "Charis SIL", + "Charm", + "Charmonman", + "Chathura", + "Chau Philomene One", + "Chela One", + "Chelsea Market", + "Chenla", + "Cherish", + "Cherry Bomb One", + "Cherry Cream Soda", + "Cherry Swash", + "Chewy", + "Chicle", + "Chilanka", + "Chiron GoRound TC", + "Chiron Hei HK", + "Chiron Sung HK", + "Chivo", + "Chivo Mono", + "Chocolate Classical Sans", + "Chokokutai", + "Chonburi", + "Chunk Five", + "Cinzel", + "Cinzel Decorative", + "Clear Sans", + "Clicker Script", + "Climate Crisis", + "Coda", + "Coda Caption", + "Codystar", + "Coiny", + "Combo", + "Comfortaa", + "Comforter", + "Comforter Brush", + "Comic Mono", + "Comic Neue", + "Comic Relief", + "Coming Soon", + "Comme", + "Commissioner", + "Commit Mono", + "Concert One", + "Condiment", + "Content", + "Contrail One", + "Convergence", + "Cookie", + "Cooper Hewitt", + "Copse", + "Coral Pixels", + "Corben", + "Corinthia", + "Cormorant", + "Cormorant Garamond", + "Cormorant Infant", + "Cormorant SC", + "Cormorant Unicase", + "Cormorant Upright", + "Cossette Texte", + "Cossette Titre", + "Courgette", + "Courier Prime", + "Cousine", + "Coustard", + "Covered By Your Grace", + "Crafty Girls", + "Creepster", + "Crete Round", + "Crimson Pro", + "Crimson Text", + "Croissant One", + "Crushed", + "Cuprum", + "Cute Font", + "Cutive", + "Cutive Mono", + "DM Mono", + "DM Sans", + "DM Serif Display", + "DM Serif Text", + "DSEG Weather", + "DSEG14 Classic", + "DSEG14 Classic Mini", + "DSEG14 Modern", + "DSEG14 Modern Mini", + "DSEG7 Classic", + "DSEG7 Classic Mini", + "DSEG7 Modern", + "DSEG7 Modern Mini", + "DSEG7 SEGG CHAN", + "DSEG7 SEGG CHAN Mini", + "Dai Banna SIL", + "Damion", + "Dancing Script", + "Danfo", + "Dangrek", + "Darker Grotesque", + "Darumadrop One", + "Datatype", + "David Libre", + "Dawning of a New Day", + "Days One", + "DejaVu Math", + "DejaVu Mono", + "DejaVu Sans", + "DejaVu Serif", + "Dekko", + "Dela Gothic One", + "Delicious Handrawn", + "Delius", + "Delius Swash Caps", + "Delius Unicase", + "Della Respira", + "Denk One", + "Devonshire", + "Dhurjati", + "Didact Gothic", + "Diphylleia", + "Diplomata", + "Diplomata SC", + "Do Hyeon", + "Dokdo", + "Domine", + "Donegal One", + "Dongle", + "Doppio One", + "Dorsa", + "Dosis", + "DotGothic16", + "Doto", + "Dr Sugiyama", + "Duru Sans", + "DynaPuff", + "Dynalight", + "EB Garamond", + "Eagle Lake", + "East Sea Dokdo", + "Eater", + "Economica", + "Eczar", + "Edu AU VIC WA NT Arrows", + "Edu AU VIC WA NT Dots", + "Edu AU VIC WA NT Guides", + "Edu AU VIC WA NT Hand", + "Edu AU VIC WA NT Pre", + "Edu NSW ACT Cursive", + "Edu NSW ACT Foundation", + "Edu NSW ACT Hand Pre", + "Edu QLD Beginner", + "Edu QLD Hand", + "Edu SA Beginner", + "Edu SA Hand", + "Edu TAS Beginner", + "Edu VIC WA NT Beginner", + "Edu VIC WA NT Hand", + "Edu VIC WA NT Hand Pre", + "El Messiri", + "Electrolize", + "Elms Sans", + "Elsie", + "Elsie Swash Caps", + "Emblema One", + "Emilys Candy", + "Encode Sans", + "Encode Sans Condensed", + "Encode Sans Expanded", + "Encode Sans SC", + "Encode Sans Semi Condensed", + "Encode Sans Semi Expanded", + "Engagement", + "Englebert", + "Enriqueta", + "Ephesis", + "Epilogue", + "Epunda Sans", + "Epunda Slab", + "Erica One", + "Esteban", + "Estonia", + "Euphoria Script", + "Ewert", + "Exile", + "Exo", + "Exo 2", + "Expletus Sans", + "Explora", + "Faculty Glyphic", + "Fahkwang", + "Familjen Grotesk", + "Fanwood Text", + "Farro", + "Farsan", + "Fascinate", + "Fascinate Inline", + "Faster One", + "Fasthand", + "Fauna One", + "Faustina", + "Federant", + "Federo", + "Felipa", + "Fenix", + "Festive", + "Figtree", + "Finger Paint", + "Finlandica", + "Fira Code", + "Fira Mono", + "Fira Sans", + "Fira Sans Condensed", + "Fira Sans Extra Condensed", + "FiraGO", + "Fjalla One", + "Fjord One", + "Flamenco", + "Flavors", + "Fleur De Leah", + "Flow Block", + "Flow Circular", + "Flow Rounded", + "Foldit", + "Fondamento", + "Fontdiner Swanky", + "Forum", + "Fragment Mono", + "Francois One", + "Frank Ruhl Libre", + "Fraunces", + "Freckle Face", + "Fredericka the Great", + "Fredoka", + "Fredoka One", + "Freehand", + "Freeman", + "Fresca", + "Frijole", + "Fruktur", + "Fugaz One", + "Fuggles", + "Funnel Display", + "Funnel Sans", + "Fusion Kai G", + "Fusion Kai J", + "Fusion Kai T", + "Fusion Pixel 10px Monospaced JP", + "Fusion Pixel 10px Monospaced KR", + "Fusion Pixel 10px Monospaced SC", + "Fusion Pixel 10px Monospaced TC", + "Fusion Pixel 10px Proportional JP", + "Fusion Pixel 10px Proportional KR", + "Fusion Pixel 10px Proportional SC", + "Fusion Pixel 10px Proportional TC", + "Fusion Pixel 12px Monospaced JP", + "Fusion Pixel 12px Monospaced KR", + "Fusion Pixel 12px Monospaced SC", + "Fusion Pixel 12px Monospaced TC", + "Fusion Pixel 12px Proportional JP", + "Fusion Pixel 12px Proportional KR", + "Fusion Pixel 12px Proportional SC", + "Fusion Pixel 12px Proportional TC", + "Fusion Pixel 8px Monospaced JP", + "Fusion Pixel 8px Monospaced KR", + "Fusion Pixel 8px Monospaced SC", + "Fusion Pixel 8px Monospaced TC", + "Fusion Pixel 8px Proportional JP", + "Fusion Pixel 8px Proportional KR", + "Fusion Pixel 8px Proportional SC", + "Fusion Pixel 8px Proportional TC", + "Fustat", + "Fuzzy Bubbles", + "GFS Didot", + "GFS Neohellenic", + "Ga Maamli", + "Gabarito", + "Gabriela", + "Gaegu", + "Gafata", + "Gajraj One", + "Galada", + "Galdeano", + "Galindo", + "Gamja Flower", + "Gantari", + "Gasoek One", + "Gayathri", + "Geist", + "Geist Mono", + "Geist Sans", + "Gelasio", + "Gemunu Libre", + "Genjyuu Gothic", + "Genos", + "Gentium Book Basic", + "Gentium Book Plus", + "Gentium Plus", + "Geo", + "Geologica", + "Geom", + "Georama", + "Geostar", + "Geostar Fill", + "Germania One", + "Gideon Roman", + "Gidole", + "Gidugu", + "Gilda Display", + "Girassol", + "Give You Glory", + "Glass Antiqua", + "Glegoo", + "Gloock", + "Gloria Hallelujah", + "Glory", + "Gluten", + "Goblin One", + "Gochi Hand", + "Goldman", + "Golos Text", + "Google Sans", + "Google Sans Code", + "Google Sans Flex", + "Gorditas", + "Gothic A1", + "Gotu", + "Goudy Bookletter 1911", + "Gowun Batang", + "Gowun Dodum", + "Graduate", + "Grand Hotel", + "Grandiflora One", + "Grandstander", + "Grape Nuts", + "Gravitas One", + "Great Vibes", + "Grechen Fuemen", + "Grenze", + "Grenze Gotisch", + "Grey Qo", + "Griffy", + "Gruppo", + "Gudea", + "Gugi", + "Gulzar", + "Gupter", + "Gurajada", + "Gveret Levin", + "Gwendolyn", + "Habibi", + "Hachi Maru Pop", + "Hahmlet", + "Halant", + "Hammersmith One", + "Hanalei", + "Hanalei Fill", + "Handjet", + "Handlee", + "Hanken Grotesk", + "Hanuman", + "Happy Monkey", + "Harmattan", + "Hauora Sans", + "Headland One", + "Hedvig Letters Sans", + "Hedvig Letters Serif", + "Heebo", + "Henny Penny", + "Hepta Slab", + "Herr Von Muellerhoff", + "Hi Melody", + "Hina Mincho", + "Hind", + "Hind Guntur", + "Hind Madurai", + "Hind Mysuru", + "Hind Siliguri", + "Hind Vadodara", + "Holtwood One SC", + "Homemade Apple", + "Homenaje", + "Honk", + "Host Grotesk", + "Hubballi", + "Hubot Sans", + "Huninn", + "Hurricane", + "IBM Plex Mono", + "IBM Plex Sans", + "IBM Plex Sans Arabic", + "IBM Plex Sans Condensed", + "IBM Plex Sans Devanagari", + "IBM Plex Sans Hebrew", + "IBM Plex Sans JP", + "IBM Plex Sans KR", + "IBM Plex Sans Thai", + "IBM Plex Sans Thai Looped", + "IBM Plex Serif", + "IM Fell DW Pica", + "IM Fell DW Pica SC", + "IM Fell Double Pica", + "IM Fell Double Pica SC", + "IM Fell English", + "IM Fell English SC", + "IM Fell French Canon", + "IM Fell French Canon SC", + "IM Fell Great Primer", + "IM Fell Great Primer SC", + "Iansui", + "Ibarra Real Nova", + "Iceberg", + "Iceland", + "Idiqlat", + "Imbue", + "Imperial Script", + "Imprima", + "Inclusive Sans", + "Inconsolata", + "Inder", + "Indie Flower", + "Ingrid Darling", + "Inika", + "Inknut Antiqua", + "Inria Sans", + "Inria Serif", + "Inspiration", + "Instrument Sans", + "Instrument Serif", + "Intel One Mono", + "Inter", + "Inter Tight", + "Iosevka", + "Iosevka Aile", + "Iosevka Charon", + "Iosevka Charon Mono", + "Iosevka Curly", + "Iosevka Curly Slab", + "Iosevka Etoile", + "Irish Grover", + "Island Moments", + "Istok Web", + "Italiana", + "Italianno", + "Itim", + "Jacquard 12", + "Jacquard 12 Charted", + "Jacquard 24", + "Jacquard 24 Charted", + "Jacquarda Bastarda 9", + "Jacquarda Bastarda 9 Charted", + "Jacques Francois", + "Jacques Francois Shadow", + "Jaini", + "Jaini Purva", + "Jaldi", + "Jaro", + "Jersey 10", + "Jersey 10 Charted", + "Jersey 15", + "Jersey 15 Charted", + "Jersey 20", + "Jersey 20 Charted", + "Jersey 25", + "Jersey 25 Charted", + "JetBrains Mono", + "Jim Nightshade", + "Joan", + "Jockey One", + "Jolly Lodger", + "Jomhuria", + "Jomolhari", + "Josefin Sans", + "Josefin Slab", + "Jost", + "Joti One", + "Jua", + "Judson", + "Julee", + "Julius Sans One", + "Junction", + "Junge", + "Jura", + "Just Another Hand", + "Just Me Again Down Here", + "K2D", + "Kablammo", + "Kadwa", + "Kaisei Decol", + "Kaisei HarunoUmi", + "Kaisei Opti", + "Kaisei Tokumin", + "Kalam", + "Kalnia", + "Kalnia Glaze", + "Kameron", + "Kanchenjunga", + "Kanit", + "Kantumruy", + "Kantumruy Pro", + "Kapakana", + "Karantina", + "Karla", + "Karla Tamil Inclined", + "Karla Tamil Upright", + "Karma", + "Karmilla", + "Katibeh", + "Kaushan Script", + "Kavivanar", + "Kavoon", + "Kay Pho Du", + "Kdam Thmor Pro", + "Keania One", + "Kedebideri", + "Kelly Slab", + "Kenia", + "Khand", + "Khmer", + "Khula", + "Kings", + "Kirang Haerang", + "Kite One", + "Kiwi Maru", + "Klee One", + "Knewave", + "KoHo", + "Kodchasan", + "Kode Mono", + "Koh Santepheap", + "Kolker Brush", + "Konkhmer Sleokchher", + "Kosugi", + "Kosugi Maru", + "Kotta One", + "Koulen", + "Kranky", + "Kreon", + "Kristi", + "Krona One", + "Krub", + "Kufam", + "Kulim Park", + "Kumar One", + "Kumar One Outline", + "Kumbh Sans", + "Kurale", + "LINE Seed JP", + "LXGW Marker Gothic", + "LXGW WenKai", + "LXGW WenKai Mono TC", + "LXGW WenKai TC", + "La Belle Aurore", + "Labrada", + "Lacquer", + "Laila", + "Lakki Reddy", + "Lalezar", + "Lancelot", + "Langar", + "Lateef", + "Lato", + "Lavishly Yours", + "League Gothic", + "League Mono", + "League Script", + "League Spartan", + "Leckerli One", + "Ledger", + "Lekton", + "Lemon", + "Lemonada", + "Lexend", + "Lexend Deca", + "Lexend Exa", + "Lexend Giga", + "Lexend Mega", + "Lexend Peta", + "Lexend Tera", + "Lexend Zetta", + "Lextrall", + "Libertinus Keyboard", + "Libertinus Math", + "Libertinus Mono", + "Libertinus Sans", + "Libertinus Serif", + "Libertinus Serif Display", + "Libre Barcode 128", + "Libre Barcode 128 Text", + "Libre Barcode 39", + "Libre Barcode 39 Extended", + "Libre Barcode 39 Extended Text", + "Libre Barcode 39 Text", + "Libre Barcode EAN13 Text", + "Libre Baskerville", + "Libre Bodoni", + "Libre Caslon Condensed", + "Libre Caslon Display", + "Libre Caslon Text", + "Libre Franklin", + "Licorice", + "Life Savers", + "Lilex", + "Lilita One", + "Lily Script One", + "Limelight", + "Linden Hill", + "Linefont", + "Lisu Bosa", + "Liter", + "Literata", + "Liu Jian Mao Cao", + "Livvic", + "Lobster", + "Lobster Two", + "Londrina Outline", + "Londrina Shadow", + "Londrina Sketch", + "Londrina Solid", + "Long Cang", + "Lora", + "Love Light", + "Love Ya Like A Sister", + "Loved by the King", + "Lovers Quarrel", + "Luckiest Guy", + "Lugrasimo", + "Lumanosimo", + "Lunasima", + "Lusitana", + "Lustria", + "Luxurious Roman", + "Luxurious Script", + "M PLUS 1", + "M PLUS 1 Code", + "M PLUS 1p", + "M PLUS 2", + "M PLUS Code Latin", + "M PLUS Rounded 1c", + "Ma Shan Zheng", + "Macondo", + "Macondo Swash Caps", + "Mada", + "Madimi One", + "Magra", + "Maiden Orange", + "Maitree", + "Major Mono Display", + "Mako", + "Mali", + "Mallanna", + "Maname", + "Mandali", + "Manjari", + "Manrope", + "Mansalva", + "Manuale", + "Manufacturing Consent", + "Maple Mono", + "Marcellus", + "Marcellus SC", + "Marck Script", + "Margarine", + "Marhey", + "Markazi Text", + "Marko One", + "Marmelad", + "Martel", + "Martel Sans", + "Martian Mono", + "Marvel", + "Matangi", + "Mate", + "Mate SC", + "Matemasie", + "Material Icons", + "Material Icons Outlined", + "Material Icons Round", + "Material Icons Sharp", + "Material Icons Two Tone", + "Material Symbols", + "Material Symbols Outlined", + "Material Symbols Rounded", + "Material Symbols Sharp", + "Maven Pro", + "McLaren", + "Mea Culpa", + "Meddon", + "MedievalSharp", + "Medula One", + "Meera Inimai", + "Megrim", + "Meie Script", + "Menbere", + "Meow Script", + "Merienda", + "Merienda One", + "Merriweather", + "Merriweather Sans", + "Metal", + "Metal Mania", + "Metamorphous", + "Metrophobic", + "Metropolis", + "Michroma", + "Micro 5", + "Micro 5 Charted", + "Milonga", + "Miltonian", + "Miltonian Tattoo", + "Mina", + "Mingzat", + "Miniver", + "Miranda Sans", + "Miriam Libre", + "Mirza", + "Miss Fajardose", + "Mitr", + "Mochiy Pop One", + "Mochiy Pop P One", + "Modak", + "Modern Antiqua", + "Moderustic", + "Mogra", + "Mohave", + "Moirai One", + "Molengo", + "Molle", + "Momo Signature", + "Momo Trust Display", + "Momo Trust Sans", + "Mona Sans", + "Monaspace Argon", + "Monaspace Krypton", + "Monaspace Neon", + "Monaspace Radon", + "Monaspace Xenon", + "Monda", + "Monofett", + "Monomakh", + "Monomaniac One", + "Mononoki", + "Monoton", + "Monsieur La Doulaise", + "Montaga", + "Montagu Slab", + "MonteCarlo", + "Montez", + "Montserrat", + "Montserrat Alternates", + "Montserrat Subrayada", + "Montserrat Underline", + "Moo Lah Lah", + "Mooli", + "Moon Dance", + "Moul", + "Moulpali", + "Mountains of Christmas", + "Mouse Memoirs", + "Mozilla Headline", + "Mozilla Text", + "Mr Bedfort", + "Mr Dafoe", + "Mr De Haviland", + "Mrs Saint Delafield", + "Mrs Sheppards", + "Ms Madi", + "Mukta", + "Mukta Mahee", + "Mukta Malar", + "Mukta Vaani", + "Mulish", + "Murecho", + "MuseoModerno", + "My Soul", + "Mynerve", + "Mystery Quest", + "NTR", + "Nabla", + "Namdhinggo", + "Nanum Brush Script", + "Nanum Gothic", + "Nanum Gothic Coding", + "Nanum Myeongjo", + "Nanum Pen Script", + "Narnoor", + "Nata Sans", + "National Park", + "Nebula Sans", + "Neonderthaw", + "Nerko One", + "Neucha", + "Neuton", + "New Amsterdam", + "New Rocker", + "New Tegomin", + "News Cycle", + "Newsreader", + "Niconne", + "Niramit", + "Nixie One", + "Nobile", + "Nokora", + "Norican", + "Norwester", + "Nosifer", + "Notable", + "Nothing You Could Do", + "Noticia Text", + "Noto Color Emoji", + "Noto Emoji", + "Noto Kufi Arabic", + "Noto Mono", + "Noto Music", + "Noto Naskh Arabic", + "Noto Nastaliq Urdu", + "Noto Rashi Hebrew", + "Noto Sans", + "Noto Sans Adlam", + "Noto Sans Adlam Unjoined", + "Noto Sans Anatolian Hieroglyphs", + "Noto Sans Arabic", + "Noto Sans Armenian", + "Noto Sans Avestan", + "Noto Sans Balinese", + "Noto Sans Bamum", + "Noto Sans Bassa Vah", + "Noto Sans Batak", + "Noto Sans Bengali", + "Noto Sans Bhaiksuki", + "Noto Sans Brahmi", + "Noto Sans Buginese", + "Noto Sans Buhid", + "Noto Sans Canadian Aboriginal", + "Noto Sans Carian", + "Noto Sans Caucasian Albanian", + "Noto Sans Chakma", + "Noto Sans Cham", + "Noto Sans Cherokee", + "Noto Sans Chorasmian", + "Noto Sans Coptic", + "Noto Sans Cuneiform", + "Noto Sans Cypriot", + "Noto Sans Cypro Minoan", + "Noto Sans Deseret", + "Noto Sans Devanagari", + "Noto Sans Display", + "Noto Sans Duployan", + "Noto Sans Egyptian Hieroglyphs", + "Noto Sans Elbasan", + "Noto Sans Elymaic", + "Noto Sans Ethiopic", + "Noto Sans Georgian", + "Noto Sans Glagolitic", + "Noto Sans Gothic", + "Noto Sans Grantha", + "Noto Sans Gujarati", + "Noto Sans Gunjala Gondi", + "Noto Sans Gurmukhi", + "Noto Sans HK", + "Noto Sans Hanifi Rohingya", + "Noto Sans Hanunoo", + "Noto Sans Hatran", + "Noto Sans Hebrew", + "Noto Sans Imperial Aramaic", + "Noto Sans Indic Siyaq Numbers", + "Noto Sans Inscriptional Pahlavi", + "Noto Sans Inscriptional Parthian", + "Noto Sans JP", + "Noto Sans Javanese", + "Noto Sans KR", + "Noto Sans Kaithi", + "Noto Sans Kannada", + "Noto Sans Kawi", + "Noto Sans Kayah Li", + "Noto Sans Kharoshthi", + "Noto Sans Khmer", + "Noto Sans Khojki", + "Noto Sans Khudawadi", + "Noto Sans Lao", + "Noto Sans Lao Looped", + "Noto Sans Lepcha", + "Noto Sans Limbu", + "Noto Sans Linear A", + "Noto Sans Linear B", + "Noto Sans Lisu", + "Noto Sans Lycian", + "Noto Sans Lydian", + "Noto Sans Mahajani", + "Noto Sans Malayalam", + "Noto Sans Mandaic", + "Noto Sans Manichaean", + "Noto Sans Marchen", + "Noto Sans Masaram Gondi", + "Noto Sans Math", + "Noto Sans Mayan Numerals", + "Noto Sans Medefaidrin", + "Noto Sans Meetei Mayek", + "Noto Sans Mende Kikakui", + "Noto Sans Meroitic", + "Noto Sans Miao", + "Noto Sans Modi", + "Noto Sans Mongolian", + "Noto Sans Mono", + "Noto Sans Mro", + "Noto Sans Multani", + "Noto Sans Myanmar", + "Noto Sans NKo", + "Noto Sans NKo Unjoined", + "Noto Sans Nabataean", + "Noto Sans Nag Mundari", + "Noto Sans Nandinagari", + "Noto Sans New Tai Lue", + "Noto Sans Newa", + "Noto Sans Nushu", + "Noto Sans Ogham", + "Noto Sans Ol Chiki", + "Noto Sans Old Hungarian", + "Noto Sans Old Italic", + "Noto Sans Old North Arabian", + "Noto Sans Old Permic", + "Noto Sans Old Persian", + "Noto Sans Old Sogdian", + "Noto Sans Old South Arabian", + "Noto Sans Old Turkic", + "Noto Sans Oriya", + "Noto Sans Osage", + "Noto Sans Osmanya", + "Noto Sans Pahawh Hmong", + "Noto Sans Palmyrene", + "Noto Sans Pau Cin Hau", + "Noto Sans Phags Pa", + "Noto Sans PhagsPa", + "Noto Sans Phoenician", + "Noto Sans Psalter Pahlavi", + "Noto Sans Rejang", + "Noto Sans Runic", + "Noto Sans SC", + "Noto Sans Samaritan", + "Noto Sans Saurashtra", + "Noto Sans Sharada", + "Noto Sans Shavian", + "Noto Sans Siddham", + "Noto Sans SignWriting", + "Noto Sans Sinhala", + "Noto Sans Sogdian", + "Noto Sans Sora Sompeng", + "Noto Sans Soyombo", + "Noto Sans Sundanese", + "Noto Sans Sunuwar", + "Noto Sans Syloti Nagri", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Syriac", + "Noto Sans Syriac Eastern", + "Noto Sans Syriac Western", + "Noto Sans TC", + "Noto Sans Tagalog", + "Noto Sans Tagbanwa", + "Noto Sans Tai Le", + "Noto Sans Tai Tham", + "Noto Sans Tai Viet", + "Noto Sans Takri", + "Noto Sans Tamil", + "Noto Sans Tamil Supplement", + "Noto Sans Tangsa", + "Noto Sans Telugu", + "Noto Sans Thaana", + "Noto Sans Thai", + "Noto Sans Thai Looped", + "Noto Sans Tifinagh", + "Noto Sans Tirhuta", + "Noto Sans Ugaritic", + "Noto Sans Vai", + "Noto Sans Vithkuqi", + "Noto Sans Wancho", + "Noto Sans Warang Citi", + "Noto Sans Yi", + "Noto Sans Zanabazar Square", + "Noto Serif", + "Noto Serif Ahom", + "Noto Serif Armenian", + "Noto Serif Balinese", + "Noto Serif Bengali", + "Noto Serif Devanagari", + "Noto Serif Display", + "Noto Serif Dives Akuru", + "Noto Serif Dogra", + "Noto Serif Ethiopic", + "Noto Serif Georgian", + "Noto Serif Grantha", + "Noto Serif Gujarati", + "Noto Serif Gurmukhi", + "Noto Serif HK", + "Noto Serif Hebrew", + "Noto Serif Hentaigana", + "Noto Serif JP", + "Noto Serif KR", + "Noto Serif Kannada", + "Noto Serif Khitan Small Script", + "Noto Serif Khmer", + "Noto Serif Khojki", + "Noto Serif Lao", + "Noto Serif Makasar", + "Noto Serif Malayalam", + "Noto Serif Myanmar", + "Noto Serif NP Hmong", + "Noto Serif Old Uyghur", + "Noto Serif Oriya", + "Noto Serif Ottoman Siyaq", + "Noto Serif SC", + "Noto Serif Sinhala", + "Noto Serif TC", + "Noto Serif Tamil", + "Noto Serif Tangut", + "Noto Serif Telugu", + "Noto Serif Thai", + "Noto Serif Tibetan", + "Noto Serif Todhri", + "Noto Serif Toto", + "Noto Serif Vithkuqi", + "Noto Serif Yezidi", + "Noto Traditional Nushu", + "Noto Znamenny Musical Notation", + "Nova Cut", + "Nova Flat", + "Nova Mono", + "Nova Oval", + "Nova Round", + "Nova Script", + "Nova Slim", + "Nova Square", + "Numans", + "Nunito", + "Nunito Sans", + "Nuosu SIL", + "Odibee Sans", + "Odor Mean Chey", + "Offside", + "Oi", + "Ojuju", + "Old Standard TT", + "Oldenburg", + "Ole", + "Oleo Script", + "Oleo Script Swash Caps", + "Onest", + "Oooh Baby", + "Open Runde", + "Open Sans", + "Open Sauce One", + "Open Sauce Sans", + "Open Sauce Two", + "OpenDyslexic", + "Oranienbaum", + "Orbit", + "Orbitron", + "Oregano", + "Orelega One", + "Orienta", + "Original Surfer", + "Ostrich Sans", + "Oswald", + "Outfit", + "Over the Rainbow", + "Overlock", + "Overlock SC", + "Overpass", + "Overpass Mono", + "Ovo", + "Oxanium", + "Oxygen", + "Oxygen Mono", + "PT Mono", + "PT Sans", + "PT Sans Caption", + "PT Sans Narrow", + "PT Serif", + "PT Serif Caption", + "Pacifico", + "Padauk", + "Padyakke Expanded One", + "Palanquin", + "Palanquin Dark", + "Palette Mosaic", + "Pangolin", + "Paprika", + "Parastoo", + "Parisienne", + "Parkinsans", + "Passero One", + "Passion One", + "Passions Conflict", + "Pathway Extreme", + "Pathway Gothic One", + "Patrick Hand", + "Patrick Hand SC", + "Pattaya", + "Patua One", + "Pavanam", + "Paytone One", + "Peace Sans", + "Peddana", + "Peralta", + "Permanent Marker", + "Petemoss", + "Petit Formal Script", + "Petrona", + "Phetsarath", + "Philosopher", + "Phudu", + "Piazzolla", + "Piedra", + "Pinyon Script", + "Pirata One", + "Pitagon Sans", + "Pitagon Sans Mono", + "Pitagon Sans Text", + "Pitagon Serif", + "Pixelify Sans", + "Plaster", + "Platypi", + "Play", + "Playball", + "Playfair", + "Playfair Display", + "Playfair Display SC", + "Playpen Sans", + "Playpen Sans Arabic", + "Playpen Sans Deva", + "Playpen Sans Hebrew", + "Playpen Sans Thai", + "Playwrite AR", + "Playwrite AR Guides", + "Playwrite AT", + "Playwrite AT Guides", + "Playwrite AU NSW", + "Playwrite AU NSW Guides", + "Playwrite AU QLD", + "Playwrite AU QLD Guides", + "Playwrite AU SA", + "Playwrite AU SA Guides", + "Playwrite AU TAS", + "Playwrite AU TAS Guides", + "Playwrite AU VIC", + "Playwrite AU VIC Guides", + "Playwrite BE VLG", + "Playwrite BE VLG Guides", + "Playwrite BE WAL", + "Playwrite BE WAL Guides", + "Playwrite BR", + "Playwrite BR Guides", + "Playwrite CA", + "Playwrite CA Guides", + "Playwrite CL", + "Playwrite CL Guides", + "Playwrite CO", + "Playwrite CO Guides", + "Playwrite CU", + "Playwrite CU Guides", + "Playwrite CZ", + "Playwrite CZ Guides", + "Playwrite DE Grund", + "Playwrite DE Grund Guides", + "Playwrite DE LA", + "Playwrite DE LA Guides", + "Playwrite DE SAS", + "Playwrite DE SAS Guides", + "Playwrite DE VA", + "Playwrite DE VA Guides", + "Playwrite DK Loopet", + "Playwrite DK Loopet Guides", + "Playwrite DK Uloopet", + "Playwrite DK Uloopet Guides", + "Playwrite ES", + "Playwrite ES Deco", + "Playwrite ES Deco Guides", + "Playwrite ES Guides", + "Playwrite FR Moderne", + "Playwrite FR Moderne Guides", + "Playwrite FR Trad", + "Playwrite FR Trad Guides", + "Playwrite GB J", + "Playwrite GB J Guides", + "Playwrite GB S", + "Playwrite GB S Guides", + "Playwrite HR", + "Playwrite HR Guides", + "Playwrite HR Lijeva", + "Playwrite HR Lijeva Guides", + "Playwrite HU", + "Playwrite HU Guides", + "Playwrite ID", + "Playwrite ID Guides", + "Playwrite IE", + "Playwrite IE Guides", + "Playwrite IN", + "Playwrite IN Guides", + "Playwrite IS", + "Playwrite IS Guides", + "Playwrite IT Moderna", + "Playwrite IT Moderna Guides", + "Playwrite IT Trad", + "Playwrite IT Trad Guides", + "Playwrite MX", + "Playwrite MX Guides", + "Playwrite NG Modern", + "Playwrite NG Modern Guides", + "Playwrite NL", + "Playwrite NL Guides", + "Playwrite NO", + "Playwrite NO Guides", + "Playwrite NZ", + "Playwrite NZ Basic", + "Playwrite NZ Basic Guides", + "Playwrite NZ Guides", + "Playwrite PE", + "Playwrite PE Guides", + "Playwrite PL", + "Playwrite PL Guides", + "Playwrite PT", + "Playwrite PT Guides", + "Playwrite RO", + "Playwrite RO Guides", + "Playwrite SK", + "Playwrite SK Guides", + "Playwrite TZ", + "Playwrite TZ Guides", + "Playwrite US Modern", + "Playwrite US Modern Guides", + "Playwrite US Trad", + "Playwrite US Trad Guides", + "Playwrite VN", + "Playwrite VN Guides", + "Playwrite ZA", + "Playwrite ZA Guides", + "Plus Jakarta Sans", + "Pochaevsk", + "Podkova", + "Poetsen One", + "Poiret One", + "Poller One", + "Poltawski Nowy", + "Poly", + "Pompiere", + "Ponnala", + "Ponomar", + "Pontano Sans", + "Poor Story", + "Poppins", + "Port Lligat Sans", + "Port Lligat Slab", + "Potta One", + "Pragati Narrow", + "Praise", + "Prata", + "Preahvihear", + "Press Start 2P", + "Pretendard", + "Pridi", + "Princess Sofia", + "Prociono", + "Prompt", + "Prosto One", + "Protest Guerrilla", + "Protest Revolution", + "Protest Riot", + "Protest Strike", + "Proza Libre", + "Public Sans", + "Puppies Play", + "Puritan", + "Purple Purse", + "Pushster", + "Qahiri", + "Quando", + "Quantico", + "Quattrocento", + "Quattrocento Sans", + "Questrial", + "Quicksand", + "Quintessential", + "Qwigley", + "Qwitcher Grypen", + "REM", + "Racing Sans One", + "Radio Canada", + "Radio Canada Big", + "Radley", + "Rajdhani", + "Rakkas", + "Raleway", + "Raleway Dots", + "Ramabhadra", + "Ramaraja", + "Rambla", + "Rammetto One", + "Rampart One", + "Ramsina", + "Ranchers", + "Rancho", + "Ranga", + "Rasa", + "Rationale", + "Ravi Prakash", + "Readex Pro", + "Recursive", + "Red Hat Display", + "Red Hat Mono", + "Red Hat Text", + "Red Rose", + "Redacted", + "Redacted Script", + "Redaction", + "Redaction 10", + "Redaction 100", + "Redaction 20", + "Redaction 35", + "Redaction 50", + "Redaction 70", + "Reddit Mono", + "Reddit Sans", + "Reddit Sans Condensed", + "Redressed", + "Reem Kufi", + "Reem Kufi Fun", + "Reem Kufi Ink", + "Reenie Beanie", + "Reggae One", + "Rethink Sans", + "Revalia", + "Rhodium Libre", + "Ribeye", + "Ribeye Marrow", + "Righteous", + "Risque", + "Road Rage", + "Roboto", + "Roboto Condensed", + "Roboto Flex", + "Roboto Mono", + "Roboto Serif", + "Roboto Slab", + "Rochester", + "Rock 3D", + "Rock Salt", + "RocknRoll One", + "Rokkitt", + "Romanesco", + "Ropa Sans", + "Rosario", + "Rosarivo", + "Rouge Script", + "Rowdies", + "Rozha One", + "Rubik", + "Rubik 80s Fade", + "Rubik Beastly", + "Rubik Broken Fax", + "Rubik Bubbles", + "Rubik Burned", + "Rubik Dirt", + "Rubik Distressed", + "Rubik Doodle Shadow", + "Rubik Doodle Triangles", + "Rubik Gemstones", + "Rubik Glitch", + "Rubik Glitch Pop", + "Rubik Iso", + "Rubik Lines", + "Rubik Maps", + "Rubik Marker Hatch", + "Rubik Maze", + "Rubik Microbe", + "Rubik Mono One", + "Rubik Moonrocks", + "Rubik One", + "Rubik Pixels", + "Rubik Puddles", + "Rubik Scribble", + "Rubik Spray Paint", + "Rubik Storm", + "Rubik Vinyl", + "Rubik Wet Paint", + "Ruda", + "Rufina", + "Ruge Boogie", + "Ruluko", + "Rum Raisin", + "Ruslan Display", + "Russo One", + "Ruthie", + "Ruwudu", + "Rye", + "SN Pro", + "STIX Two Text", + "SUSE", + "SUSE Mono", + "Sacramento", + "Sahitya", + "Sail", + "Saira", + "Saira Condensed", + "Saira Extra Condensed", + "Saira Semi Condensed", + "Saira Stencil", + "Saira Stencil One", + "Salsa", + "Sanchez", + "Sancreek", + "Sankofa Display", + "Sansation", + "Sansita", + "Sansita Swashed", + "Sarabun", + "Sarala", + "Sarina", + "Sarpanch", + "Sassy Frass", + "Satisfy", + "Savate", + "Sawarabi Gothic", + "Sawarabi Mincho", + "Scada", + "Scheherazade New", + "Schibsted Grotesk", + "Schoolbell", + "Science Gothic", + "Scope One", + "Seaweed Script", + "Secular One", + "Sedan", + "Sedan SC", + "Sedgwick Ave", + "Sedgwick Ave Display", + "Sekuya", + "Sen", + "Send Flowers", + "Sevillana", + "Seymour One", + "Shadows Into Light", + "Shadows Into Light Two", + "Shafarik", + "Shalimar", + "Shantell Sans", + "Shanti", + "Share", + "Share Tech", + "Share Tech Mono", + "Shippori Antique", + "Shippori Antique B1", + "Shippori Mincho", + "Shippori Mincho B1", + "Shizuru", + "Shojumaru", + "Short Stack", + "Shrikhand", + "Siemreap", + "Sigmar", + "Sigmar One", + "Signika", + "Signika Negative", + "Silkscreen", + "Simonetta", + "Single Day", + "Sintony", + "Sirin Stencil", + "Sirivennela", + "Six Caps", + "Sixtyfour", + "Sixtyfour Convergence", + "Skranji", + "Slabo 13px", + "Slabo 27px", + "Slackey", + "Slackside One", + "Smokum", + "Smooch", + "Smooch Sans", + "Smythe", + "Sniglet", + "Snippet", + "Snowburst One", + "Sofadi One", + "Sofia", + "Sofia Sans", + "Sofia Sans Condensed", + "Sofia Sans Extra Condensed", + "Sofia Sans Semi Condensed", + "Solitreo", + "Solway", + "Sometype Mono", + "Song Myung", + "Sono", + "Sonsie One", + "Sora", + "Sorts Mill Goudy", + "Sour Gummy", + "Source Code Pro", + "Source Sans 3", + "Source Sans Pro", + "Source Serif 4", + "Source Serif Pro", + "Space Grotesk", + "Space Mono", + "Special Elite", + "Special Gothic", + "Special Gothic Condensed One", + "Special Gothic Expanded One", + "Spectral", + "Spectral SC", + "Spicy Rice", + "Spinnaker", + "Spirax", + "Splash", + "Spline Sans", + "Spline Sans Mono", + "Squada One", + "Square Peg", + "Sree Krushnadevaraya", + "Sriracha", + "Srisakdi", + "Staatliches", + "Stack Sans Headline", + "Stack Sans Notch", + "Stack Sans Text", + "Stalemate", + "Stalinist One", + "Stardos Stencil", + "Stick", + "Stick No Bills", + "Stint Ultra Condensed", + "Stint Ultra Expanded", + "Stoke", + "Story Script", + "Strait", + "Style Script", + "Stylish", + "Sue Ellen Francisco", + "Suez One", + "Sulphur Point", + "Sumana", + "Sunflower", + "Sunshiney", + "Supermercado One", + "Sura", + "Suranna", + "Suravaram", + "Suwannaphum", + "Swanky and Moo Moo", + "Syncopate", + "Syne", + "Syne Italic", + "Syne Mono", + "Syne Tactile", + "TASA Explorer", + "TASA Orbiter", + "Tac One", + "Tagesschrift", + "Tai Heritage Pro", + "Tajawal", + "Tangerine", + "Tapestry", + "Taprom", + "Tauri", + "Taviraj", + "Teachers", + "Teko", + "Tektur", + "Telex", + "Tenali Ramakrishna", + "Tenor Sans", + "Text Me One", + "Texturina", + "Thasadith", + "The Girl Next Door", + "The Nautigal", + "Tienne", + "TikTok Sans", + "Tillana", + "Tilt Neon", + "Tilt Prism", + "Tilt Warp", + "Timmana", + "Tinos", + "Tiny5", + "Tiro Bangla", + "Tiro Devanagari Hindi", + "Tiro Devanagari Marathi", + "Tiro Devanagari Sanskrit", + "Tiro Gurmukhi", + "Tiro Kannada", + "Tiro Tamil", + "Tiro Telugu", + "Tirra", + "Titan One", + "Titillium Web", + "Tomorrow", + "Tourney", + "Trade Winds", + "Train One", + "Triodion", + "Trirong", + "Trispace", + "Trocchi", + "Trochut", + "Truculenta", + "Trykker", + "Tsukimi Rounded", + "Tuffy", + "Tulpen One", + "Turret Road", + "Twinkle Star", + "Ubuntu", + "Ubuntu Condensed", + "Ubuntu Mono", + "Ubuntu Sans", + "Ubuntu Sans Mono", + "Uchen", + "Ultra", + "Unbounded", + "Uncial Antiqua", + "Uncut Sans", + "Underdog", + "Unica One", + "Unifont", + "UnifontEX", + "UnifrakturCook", + "UnifrakturMaguntia", + "Unkempt", + "Unlock", + "Unna", + "UoqMunThenKhung", + "Updock", + "Urbanist", + "VT323", + "Vampiro One", + "Varela", + "Varela Round", + "Varta", + "Vast Shadow", + "Vazirmatn", + "Vend Sans", + "Vesper Libre", + "Viaoda Libre", + "Vibes", + "Vibur", + "Victor Mono", + "Vidaloka", + "Viga", + "Vina Sans", + "Voces", + "Volkhov", + "Vollkorn", + "Vollkorn SC", + "Voltaire", + "Vujahday Script", + "WDXL Lubrifont JP N", + "WDXL Lubrifont SC", + "WDXL Lubrifont TC", + "WIN95FA", + "Waiting for the Sunrise", + "Wallpoet", + "Walter Turncoat", + "Warnes", + "Water Brush", + "Waterfall", + "Wavefont", + "Wellfleet", + "Wendy One", + "Whisper", + "WindSong", + "Winky Rough", + "Winky Sans", + "Wire One", + "Wittgenstein", + "Wix Madefor Display", + "Wix Madefor Text", + "Work Sans", + "Workbench", + "Xanh Mono", + "YakuHanJP", + "YakuHanJPs", + "YakuHanMP", + "YakuHanMPs", + "YakuHanRP", + "YakuHanRPs", + "Yaldevi", + "Yanone Kaffeesatz", + "Yantramanav", + "Yarndings 12", + "Yarndings 12 Charted", + "Yarndings 20", + "Yarndings 20 Charted", + "Yatra One", + "Yellowtail", + "Yeon Sung", + "Yeseva One", + "Yesteryear", + "Yomogi", + "Young Serif", + "Yrsa", + "Ysabeau", + "Ysabeau Infant", + "Ysabeau Office", + "Ysabeau SC", + "Yuji Boku", + "Yuji Hentaigana Akari", + "Yuji Hentaigana Akebono", + "Yuji Mai", + "Yuji Syuku", + "Yusei Magic", + "ZCOOL KuaiLe", + "ZCOOL QingKe HuangYou", + "ZCOOL XiaoWei", + "Zain", + "Zalando Sans", + "Zalando Sans Expanded", + "Zalando Sans SemiExpanded", + "Zen Antique", + "Zen Antique Soft", + "Zen Dots", + "Zen Kaku Gothic Antique", + "Zen Kaku Gothic New", + "Zen Kurenaido", + "Zen Loop", + "Zen Maru Gothic", + "Zen Old Mincho", + "Zen Tokyo Zoo", + "Zeyada", + "Zhi Mang Xing", + "Zilla Slab", + "Zilla Slab Highlight", + "iA Writer Duo", + "iA Writer Mono", + "iA Writer Quattro" +] \ No newline at end of file diff --git a/desktopApp/src/desktopMain/resources/textures/classy_fabric.webp b/desktopApp/src/desktopMain/resources/textures/classy_fabric.webp new file mode 100644 index 0000000..40c01e9 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/classy_fabric.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp b/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp new file mode 100644 index 0000000..eeda652 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/ep_naturalblack.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp b/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp new file mode 100644 index 0000000..050f115 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/ep_naturalwhite.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp b/desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp new file mode 100644 index 0000000..c6cba20 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/grey_wash_wall.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/light-veneer.webp b/desktopApp/src/desktopMain/resources/textures/light-veneer.webp new file mode 100644 index 0000000..2827d62 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/light-veneer.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/retina_wood.webp b/desktopApp/src/desktopMain/resources/textures/retina_wood.webp new file mode 100644 index 0000000..d697e3d Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/retina_wood.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/retro_intro.webp b/desktopApp/src/desktopMain/resources/textures/retro_intro.webp new file mode 100644 index 0000000..ce031ae Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/retro_intro.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_canvas.png b/desktopApp/src/desktopMain/resources/textures/texture_canvas.png new file mode 100644 index 0000000..edd5c01 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_canvas.png differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_eink.webp b/desktopApp/src/desktopMain/resources/textures/texture_eink.webp new file mode 100644 index 0000000..050f115 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_eink.webp differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_paper.png b/desktopApp/src/desktopMain/resources/textures/texture_paper.png new file mode 100644 index 0000000..b5855b9 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_paper.png differ diff --git a/desktopApp/src/desktopMain/resources/textures/texture_slate.png b/desktopApp/src/desktopMain/resources/textures/texture_slate.png new file mode 100644 index 0000000..9fddee6 Binary files /dev/null and b/desktopApp/src/desktopMain/resources/textures/texture_slate.png differ diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStoreTest.kt new file mode 100644 index 0000000..737dfb4 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAiByokStoreTest.kt @@ -0,0 +1,173 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAiByokStoreTest { + @Test + fun `save keeps keys out of plaintext settings file`() { + val settingsFile = Files.createTempDirectory("reader-ai-store").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + store.save( + ReaderAiByokSettings( + geminiKey = "gemini_secret", + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + ) + + val raw = settingsFile.readText() + assertFalse(raw.contains("gemini_secret")) + assertFalse(raw.contains("groq_secret")) + assertTrue(raw.contains("geminiKeyProtected=")) + assertTrue(raw.contains("groqKeyProtected=")) + + val loaded = store.load() + assertEquals("gemini_secret", loaded.geminiKey) + assertEquals("groq_secret", loaded.groqKey) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) + } + + @Test + fun `load migrates legacy plaintext keys into protected entries`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-legacy").resolve("ai-byok.properties") + settingsFile.writeText( + """ + geminiKey=old_gemini + groqKey=old_groq + modelForAll=groq:qwen/qwen3-32b + useOneModel=true + """.trimIndent() + ) + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + val loaded = store.load() + + assertEquals("old_gemini", loaded.geminiKey) + assertEquals("old_groq", loaded.groqKey) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) + val raw = settingsFile.readText() + assertFalse(raw.contains("geminiKey=old_gemini")) + assertFalse(raw.contains("groqKey=old_groq")) + assertTrue(raw.contains("geminiKeyProtected=")) + assertTrue(raw.contains("groqKeyProtected=")) + } + + @Test + fun `model settings persist when secure key storage is unavailable`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-unavailable").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), UnavailableSecretCodec) + + store.save( + ReaderAiByokSettings( + geminiKey = "session_only", + modelForAll = "groq:qwen/qwen3-32b", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + ) + + val raw = settingsFile.readText() + assertFalse(raw.contains("session_only")) + assertFalse(raw.contains("geminiKeyProtected=")) + + val loaded = store.load() + assertEquals("", loaded.geminiKey) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel) + } + + @Test + fun `save with blank key clears protected secret entry`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-clear").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + store.save( + ReaderAiByokSettings( + geminiKey = "gemini_secret", + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b" + ) + ) + store.save( + ReaderAiByokSettings( + groqKey = "groq_secret", + modelForAll = "groq:qwen/qwen3-32b" + ) + ) + + val raw = settingsFile.readText() + assertFalse(raw.contains("geminiKeyProtected=")) + assertTrue(raw.contains("groqKeyProtected=")) + + val loaded = store.load() + assertEquals("", loaded.geminiKey) + assertEquals("groq_secret", loaded.groqKey) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + } + + @Test + fun `load ignores legacy hidden reader ai preference on desktop`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-visible").resolve("ai-byok.properties") + settingsFile.writeText( + """ + hideReaderAiFeatures=true + modelForAll=groq:qwen/qwen3-32b + useOneModel=true + """.trimIndent() + ) + val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec) + + val loaded = store.load() + + assertFalse(loaded.hideReaderAiFeatures) + assertTrue(loaded.useOneModel) + assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll) + } + + @Test + fun `load does not probe secure storage when settings file is missing`() { + val settingsFile = Files.createTempDirectory("reader-ai-store-missing").resolve("ai-byok.properties") + val store = DesktopAiByokStore(settingsFile.toFile(), ThrowingAvailabilitySecretCodec) + + val loaded = store.load() + + assertEquals("", loaded.geminiKey) + assertEquals("", loaded.groqKey) + } + + private object ReversibleSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = true + + override fun protect(value: String): String { + return "test:" + value.reversed() + } + + override fun unprotect(value: String): String { + return value.removePrefix("test:").reversed() + } + } + + private object UnavailableSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = false + override fun protect(value: String): String = "" + override fun unprotect(value: String): String = "" + } + + private object ThrowingAvailabilitySecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean + get() = error("Secure storage should not be checked for a missing settings file.") + + override fun protect(value: String): String = "" + override fun unprotect(value: String): String = "" + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAurPackagingMetadataTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAurPackagingMetadataTest.kt new file mode 100644 index 0000000..b72f737 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAurPackagingMetadataTest.kt @@ -0,0 +1,32 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class DesktopAurPackagingMetadataTest { + @Test + fun `aur metadata declares arch runtime dependencies license and desktop mime support`() { + val buildScript = desktopBuildScriptText() + + assertTrue(buildScript.contains("\"libarchive\"")) + assertTrue(buildScript.contains("license=('AGPL-3.0-only')")) + assertTrue(buildScript.contains("license = AGPL-3.0-only")) + assertTrue(buildScript.contains("/usr/share/licenses/${'$'}pkgname/LICENSE")) + assertTrue(buildScript.contains("application/epub+zip")) + assertTrue(buildScript.contains("application/vnd.comicbook+zip")) + assertTrue(buildScript.contains("application/vnd.openxmlformats-officedocument.wordprocessingml.document")) + } + + private fun desktopBuildScriptText(): String { + val candidates = listOf( + File("build.gradle.kts"), + File("desktopApp/build.gradle.kts") + ) + val buildFile = candidates.firstOrNull { file -> + file.isFile && file.readText().contains("PrepareDesktopAurPackageTask") + } + requireNotNull(buildFile) { "Could not locate desktopApp/build.gradle.kts" } + return buildFile.readText() + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAuthStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAuthStoreTest.kt new file mode 100644 index 0000000..0cf8176 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopAuthStoreTest.kt @@ -0,0 +1,108 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.UserData +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAuthStoreTest { + @Test + fun `save protects refresh tokens and load restores the account`() { + val settingsFile = Files.createTempDirectory("reader-auth-store") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, ReversibleSecretCodec) + + store.save(testSession()) + + val raw = settingsFile.readText() + assertFalse(raw.contains("firebase_refresh")) + assertFalse(raw.contains("google_refresh")) + assertTrue(raw.contains("firebaseRefreshTokenProtected=")) + assertTrue(raw.contains("googleRefreshTokenProtected=")) + + val loaded = DesktopAuthStore(settingsFile, ReversibleSecretCodec).load() + assertEquals("user-1", loaded?.user?.uid) + assertEquals("reader@example.com", loaded?.user?.email) + assertEquals("firebase_refresh", loaded?.refreshToken) + assertEquals("google_refresh", loaded?.googleRefreshToken) + } + + @Test + fun `save falls back to session only without leaving a partial account file when secure storage is unavailable`() { + val settingsFile = Files.createTempDirectory("reader-auth-store-unavailable") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, ThrowingSecretCodec) + + store.save(testSession()) + + assertFalse(settingsFile.exists()) + assertEquals(null, store.load()) + } + + @Test + fun `save still surfaces secure storage write failures when storage is available`() { + val settingsFile = Files.createTempDirectory("reader-auth-store-available-write-failure") + .resolve("auth.properties") + .toFile() + val store = DesktopAuthStore(settingsFile, AvailableThrowingSecretCodec) + + assertFailsWith { + store.save(testSession()) + } + assertFalse(settingsFile.exists()) + } + + private fun testSession(): DesktopAuthSession { + return DesktopAuthSession( + user = UserData( + uid = "user-1", + displayName = "Reader", + photoUrl = null, + email = "reader@example.com" + ), + idToken = "id_token", + refreshToken = "firebase_refresh", + expiresAtEpochMillis = 123L, + googleAccessToken = "google_access", + googleRefreshToken = "google_refresh", + googleAccessTokenExpiresAtEpochMillis = 456L + ) + } + + private object ReversibleSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = true + + override fun protect(value: String): String { + return "test:" + value.reversed() + } + + override fun unprotect(value: String): String { + return value.removePrefix("test:").reversed() + } + } + + private object ThrowingSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = false + + override fun protect(value: String): String { + throw IllegalStateException("Secure storage unavailable") + } + + override fun unprotect(value: String): String = "" + } + + private object AvailableThrowingSecretCodec : DesktopSecretCodec { + override val isAvailable: Boolean = true + + override fun protect(value: String): String { + throw IllegalStateException("Secure storage write failed") + } + + override fun unprotect(value: String): String = "" + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporterTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporterTest.kt new file mode 100644 index 0000000..08bc0ed --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBookImporterTest.kt @@ -0,0 +1,76 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ImportedBookFile +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DesktopBookImporterTest { + @Test + fun `prepare imports copies supported file into app storage without source folder`() { + val tempRoot = Files.createTempDirectory("episteme-book-importer-test").toFile() + try { + val source = File(tempRoot, "Book.md").apply { writeText("# Hello") } + val store = File(tempRoot, "books") + val importer = DesktopBookImporter(store) + + val result = importer.prepareImports(listOf(source.toImportedBookFile())) + + assertEquals(0, result.failedCount) + val prepared = result.files.single() + val copied = File(assertNotNull(prepared.localPath)) + assertEquals("Book.md", prepared.name) + assertEquals(source.sha256(), prepared.id) + assertNull(prepared.uriString) + assertNull(prepared.sourceFolder) + assertEquals(store.canonicalFile, copied.parentFile.canonicalFile) + assertNotEquals(source.canonicalFile, copied.canonicalFile) + assertEquals("# Hello", copied.readText()) + } finally { + tempRoot.deleteRecursively() + } + } + + @Test + fun `prepare imports leaves unsupported files uncopied for shared planner`() { + val tempRoot = Files.createTempDirectory("episteme-book-importer-test").toFile() + try { + val source = File(tempRoot, "Archive.zip").apply { writeText("zip") } + val store = File(tempRoot, "books") + val importer = DesktopBookImporter(store) + + val result = importer.prepareImports(listOf(source.toImportedBookFile(sourceFolder = tempRoot.absolutePath))) + + assertEquals(0, result.failedCount) + val prepared = result.files.single() + assertEquals(source.absolutePath, prepared.localPath) + assertNull(prepared.sourceFolder) + assertNull(prepared.id) + assertFalse(store.listFiles().orEmpty().any { it.isFile }) + } finally { + tempRoot.deleteRecursively() + } + } + + private fun File.toImportedBookFile(sourceFolder: String? = null): ImportedBookFile { + return ImportedBookFile( + name = name, + uriString = null, + localPath = absolutePath, + size = length(), + sourceFolder = sourceFolder + ) + } + + private fun File.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(readBytes()) + return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfileTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfileTest.kt new file mode 100644 index 0000000..9280be1 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopBuildProfileTest.kt @@ -0,0 +1,175 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.GEMINI_CLOUD_TTS_MODEL_ID +import org.dueattendant149.bookreader.shared.ReaderAiByokSettings +import org.dueattendant149.bookreader.shared.SharedFeaturePolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopBuildProfileTest { + @Test + fun `standard desktop flavor keeps online features available`() { + val profile = desktopBuildProfileForFlavor("standard") + + assertEquals(DesktopFlavorStandard, profile.flavor) + assertEquals(EpistemeDesktopStandardAppName, profile.appName) + assertEquals("Standard edition", profile.buildLabel) + assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy) + assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/privacy-policy.html")) + assertTrue(profile.legalLinks.termsUrl.endsWith("/terms-and-conditions.html")) + assertTrue(profile.featurePolicy.networkAccess) + assertFalse(profile.featurePolicy.byokAi) + assertFalse(profile.byokAiAvailable) + assertTrue(profile.aiKeySettingsAvailable) + assertTrue(profile.creditBackedCloudTtsControlsAvailable) + } + + @Test + fun `oss offline desktop flavor disables network backed features`() { + val profile = desktopBuildProfileForFlavor("oss-offline") + + assertEquals(DesktopFlavorOssOffline, profile.flavor) + assertEquals(EpistemeDesktopOssAppName, profile.appName) + assertEquals("Offline OSS edition", profile.buildLabel) + assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) + assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/oss-privacy-policy.html")) + assertTrue(profile.legalLinks.termsUrl.endsWith("/oss-terms-of-service.html")) + assertFalse(profile.featurePolicy.networkAccess) + assertFalse(profile.featurePolicy.aiAndCloud) + assertTrue(profile.featurePolicy.byokAi) + assertFalse(profile.byokAiAvailable) + assertFalse(profile.aiKeySettingsAvailable) + assertFalse(profile.featurePolicy.opdsCatalogs) + assertFalse(profile.featurePolicy.googleFontsDownload) + assertFalse(profile.creditBackedCloudTtsControlsAvailable) + } + + @Test + fun `oss desktop flavor aliases resolve to offline oss profile`() { + val profile = desktopBuildProfileForFlavor("oss") + + assertEquals(DesktopFlavorOssOffline, profile.flavor) + assertEquals(EpistemeDesktopOssAppName, profile.appName) + assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy) + } + + @Test + fun `desktop BYOK settings are only exposed by an online OSS-style policy`() { + val settings = ReaderAiByokSettings( + geminiKey = "gemini_secret", + modelForAll = "gemini:gemini-flash-lite-latest" + ) + val onlineOssPolicy = SharedFeaturePolicy.OssOnline + + val onlineOssProfile = DesktopBuildProfile( + flavor = "oss-online", + appName = "Episteme oss", + buildLabel = "OSS edition", + featurePolicy = onlineOssPolicy + ) + + assertTrue(onlineOssProfile.byokAiAvailable) + assertFalse(onlineOssProfile.aiKeySettingsAvailable) + assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy)) + assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures) + assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures) + + val byokCloudTtsSettings = settings.copy( + geminiKey = "gemini_secret", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + val desktopByokSettings = byokCloudTtsSettings.withDesktopFeaturePolicy(onlineOssPolicy) + assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, desktopByokSettings.ttsModel) + assertTrue(desktopByokSettings.isCloudTtsAvailable) + assertFalse( + DesktopBuildProfile( + flavor = "oss-online", + appName = "Episteme oss", + buildLabel = "OSS edition", + featurePolicy = onlineOssPolicy + ).creditBackedCloudTtsControlsAvailable + ) + } + + @Test + fun `desktop tts worker requires its own configured endpoint`() { + val config = DesktopCloudConfig( + aiWorkerUrl = "https://example.com/ai", + ttsWorkerUrl = "", + firebaseWebApiKey = "", + firebaseProjectId = "", + googleOAuthClientId = "", + googleOAuthClientSecret = "" + ) + + assertTrue(config.isAiWorkerConfigured) + assertFalse(config.isTtsWorkerConfigured) + } + + @Test + fun `desktop cloud tts adapter allows byok before credit worker`() { + val byokAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { + ReaderAiByokSettings( + geminiKey = "gemini_secret", + ttsModel = GEMINI_CLOUD_TTS_MODEL_ID + ) + }, + networkAccess = { true }, + workerUrlProvider = { "" } + ) + val workerAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { ReaderAiByokSettings(serverBackedCloudTts = true) }, + networkAccess = { true }, + workerUrlProvider = { "https://example.com/tts" } + ) + val unavailableAdapter = DesktopGeminiCloudTtsAdapter( + settingsProvider = { ReaderAiByokSettings() }, + networkAccess = { true }, + workerUrlProvider = { "https://example.com/tts" } + ) + + assertTrue(byokAdapter.isAvailable) + assertTrue(workerAdapter.isAvailable) + assertFalse(unavailableAdapter.isAvailable) + } + + @Test + fun `desktop persisted AI settings keep Android model controls and force visibility`() { + val settings = ReaderAiByokSettings( + geminiKey = " gemini_secret ", + groqKey = " groq_secret ", + useOneModel = true, + modelForAll = "groq:qwen/qwen3-32b", + defineModel = "gemini:gemini-flash-lite-latest", + summarizeModel = "groq:llama-3.3-70b-versatile", + recapModel = "gemini:gemini-2.5-flash-lite", + hideReaderAiFeatures = true + ) + + val persisted = settings.toDesktopPersistableAiSettings() + + assertEquals("gemini_secret", persisted.geminiKey) + assertEquals("groq_secret", persisted.groqKey) + assertTrue(persisted.useOneModel) + assertEquals("groq:qwen/qwen3-32b", persisted.modelForAll) + assertEquals("gemini:gemini-flash-lite-latest", persisted.defineModel) + assertEquals("groq:llama-3.3-70b-versatile", persisted.summarizeModel) + assertEquals("gemini:gemini-2.5-flash-lite", persisted.recapModel) + assertFalse(persisted.hideReaderAiFeatures) + } + + @Test + fun `desktop diagnostics are disabled unless explicitly enabled`() { + assertFalse(desktopDiagnosticsFlag(null)) + assertFalse(desktopDiagnosticsFlag("")) + assertFalse(desktopDiagnosticsFlag("false")) + assertFalse(desktopDiagnosticsFlag("1")) + + assertTrue(desktopDiagnosticsFlag("true")) + assertTrue(desktopDiagnosticsFlag(" TRUE ")) + } + +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfigTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfigTest.kt new file mode 100644 index 0000000..e2ef8cf --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudConfigTest.kt @@ -0,0 +1,60 @@ +package org.dueattendant149.bookreader.desktop + +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopCloudConfigTest { + @Test + fun `packaged resource config can enable desktop Google sign in`() { + val config = desktopCloudConfigFromProperties( + resourceProperties = properties( + "FIREBASE_WEB_API_KEY" to "firebase-key", + "FIREBASE_PROJECT_ID" to "reader-project", + "GOOGLE_OAUTH_CLIENT_ID" to "oauth-client", + "GOOGLE_OAUTH_CLIENT_SECRET" to "oauth-secret" + ), + systemProperty = { null }, + environment = { null } + ) + + assertTrue(config.isAuthConfigured) + assertEquals("firebase-key", config.firebaseWebApiKey) + assertEquals("reader-project", config.firebaseProjectId) + assertEquals("oauth-client", config.googleOAuthClientId) + assertEquals("oauth-secret", config.googleOAuthClientSecret) + } + + @Test + fun `local desktop keys override packaged resource config`() { + val config = desktopCloudConfigFromProperties( + resourceProperties = properties( + "FIREBASE_WEB_API_KEY" to "packaged-firebase-key", + "FIREBASE_PROJECT_ID" to "packaged-project", + "GOOGLE_OAUTH_CLIENT_ID" to "packaged-oauth-client", + "GOOGLE_OAUTH_CLIENT_SECRET" to "packaged-oauth-secret" + ), + localProperties = properties( + "DESKTOP_FIREBASE_WEB_API_KEY" to "local-firebase-key", + "DESKTOP_FIREBASE_PROJECT_ID" to "local-project", + "DESKTOP_GOOGLE_OAUTH_CLIENT_ID" to "local-oauth-client", + "DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" to "local-oauth-secret" + ), + systemProperty = { null }, + environment = { null } + ) + + assertTrue(config.isAuthConfigured) + assertEquals("local-firebase-key", config.firebaseWebApiKey) + assertEquals("local-project", config.firebaseProjectId) + assertEquals("local-oauth-client", config.googleOAuthClientId) + assertEquals("local-oauth-secret", config.googleOAuthClientSecret) + } +} + +private fun properties(vararg values: Pair): Properties { + return Properties().apply { + values.forEach { (key, value) -> setProperty(key, value) } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncMappingTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncMappingTest.kt new file mode 100644 index 0000000..de3454c --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCloudSyncMappingTest.kt @@ -0,0 +1,391 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.HighlightColor +import org.dueattendant149.bookreader.shared.ReaderLocator +import org.dueattendant149.bookreader.shared.UserHighlight +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationSerializer +import org.dueattendant149.bookreader.shared.pdf.SharedPdfBookmark +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichDocument +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichTextSerializer +import org.dueattendant149.bookreader.shared.reader.ReaderBookmark +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopCloudSyncMappingTest { + @Test + fun `book metadata encodes desktop reader state for cloud sync`() { + val bookmarkLocator = ReaderLocator( + chapterIndex = 2, + pageIndex = 4, + startOffset = 30, + endOffset = 44, + textQuote = "marked passage" + ) + val highlightLocator = ReaderLocator( + chapterIndex = 2, + startOffset = 50, + endOffset = 64, + textQuote = "highlighted text" + ) + val book = BookItem( + id = "book-1", + path = null, + type = FileType.EPUB, + displayName = "Book.epub", + timestamp = 1_000L, + title = "Book", + author = "Author", + progressPercentage = 42f, + lastPageIndex = 4, + readerPosition = ReaderLocator( + chapterIndex = 2, + pageIndex = 4, + startOffset = 10, + endOffset = 20 + ), + readerBookmarks = listOf( + ReaderBookmark( + id = "bookmark-1", + pageIndex = 4, + chapterTitle = "Chapter", + preview = "marked passage", + locator = bookmarkLocator + ) + ), + readerHighlights = listOf( + UserHighlight( + id = "highlight-1", + cfi = "desktop:2:50:64", + text = "highlighted text", + color = HighlightColor.YELLOW, + chapterIndex = 2, + locator = highlightLocator + ) + ) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false, timestamp = 2_000L) + val restored = metadata.toDesktopBookItem() + + assertEquals("desktop:2:10:20", metadata.lastPositionCfi) + assertEquals(2, metadata.lastChapterIndex) + assertEquals(4, metadata.lastPage) + assertEquals(42f, metadata.progressPercentage) + assertEquals(1_000L, metadata.readingPositionModifiedTimestamp) + assertTrue(assertNotNull(metadata.bookmarksJson).contains("desktop:2:30:44")) + assertTrue(assertNotNull(metadata.highlightsJson).contains("highlighted text")) + assertEquals(book.id, restored.id) + assertEquals(2, restored.readerPosition?.chapterIndex) + assertEquals(10, restored.readerPosition?.startOffset) + assertEquals(1, restored.readerBookmarks.size) + assertEquals(2, restored.readerBookmarks.single().locator.chapterIndex) + assertEquals(30, restored.readerBookmarks.single().locator.startOffset) + assertEquals(44, restored.readerBookmarks.single().locator.endOffset) + assertEquals("desktop:2:30:44", restored.readerBookmarks.single().locator.cfi) + assertEquals(1, restored.readerHighlights.size) + assertEquals(2, restored.readerHighlights.single().locator.chapterIndex) + assertEquals(50, restored.readerHighlights.single().locator.startOffset) + assertEquals(64, restored.readerHighlights.single().locator.endOffset) + assertEquals("desktop:2:50:64", restored.readerHighlights.single().locator.cfi) + } + + @Test + fun `metadata only upload can preserve remote content timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + fileContentModifiedTimestamp = 111L + ) + + val metadata = book.toDesktopCloudBookMetadata( + hasAnnotations = false, + timestamp = 2_000L, + contentTimestampOverride = 999L + ) + + assertEquals(999L, metadata.fileContentModifiedTimestamp) + } + + @Test + fun `metadata upload keeps reading position timestamp separate from upload timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 12, + progressPercentage = 20f, + readingPositionModifiedTimestamp = 1_500L + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = true, timestamp = 3_000L) + + assertEquals(3_000L, metadata.lastModifiedTimestamp) + assertEquals(1_500L, metadata.readingPositionModifiedTimestamp) + assertEquals(0L, metadata.annotationModifiedTimestamp) + assertEquals(12, metadata.lastPage) + } + + @Test + fun `metadata upload keeps annotation timestamp separate from upload timestamp`() { + val book = BookItem( + id = "book-1", + path = null, + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L + ) + + val metadata = book.toDesktopCloudBookMetadata( + hasAnnotations = true, + timestamp = 3_000L, + annotationModifiedTimestamp = 2_250L + ) + + assertEquals(3_000L, metadata.lastModifiedTimestamp) + assertEquals(2_250L, metadata.annotationModifiedTimestamp) + assertEquals(2_250L, metadata.effectiveCloudAnnotationModifiedTimestamp()) + } + + @Test + fun `annotation freshness does not fall back to book metadata timestamp`() { + val metadata = DesktopCloudBookMetadata( + bookId = "book-1", + type = FileType.PDF.name, + lastModifiedTimestamp = 5_000L, + hasAnnotations = true + ) + + assertEquals(0L, metadata.effectiveCloudAnnotationModifiedTimestamp()) + assertEquals(3_000L, metadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L)) + } + + @Test + fun `desktop drive file names use shared cloud content extension`() { + assertEquals("book-1.epub", desktopCloudBookDriveFileName("book-1", FileType.EPUB)) + assertEquals("book-1.md", desktopCloudBookDriveFileName("book-1", FileType.MD)) + assertEquals("book-1.mobi", desktopCloudBookDriveFileName("book-1", FileType.MOBI)) + assertNull(desktopCloudBookDriveFileName("book-1", FileType.UNKNOWN)) + } + + @Test + fun `empty epub annotations upload as empty arrays`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.epub", + type = FileType.EPUB, + displayName = "Book.epub", + timestamp = 1_000L + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals("[]", metadata.bookmarksJson) + assertEquals("[]", metadata.highlightsJson) + } + + @Test + fun `remote pdf metadata moves stale desktop viewport to remote page`() { + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 264, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 264), + pdfReaderViewport = SharedPdfReaderViewport( + pageIndex = 264, + verticalFirstPageIndex = 264, + verticalFirstPageScrollOffset = 120 + ) + ) + val remote = DesktopCloudBookMetadata( + bookId = "book-1", + displayName = "Book.pdf", + type = FileType.PDF.name, + lastModifiedTimestamp = 2_000L, + lastPage = 69, + progressPercentage = 8.75f + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(69, restored.lastPageIndex) + assertEquals(8.75f, restored.progressPercentage) + assertNull(restored.readerPosition) + assertEquals(69, restored.pdfReaderViewport?.pageIndex) + assertEquals(69, restored.pdfReaderViewport?.verticalFirstPageIndex) + assertEquals(0, restored.pdfReaderViewport?.verticalFirstPageScrollOffset) + } + + @Test + fun `remote metadata with older reading timestamp preserves newer local pdf position`() { + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 4_000L, + lastPageIndex = 88, + progressPercentage = 44f, + pdfReaderViewport = SharedPdfReaderViewport(pageIndex = 88, verticalFirstPageIndex = 88), + readingPositionModifiedTimestamp = 4_000L + ) + val remote = DesktopCloudBookMetadata( + bookId = "book-1", + displayName = "Book.pdf", + type = FileType.PDF.name, + lastModifiedTimestamp = 6_000L, + readingPositionModifiedTimestamp = 3_000L, + lastPage = 12, + progressPercentage = 6f, + hasAnnotations = true + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(6_000L, restored.timestamp) + assertEquals(88, restored.lastPageIndex) + assertEquals(44f, restored.progressPercentage) + assertEquals(88, restored.pdfReaderViewport?.pageIndex) + assertEquals(4_000L, restored.readingPositionModifiedTimestamp) + } + + @Test + fun `pdf metadata upload ignores stale text locator page`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.pdf", + type = FileType.PDF, + displayName = "Book.pdf", + timestamp = 1_000L, + lastPageIndex = 264, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 69) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals(264, metadata.lastPage) + assertNull(metadata.lastPositionCfi) + } + + @Test + fun `comic metadata upload ignores stale text locator page`() { + val book = BookItem( + id = "book-1", + path = "C:/books/Book.cbt", + type = FileType.CBT, + displayName = "Book.cbt", + timestamp = 1_000L, + lastPageIndex = 42, + progressPercentage = 33.125f, + readerPosition = ReaderLocator(pageIndex = 12) + ) + + val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false) + + assertEquals(42, metadata.lastPage) + assertNull(metadata.lastPositionCfi) + } + + @Test + fun `remote metadata without annotation json preserves existing desktop annotations`() { + val existingBookmark = ReaderBookmark( + id = "bookmark-1", + pageIndex = 1, + chapterTitle = "Chapter", + preview = "local bookmark" + ) + val existingHighlight = UserHighlight( + id = "highlight-1", + cfi = "desktop:0:12:18", + text = "local highlight", + color = HighlightColor.BLUE, + chapterIndex = 0 + ) + val existing = BookItem( + id = "book-1", + path = "C:/books/Book.epub", + type = FileType.EPUB, + displayName = "Book.epub", + timestamp = 1_000L, + readerBookmarks = listOf(existingBookmark), + readerHighlights = listOf(existingHighlight) + ) + val remote = DesktopCloudBookMetadata( + bookId = existing.id, + displayName = existing.displayName, + type = FileType.EPUB.name, + lastModifiedTimestamp = 2_000L, + bookmarksJson = null, + highlightsJson = null + ) + + val restored = remote.toDesktopBookItem(existing = existing) + + assertEquals(listOf(existingBookmark), restored.readerBookmarks) + assertEquals(listOf(existingHighlight), restored.readerHighlights) + assertEquals(existing.path, restored.path) + } + + @Test + fun `desktop pdf bookmarks map to android metadata json`() { + val metadataJson = desktopPdfBookmarksMetadataJson( + bookmarks = listOf( + SharedPdfBookmark( + pageIndex = 3, + label = "Important page", + createdAt = 1_234L + ) + ), + lastPageIndex = 9 + ) + + val restored = desktopPdfBookmarksFromMetadataJson(metadataJson) + + assertTrue(metadataJson.contains("\"pageIndex\"")) + assertTrue(metadataJson.contains("\"title\"")) + assertTrue(metadataJson.contains("\"totalPages\"")) + assertEquals(1, restored.size) + assertEquals(3, restored.single().pageIndex) + assertEquals("Important page", restored.single().label) + } + + @Test + fun `android pdf bookmark metadata keeps titles on desktop`() { + val restored = desktopPdfBookmarksFromMetadataJson( + """[{"pageIndex":2,"title":"Android bookmark","totalPages":8}]""" + ) + + assertEquals(1, restored.size) + assertEquals(2, restored.single().pageIndex) + assertEquals("Android bookmark", restored.single().label) + } + + @Test + fun `empty desktop pdf annotations are not exported as cloud annotation data`() { + val emptyAnnotationsJson = SharedPdfAnnotationSerializer.encode(emptyList()) + + assertNull(desktopPdfAnnotationElementForSync(emptyAnnotationsJson)) + } + + @Test + fun `empty desktop pdf rich text is not exported as cloud annotation data`() { + val emptyRichTextJson = SharedPdfRichTextSerializer.encode(SharedPdfRichDocument()) + + assertNull(desktopPdfRichTextElementForSync(emptyRichTextJson)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchiveTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchiveTest.kt new file mode 100644 index 0000000..829b126 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComicArchiveTest.kt @@ -0,0 +1,115 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.FileType +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopComicArchiveTest { + @Test + fun `cbz archive loads image pages for pdf reader surface`() = withTempDir { dir -> + val cbz = File(dir, "comic.cbz") + ZipOutputStream(cbz.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("pages/001.png")) + zip.write(onePixelPngBytes()) + zip.closeEntry() + } + + val document = DesktopPdfium.loadComic(cbz, FileType.CBZ) + try { + assertEquals(1, document.pageCount) + assertEquals(1f, document.pageSizes.single().width) + assertEquals(1f, document.pageSizes.single().height) + + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f) + + assertEquals(8, image.width) + assertEquals(8, image.height) + } finally { + document.close() + } + } + + @Test + fun `closing stale comic document does not close replacement with same path`() = withTempDir { dir -> + val cbz = File(dir, "comic.cbz") + ZipOutputStream(cbz.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("pages/001.png")) + zip.write(onePixelPngBytes()) + zip.closeEntry() + } + + val staleDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ) + val activeDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ) + try { + staleDocument.close() + + val image = DesktopPdfium.renderPageBufferedImage(activeDocument, pageIndex = 0, scale = 4f) + + assertEquals(4, image.width) + assertEquals(4, image.height) + } finally { + staleDocument.close() + activeDocument.close() + } + } + + @Test + fun `cbt archive loads image pages for pdf reader surface`() = withTempDir { dir -> + val cbt = File(dir, "comic.cbt") + TarArchiveOutputStream(cbt.outputStream()).use { tar -> + val bytes = onePixelPngBytes() + val entry = TarArchiveEntry("pages/001.png").apply { + size = bytes.size.toLong() + } + tar.putArchiveEntry(entry) + tar.write(bytes) + tar.closeArchiveEntry() + tar.finish() + } + + val document = DesktopPdfium.loadComic(cbt, FileType.CBT) + try { + assertEquals(1, document.pageCount) + assertEquals(1f, document.pageSizes.single().width) + assertEquals(1f, document.pageSizes.single().height) + + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f) + + assertEquals(8, image.width) + assertEquals(8, image.height) + } finally { + document.close() + } + } + + @Test + fun `desktop comic types are routed through shared reader capability map`() { + assertTrue(DesktopComicArchive.canLoad(FileType.CBZ)) + assertTrue(DesktopComicArchive.canLoad(FileType.CBR)) + assertTrue(DesktopComicArchive.canLoad(FileType.CB7)) + assertTrue(DesktopComicArchive.canLoad(FileType.CBT)) + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-desktop-comic").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun onePixelPngBytes(): ByteArray { + return Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComposeInteropTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComposeInteropTest.kt new file mode 100644 index 0000000..5d3700f --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopComposeInteropTest.kt @@ -0,0 +1,62 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopComposeInteropTest { + @Test + fun `desktop enables Compose interop blending before app startup`() { + withSystemProperty(ComposeInteropBlendingProperty, null) { + configureComposeSwingInterop(nonNativeWebViewPlatform) + + assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) + } + } + + @Test + fun `desktop treats blank Compose interop blending value as unset`() { + withSystemProperty(ComposeInteropBlendingProperty, " ") { + configureComposeSwingInterop(nonNativeWebViewPlatform) + + assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty)) + } + } + + @Test + fun `desktop preserves explicit Compose interop blending override`() { + withSystemProperty(ComposeInteropBlendingProperty, "false") { + configureComposeSwingInterop(nonNativeWebViewPlatform) + + assertEquals("false", System.getProperty(ComposeInteropBlendingProperty)) + } + } + + private fun withSystemProperty( + key: String, + value: String?, + block: () -> Unit + ) { + val previous = System.getProperty(key) + try { + if (value == null) { + System.clearProperty(key) + } else { + System.setProperty(key, value) + } + block() + } finally { + if (previous == null) { + System.clearProperty(key) + } else { + System.setProperty(key, previous) + } + } + } + + private companion object { + val nonNativeWebViewPlatform = DesktopPlatform( + os = DesktopOperatingSystem.OTHER, + architecture = DesktopArchitecture.X64 + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStoreTest.kt new file mode 100644 index 0000000..a755d8d --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopCustomFontStoreTest.kt @@ -0,0 +1,104 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.CustomFontItem +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopCustomFontStoreTest { + @Test + fun `import font copies supported file into desktop font store`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val source = File(tempRoot, "Literata.ttf").apply { writeText("font-bytes") } + val store = DesktopCustomFontStore(File(tempRoot, "store")) + + val font = store.importFont(source).getOrThrow() + + assertEquals("Literata", font.displayName) + assertEquals("ttf", font.fileExtension) + assertTrue(File(font.path).isFile) + assertEquals("font-bytes", File(font.path).readText()) + } finally { + tempRoot.deleteRecursively() + } + } + + @Test + fun `import font rejects unsupported extension`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val source = File(tempRoot, "not-a-font.txt").apply { writeText("nope") } + val store = DesktopCustomFontStore(File(tempRoot, "store")) + + assertTrue(store.importFont(source).isFailure) + } finally { + tempRoot.deleteRecursively() + } + } + + @Test + fun `delete font only removes files inside desktop font store`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val storeDir = File(tempRoot, "store").apply { mkdirs() } + val stored = File(storeDir, "font_a.ttf").apply { writeText("stored") } + val outside = File(tempRoot, "outside.ttf").apply { writeText("outside") } + val store = DesktopCustomFontStore(storeDir) + + assertTrue(store.deleteFont(stored.toFontItem())) + assertFalse(stored.exists()) + assertFalse(store.deleteFont(outside.toFontItem())) + assertTrue(outside.exists()) + } finally { + tempRoot.deleteRecursively() + } + } + + @Test + fun `google font css parser extracts first https font url`() { + val css = """ + @font-face { + font-family: 'Literata'; + src: url(https://fonts.gstatic.com/s/literata/v35/font.ttf) format('truetype'); + } + """.trimIndent() + + assertEquals("https://fonts.gstatic.com/s/literata/v35/font.ttf", googleFontDownloadUrlFromCss(css)) + assertEquals("ttf", googleFontFileExtension("https://fonts.gstatic.com/s/literata/v35/font.ttf?foo=bar")) + } + + @Test + fun `google fonts json parser ignores blank names`() { + assertEquals(listOf("Inter", "Literata"), googleFontsFromJson("""["Inter", "", " Literata "]""")) + } + + @Test + fun `download google font fails before network when downloads are disabled`() { + val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile() + try { + val store = DesktopCustomFontStore( + fontsDir = File(tempRoot, "store"), + googleFontsDownloadAvailable = { false } + ) + + assertTrue(store.downloadGoogleFont("Inter").isFailure) + } finally { + tempRoot.deleteRecursively() + } + } + + private fun File.toFontItem(): CustomFontItem { + return CustomFontItem( + id = nameWithoutExtension, + displayName = nameWithoutExtension, + fileName = name, + fileExtension = extension, + path = absolutePath, + timestamp = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsingTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsingTest.kt new file mode 100644 index 0000000..257652e --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubBridgeParsingTest.kt @@ -0,0 +1,112 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.ReaderLocator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopEpubBridgeParsingTest { + @Test + fun `reader position bridge keeps semantic locator fields`() { + val position = """ + { + "pageIndex": 12, + "chapterIndex": 2, + "chapterId": "chap-2", + "href": "text/chapter2.xhtml", + "startOffset": 140, + "endOffset": 140, + "blockIndex": 9, + "charOffset": 140, + "textQuote": "quoted text", + "cfi": "desktop-scroll:10:100:/4/2:3" + } + """.trimIndent().readerPositionOrNull() + + assertEquals(12, position?.pageIndex) + assertEquals(2, position?.locator?.chapterIndex) + assertEquals("chap-2", position?.locator?.chapterId) + assertEquals("text/chapter2.xhtml", position?.locator?.href) + assertEquals(9, position?.locator?.blockIndex) + assertEquals(140, position?.locator?.charOffset) + assertEquals("quoted text", position?.locator?.textQuote) + assertEquals("/4/2:3", position?.locator?.cfi) + } + + @Test + fun `locator json sent to web view includes semantic position fields`() { + val json = ReaderLocator( + chapterIndex = 2, + chapterId = "chap-2", + href = "text/chapter2.xhtml", + pageIndex = 12, + startOffset = 140, + endOffset = 155, + blockIndex = 9, + charOffset = 140, + textQuote = "quoted text", + cfi = "/4/2:3" + ).toReaderLocatorJson() + + assertTrue(json.contains("\"chapterId\":\"chap-2\"")) + assertTrue(json.contains("\"href\":\"text/chapter2.xhtml\"")) + assertTrue(json.contains("\"blockIndex\":9")) + assertTrue(json.contains("\"charOffset\":140")) + } + + @Test + fun `selection action bridge keeps locator fields for selected tts`() { + val payload = """ + { + "action": "speak", + "text": "selected text", + "locator": { + "chapterIndex": 3, + "chapterId": "chap-3", + "href": "text/chapter3.xhtml", + "pageIndex": 41, + "startOffset": 900, + "endOffset": 913, + "blockIndex": 7, + "charOffset": 900, + "textQuote": "selected text", + "cfi": "desktop-scroll:10:20:/4/8:12|/4/8:25" + } + } + """.trimIndent().readerSelectionActionOrNull() + + assertEquals(DesktopReaderSelectionAction.SPEAK, payload?.action) + assertEquals("selected text", payload?.text) + assertEquals(3, payload?.locator?.chapterIndex) + assertEquals("chap-3", payload?.locator?.chapterId) + assertEquals("text/chapter3.xhtml", payload?.locator?.href) + assertEquals(41, payload?.locator?.pageIndex) + assertEquals(900, payload?.locator?.startOffset) + assertEquals(913, payload?.locator?.endOffset) + assertEquals(7, payload?.locator?.blockIndex) + assertEquals(900, payload?.locator?.charOffset) + assertEquals("selected text", payload?.locator?.textQuote) + assertEquals("/4/8:12|/4/8:25", payload?.locator?.cfi) + } + + @Test + fun `selection action bridge parses highlight palette manager action`() { + val payload = """ + { + "action": "palette", + "text": "selected text" + } + """.trimIndent().readerSelectionActionOrNull() + + assertEquals(DesktopReaderSelectionAction.PALETTE, payload?.action) + assertEquals("selected text", payload?.text) + } + + @Test + fun `desktop epub chrome tap script keeps click fallback for pointer-capable webviews`() { + assertTrue(DesktopEpubKeyNavigationScript.contains("var lastChromeTapNotifiedAt = 0;")) + assertTrue(DesktopEpubKeyNavigationScript.contains("function maybeNotifyChromeTapFromClick(event)")) + assertTrue(DesktopEpubKeyNavigationScript.contains("if (window.PointerEvent) {")) + assertTrue(DesktopEpubKeyNavigationScript.contains("maybeNotifyChromeTapFromClick(event);")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPaginationTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPaginationTest.kt new file mode 100644 index 0000000..cde4361 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopEpubPaginationTest.kt @@ -0,0 +1,119 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.ReaderViewportSpec +import org.dueattendant149.bookreader.shared.reader.layoutSignature +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopEpubPaginationTest { + @Test + fun `measured pagination is not ready until measured pages are applied`() { + val request = desktopPaginationRequest() + val currentPages = listOf(readerPage(text = "old page")) + val measuredPages = listOf(readerPage(text = "measured page")) + + assertFalse( + desktopMeasuredPaginationReady( + request = request, + completedRequest = request, + currentPages = currentPages, + measuredPages = measuredPages + ) + ) + } + + @Test + fun `measured pagination is ready when current pages match measured pages`() { + val request = desktopPaginationRequest() + val measuredPages = listOf(readerPage(text = "measured page")) + + assertTrue( + desktopMeasuredPaginationReady( + request = request, + completedRequest = request, + currentPages = measuredPages, + measuredPages = measuredPages + ) + ) + } + + @Test + fun `paginated display waits for completed measured pages`() { + assertFalse( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.PAGINATED, + measuredPagesApplied = false + ) + ) + assertTrue( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.PAGINATED, + measuredPagesApplied = true + ) + ) + assertTrue( + desktopPaginatedLayoutReadyForDisplay( + readingMode = ReaderReadingMode.VERTICAL, + measuredPagesApplied = false + ) + ) + } + + @Test + fun `measured chapter warm start replaces only that chapter and renumbers pages`() { + val currentPages = listOf( + readerPage(text = "chapter 0 page", chapterIndex = 0, pageIndex = 0), + readerPage(text = "chapter 1 old a", chapterIndex = 1, pageIndex = 1), + readerPage(text = "chapter 1 old b", chapterIndex = 1, pageIndex = 2), + readerPage(text = "chapter 2 page", chapterIndex = 2, pageIndex = 3) + ) + val measuredChapter = listOf( + readerPage(text = "chapter 1 measured", chapterIndex = 1, pageIndex = 1) + ) + + val pages = desktopPagesWithMeasuredChapter( + currentPages = currentPages, + chapterIndex = 1, + measuredChapterPages = measuredChapter + ) + + assertEquals(listOf(0, 1, 2), pages.map { it.pageIndex }) + assertEquals(listOf(0, 1, 2), pages.map { it.chapterIndex }) + assertEquals("chapter 1 measured", pages[1].text) + } + + private fun desktopPaginationRequest(): DesktopEpubPaginationRequest { + return DesktopEpubPaginationRequest( + bookId = "book", + chapterSignature = 1, + layoutSignature = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.SINGLE + ).layoutSignature(), + viewport = ReaderViewportSpec(widthPx = 1200, heightPx = 900), + density = DesktopEpubPaginationDensity(density = 1f, fontScale = 1f), + cacheGeneration = 0 + ) + } + + private fun readerPage( + text: String, + chapterIndex: Int = 0, + pageIndex: Int = 0 + ): ReaderPage { + return ReaderPage( + pageIndex = pageIndex, + chapterIndex = chapterIndex, + chapterTitle = "Chapter", + text = text, + startOffset = 0, + endOffset = text.length + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacementTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacementTest.kt new file mode 100644 index 0000000..50eee68 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFeatureNoticePlacementTest.kt @@ -0,0 +1,24 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopFeatureNoticePlacementTest { + @Test + fun `main notice renders only in the main window`() { + val placement = desktopFeatureNoticePlacement(readerWindowId = null) + + assertTrue(placement.rendersInMainWindow()) + assertFalse(placement.rendersInReaderWindow("reader-1")) + } + + @Test + fun `reader notice renders only in the matching reader window`() { + val placement = desktopFeatureNoticePlacement(readerWindowId = "reader-1") + + assertFalse(placement.rendersInMainWindow()) + assertTrue(placement.rendersInReaderWindow("reader-1")) + assertFalse(placement.rendersInReaderWindow("reader-2")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractorTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractorTest.kt new file mode 100644 index 0000000..50412cc --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopFolderMetadataExtractorTest.kt @@ -0,0 +1,224 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopFolderMetadataExtractorTest { + @Test + fun `direct imported epub gets text metadata and embedded cover`() = withCoverCacheDir { tempDir -> + val epub = File(tempDir, "direct.epub") + writeEpub( + target = epub, + opf = """ + + + Direct EPUB + Ada Lovelace + <p>Metadata summary</p> + + + + + + + + + """.trimIndent() + ) + val book = bookFor(epub, FileType.EPUB) + + val result = DesktopFolderMetadataExtractor.enrichImportedBooks( + books = listOf(book), + importedBookIds = setOf(book.id) + ) + + val enriched = result.books.single() + assertEquals("Direct EPUB", enriched.title) + assertEquals("Ada Lovelace", enriched.author) + assertEquals("

Metadata summary

", enriched.description) + assertEquals("Computing Notes", enriched.seriesName) + assertEquals(2.0, enriched.seriesIndex) + assertEquals("Direct EPUB", enriched.originalTitle) + assertEquals("Ada Lovelace", enriched.originalAuthor) + assertEquals("Computing Notes", enriched.originalSeriesName) + assertEquals(2.0, enriched.originalSeriesIndex) + assertEquals("

Metadata summary

", enriched.originalDescription) + assertEquals(epub.lastModified(), enriched.fileContentModifiedTimestamp) + assertTrue(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + assertEquals(1, result.stats.updatedBooks) + assertEquals(1, result.stats.coversUpdated) + } + + @Test + fun `opened epub gets embedded cover`() = withCoverCacheDir { tempDir -> + val epub = File(tempDir, "opened.epub") + writeEpub( + target = epub, + opf = """ + + + Opened EPUB + Mary Shelley + + + + + + + """.trimIndent() + ) + val book = bookFor(epub, FileType.EPUB, title = null) + + val enriched = DesktopFolderMetadataExtractor.enrichOpenedBook(book) + + assertEquals("Opened EPUB", enriched.title) + assertEquals("Mary Shelley", enriched.author) + assertTrue(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + } + + @Test + fun `direct imported text file gets generated cover`() = withCoverCacheDir { tempDir -> + val textFile = File(tempDir, "notes.txt").apply { writeText("Notes") } + val book = bookFor(textFile, FileType.TXT, title = "Notes") + + val result = DesktopFolderMetadataExtractor.enrichImportedBooks( + books = listOf(book), + importedBookIds = setOf(book.id) + ) + + val enriched = result.books.single() + assertEquals("Notes", enriched.title) + assertFalse(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + assertEquals(1, result.stats.updatedBooks) + assertEquals(1, result.stats.coversUpdated) + } + + @Test + fun `direct imported docx gets text metadata and generated cover`() = withCoverCacheDir { tempDir -> + val docx = File(tempDir, "direct.docx") + writeDocx( + target = docx, + title = "Direct DOCX", + author = "Grace Hopper", + bodyText = "Portable desktop document text." + ) + val book = bookFor(docx, FileType.DOCX, title = null) + + val result = DesktopFolderMetadataExtractor.enrichImportedBooks( + books = listOf(book), + importedBookIds = setOf(book.id) + ) + + val enriched = result.books.single() + assertEquals("Direct DOCX", enriched.title) + assertEquals("Grace Hopper", enriched.author) + assertTrue(enriched.folderTextMetadataParsed) + assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile) + assertEquals(1, result.stats.updatedBooks) + assertEquals(1, result.stats.coversUpdated) + } + + private fun withCoverCacheDir(block: (File) -> Unit) { + val tempDir = Files.createTempDirectory("reader-desktop-covers").toFile() + val oldCacheDir = System.getProperty("reader.cover.cache.dir") + System.setProperty("reader.cover.cache.dir", File(tempDir, "covers").absolutePath) + try { + block(tempDir) + } finally { + if (oldCacheDir == null) { + System.clearProperty("reader.cover.cache.dir") + } else { + System.setProperty("reader.cover.cache.dir", oldCacheDir) + } + tempDir.deleteRecursively() + } + } + + private fun bookFor( + file: File, + type: FileType, + title: String? = file.nameWithoutExtension + ): BookItem { + return BookItem( + id = file.absolutePath, + path = file.absolutePath, + type = type, + displayName = file.name, + timestamp = 1L, + title = title, + fileSize = file.length(), + isRecent = false + ) + } + + private fun writeEpub(target: File, opf: String) { + ZipOutputStream(target.outputStream()).use { zip -> + zip.putText( + "META-INF/container.xml", + """ + + + + + + """.trimIndent() + ) + zip.putText("OEBPS/content.opf", opf) + zip.putBytes("OEBPS/images/cover.png", onePixelPngBytes()) + } + } + + private fun writeDocx(target: File, title: String, author: String, bodyText: String) { + ZipOutputStream(target.outputStream()).use { zip -> + zip.putText( + "docProps/core.xml", + """ + + $title + $author + + """.trimIndent() + ) + zip.putText( + "word/document.xml", + """ + + + $bodyText + + + """.trimIndent() + ) + } + } + + private fun ZipOutputStream.putText(name: String, value: String) { + putBytes(name, value.toByteArray(Charsets.UTF_8)) + } + + private fun ZipOutputStream.putBytes(name: String, value: ByteArray) { + putNextEntry(ZipEntry(name)) + write(value) + closeEntry() + } + + private fun onePixelPngBytes(): ByteArray { + return Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt new file mode 100644 index 0000000..8b2d450 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLibraryDatabaseTest.kt @@ -0,0 +1,55 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshotJson +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopLibraryDatabaseTest { + @Test + fun `save writes readable library and backup snapshots`() { + val databaseFile = Files.createTempDirectory("reader-library-db") + .resolve("library.json") + .toFile() + val database = DesktopLibraryDatabase(databaseFile) + val snapshot = SharedLibrarySnapshot( + recentFilesLimit = 37, + openTabIds = listOf("book-a"), + activeTabBookId = "book-a" + ) + + database.save(snapshot) + + val loaded = database.load() + assertEquals(37, loaded.recentFilesLimit) + assertEquals(listOf("book-a"), loaded.openTabIds) + assertEquals("book-a", loaded.activeTabBookId) + assertTrue(databaseFile.isFile) + assertTrue(databaseFile.parentFile.resolve("library.json.bak").isFile) + } + + @Test + fun `load falls back to backup when primary library is corrupt`() { + val databaseFile = Files.createTempDirectory("reader-library-db-corrupt") + .resolve("library.json") + .toFile() + val backupSnapshot = SharedLibrarySnapshot( + recentFilesLimit = 19, + openTabIds = listOf("backup-book"), + activeTabBookId = "backup-book" + ) + databaseFile.parentFile.mkdirs() + databaseFile.writeText("""{"books":[""") + databaseFile.parentFile + .resolve("library.json.bak") + .writeText(SharedLibrarySnapshotJson.encode(backupSnapshot)) + + val loaded = DesktopLibraryDatabase(databaseFile).load() + + assertEquals(19, loaded.recentFilesLimit) + assertEquals(listOf("backup-book"), loaded.openTabIds) + assertEquals("backup-book", loaded.activeTabBookId) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt new file mode 100644 index 0000000..dd60247 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopLocalFolderSyncTest.kt @@ -0,0 +1,171 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR +import org.dueattendant149.bookreader.shared.SharedFolderBookMetadata +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.SyncedFolder +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopLocalFolderSyncTest { + @Test + fun `target folder sync imports files before desktop metadata extraction`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + val bookFile = File(root, "Notes.txt").apply { writeText("Notes") } + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState(), + shelfRefs = emptyList(), + targetFolder = root, + nowMillis = 3_000L, + extractMetadata = false + ) + + val syncedBook = result.state.rawLibraryBooks.single() + assertEquals("local_Notes.txt", syncedBook.id) + assertEquals(bookFile.absolutePath, syncedBook.path) + assertEquals(root.absolutePath, syncedBook.sourceFolder) + assertEquals(listOf(root.absolutePath), result.processedFolderUris) + assertEquals(1, result.state.syncedFolders.size) + assertEquals(1, result.stats.newBooks) + assertEquals(0, result.metadataStats.updatedBooks) + assertNull(syncedBook.coverImagePath) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `metadata-only sync imports sidecar metadata without scanning physical files`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + File(root, "New.pdf").writeText("%PDF") + val existingFile = File(root, "Existing.pdf") + val existingId = "local_Existing.pdf" + writeMetadataSidecar( + root = root, + metadata = metadata( + id = existingId, + title = "Remote Title", + progress = 72f, + modified = 2_000L + ) + ) + + val existingBook = BookItem( + id = existingId, + path = existingFile.absolutePath, + type = FileType.PDF, + displayName = existingFile.name, + timestamp = 100L, + title = "Local Title", + progressPercentage = 5f, + sourceFolder = root.absolutePath + ) + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(existingBook), + syncedFolders = listOf(syncedFolder(root)) + ), + shelfRefs = emptyList(), + nowMillis = 3_000L, + metadataOnly = true + ) + + assertEquals(1, result.state.rawLibraryBooks.size) + val syncedBook = result.state.rawLibraryBooks.single() + assertEquals(existingId, syncedBook.id) + assertEquals("Local Title", syncedBook.title) + assertEquals(72f, syncedBook.progressPercentage) + assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_New.pdf" }) + assertEquals(0, result.stats.scannedFiles) + assertEquals(0, result.stats.newBooks) + assertEquals(0, result.stats.removedBooks) + assertEquals(1, result.stats.remoteMetadataUpdates) + assertTrue(result.processedFolderUris.contains(root.absolutePath)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `disabled folder is not scanned or written`() { + val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile() + try { + File(root, "Notes.txt").writeText("Notes") + val existingBook = BookItem( + id = "local_Existing.pdf", + path = File(root, "Existing.pdf").absolutePath, + type = FileType.PDF, + displayName = "Existing.pdf", + timestamp = 100L, + progressPercentage = 50f, + sourceFolder = root.absolutePath + ) + + val result = DesktopLocalFolderSync.sync( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(existingBook), + syncedFolders = listOf(syncedFolder(root).copy(localSyncEnabled = false)) + ), + shelfRefs = emptyList(), + nowMillis = 3_000L + ) + + assertEquals(listOf(existingBook), result.state.rawLibraryBooks) + assertTrue(result.processedFolderUris.isEmpty()) + assertEquals(0, result.stats.newBooks) + assertTrue(!File(root, LOCAL_FOLDER_SYNC_DATA_DIR).exists()) + } finally { + root.deleteRecursively() + } + } + + private fun syncedFolder(root: File): SyncedFolder { + return SyncedFolder( + uriString = root.absolutePath, + name = root.name, + lastScanTime = 0L, + allowedFileTypes = setOf(FileType.PDF) + ) + } + + private fun writeMetadataSidecar(root: File, metadata: SharedFolderBookMetadata) { + val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() } + File(syncDir, ".${metadata.bookId}.json").writeText(metadata.toJsonString()) + } + + private fun metadata( + id: String, + title: String, + progress: Float, + modified: Long + ): SharedFolderBookMetadata { + return SharedFolderBookMetadata( + bookId = id, + title = title, + author = null, + displayName = "Existing.pdf", + type = FileType.PDF.name, + lastChapterIndex = null, + lastPage = null, + lastPositionCfi = null, + progressPercentage = progress, + isRecent = true, + lastModifiedTimestamp = modified, + bookmarksJson = null, + locatorBlockIndex = null, + locatorCharOffset = null, + customName = null, + highlightsJson = null + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt new file mode 100644 index 0000000..4ea13be --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopOpdsRepositoryTest.kt @@ -0,0 +1,118 @@ +package org.dueattendant149.bookreader.desktop + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class DesktopOpdsRepositoryTest { + @Test + fun `desktop repository persists shared opds catalog rules`() = withTempDir { dir -> + var nextId = 0 + val repository = DesktopOpdsRepository( + catalogFile = File(dir, "opds_catalogs.json"), + idFactory = { "catalog-${nextId++}" } + ) + + val defaults = repository.loadCatalogs() + assertEquals(2, defaults.size) + assertTrue(defaults.all { it.isDefault }) + + repository.addCatalogForTest(" Custom ", " https://example.org/opds ", " user ", " pass ") + val custom = repository.loadCatalogs().single { !it.isDefault } + assertEquals("Custom", custom.title) + assertEquals("https://example.org/opds", custom.url) + assertEquals("user", custom.username) + assertEquals("pass", custom.password) + } + + @Test + fun `desktop opds http blocks before network in offline flavor`() { + withSystemProperty(DesktopFlavorProperty, DesktopFlavorOssOffline) { + assertFailsWith { + DesktopOpdsHttp.fetchString("https://example.org/opds", null, null) + } + } + } + + @Test + fun `desktop opds http creates basic authorization header for challenged catalogs`() { + assertEquals( + "Basic dXNlcjpwYXNz", + DesktopOpdsHttp.authorizationHeaderForChallenge( + challenge = "Basic realm=\"Catalog\"", + url = "https://example.org/opds", + username = "user", + password = "pass" + ) + ) + } + + @Test + fun `desktop opds http creates digest authorization header for challenged catalogs`() { + assertEquals( + "Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"abcdef\", " + + "uri=\"/dir/index.atom?x=1\", response=\"ca833912ad1f4339630e23476d538d67\", " + + "qop=auth, nc=00000001, cnonce=\"0a4f113b\", opaque=\"xyz\"", + DesktopOpdsHttp.authorizationHeaderForChallenge( + challenge = "Digest realm=\"testrealm@host.com\", nonce=\"abcdef\", qop=\"auth\", opaque=\"xyz\"", + url = "https://example.org/dir/index.atom?x=1", + username = "Mufasa", + password = "Circle Of Life", + cnonce = "0a4f113b", + nonceCount = "00000001" + ) + ) + } + + private fun DesktopOpdsRepository.addCatalogForTest( + title: String, + url: String, + username: String?, + password: String? + ) { + saveCatalogs( + org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs.addCatalog( + catalogs = loadCatalogs(), + title = title, + url = url, + username = username, + password = password, + idFactory = { "custom" } + ) + ) + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-desktop-opds").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun withSystemProperty( + key: String, + value: String?, + block: () -> Unit + ) { + val previous = System.getProperty(key) + try { + if (value == null) { + System.clearProperty(key) + } else { + System.setProperty(key, value) + } + block() + } finally { + if (previous == null) { + System.clearProperty(key) + } else { + System.setProperty(key, previous) + } + } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt new file mode 100644 index 0000000..28541ab --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPaidAiUsageTest.kt @@ -0,0 +1,15 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPaidAiUsageTest { + @Test + fun `desktop paid AI usage applies an optimistic integer credit decrement`() { + assertEquals(9, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 1.0)) + assertEquals(7, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 2.2)) + assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 0.0)) + assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = null)) + assertEquals(0, desktopCreditsAfterPaidAiUsage(currentCredits = 1, cost = 4.0)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt new file mode 100644 index 0000000..cc7376e --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfFileActionsTest.kt @@ -0,0 +1,138 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.text.AnnotatedString +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.PdfPageBounds +import org.dueattendant149.bookreader.shared.pdf.PdfPagePoint +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfRichPageLayout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopPdfFileActionsTest { + + @Test + fun `desktop pdf suggested filename follows android suffix format`() { + assertEquals( + "My_PDF_annotated_1234.pdf", + desktopSuggestedPdfFilename("My PDF.pdf", isAnnotated = true, shortId = "1234") + ) + assertEquals( + "My_PDF_1234.pdf", + desktopSuggestedPdfFilename("My PDF.pdf", isAnnotated = false, shortId = "1234") + ) + } + + @Test + fun `desktop pdf export choice waits for sidecars before defaulting to original`() { + assertTrue( + shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady = false, + annotations = emptyList(), + richTextPageLayouts = emptyList() + ) + ) + assertFalse( + shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady = true, + annotations = emptyList(), + richTextPageLayouts = emptyList() + ) + ) + } + + @Test + fun `desktop pdf export choice appears for exportable annotations`() { + val ink = SharedPdfAnnotation( + id = "ink", + pageIndex = 0, + kind = PdfAnnotationKind.INK, + tool = PdfInkTool.PEN, + points = listOf(PdfPagePoint(0.1f, 0.1f), PdfPagePoint(0.2f, 0.2f)), + colorArgb = 0xFF000000.toInt() + ) + val highlight = SharedPdfAnnotation( + id = "highlight", + pageIndex = 0, + kind = PdfAnnotationKind.HIGHLIGHT, + bounds = PdfPageBounds(0.1f, 0.1f, 0.3f, 0.2f), + colorArgb = 0x66FFFF00 + ) + + assertTrue( + shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady = true, + annotations = listOf(ink, highlight), + richTextPageLayouts = emptyList() + ) + ) + } + + @Test + fun `desktop pdf export choice ignores non-exportable ink by itself`() { + val eraser = SharedPdfAnnotation( + id = "eraser", + pageIndex = 0, + kind = PdfAnnotationKind.INK, + tool = PdfInkTool.ERASER, + points = listOf(PdfPagePoint(0.1f, 0.1f), PdfPagePoint(0.2f, 0.2f)), + colorArgb = 0x00000000 + ) + + assertFalse( + shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady = true, + annotations = listOf(eraser), + richTextPageLayouts = emptyList() + ) + ) + } + + @Test + fun `desktop pdf export choice appears for recomputable highlight ranges`() { + val highlight = SharedPdfAnnotation( + id = "highlight-range", + pageIndex = 0, + kind = PdfAnnotationKind.HIGHLIGHT, + colorArgb = 0x66FFFF00, + rangeStartIndex = 4, + rangeEndIndex = 12 + ) + + assertTrue( + shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady = true, + annotations = listOf(highlight), + richTextPageLayouts = emptyList() + ) + ) + } + + @Test + fun `desktop pdf export choice appears for rich text layouts`() { + val richLayout = SharedPdfRichPageLayout( + pageIndex = 0, + visibleText = AnnotatedString("Margin note"), + globalStartIndex = 0, + globalEndIndex = 11, + pageHeightPx = 1200f + ) + + assertTrue( + shouldShowDesktopPdfAnnotationExportChoice( + sidecarsReady = true, + annotations = emptyList(), + richTextPageLayouts = listOf(richLayout) + ) + ) + } + + @Test + fun `desktop pdf password errors can be detected through wrappers`() { + assertTrue(DesktopPdfPasswordException("locked.pdf").isDesktopPdfPasswordException()) + assertTrue(RuntimeException(DesktopPdfPasswordException("locked.pdf")).isDesktopPdfPasswordException()) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt new file mode 100644 index 0000000..8d7dc06 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfNavigationSidebarTest.kt @@ -0,0 +1,56 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopPdfNavigationSidebarTest { + @Test + fun `sidebar highlights exclude ink and text annotations`() { + val result = desktopPdfSidebarHighlights( + listOf( + annotation(id = "ink", pageIndex = 0, kind = PdfAnnotationKind.INK, createdAt = 1L), + annotation(id = "later-highlight", pageIndex = 2, kind = PdfAnnotationKind.HIGHLIGHT, createdAt = 4L), + annotation(id = "text", pageIndex = 1, kind = PdfAnnotationKind.TEXT, createdAt = 1L), + annotation( + id = "first-same-page-highlight", + pageIndex = 1, + kind = PdfAnnotationKind.HIGHLIGHT, + createdAt = 3L + ), + annotation( + id = "second-same-page-highlight", + pageIndex = 1, + kind = PdfAnnotationKind.HIGHLIGHT, + createdAt = 2L + ) + ) + ) + + assertEquals( + listOf("first-same-page-highlight", "second-same-page-highlight", "later-highlight"), + result.map { it.id } + ) + assertTrue(result.all { it.kind == PdfAnnotationKind.HIGHLIGHT }) + } + + private fun annotation( + id: String, + pageIndex: Int, + kind: PdfAnnotationKind, + createdAt: Long + ): SharedPdfAnnotation { + return SharedPdfAnnotation( + id = id, + pageIndex = pageIndex, + kind = kind, + tool = if (kind == PdfAnnotationKind.TEXT) PdfInkTool.TEXT else PdfInkTool.HIGHLIGHTER, + text = "selected text", + colorArgb = 0x55FFEB3B, + createdAt = createdAt + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt new file mode 100644 index 0000000..7339f3d --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfReflowTest.kt @@ -0,0 +1,93 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.SharedLibraryStateProjector +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.ui.toNonReaderLibraryOrganizationModel +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopPdfReflowTest { + @Test + fun `desktop reflow ids and labels match Android text view convention`() { + assertEquals("abc_reflow", desktopPdfReflowBookId("abc")) + assertTrue(isDesktopPdfReflowBookId("abc_reflow")) + assertEquals("Source (Text View)", desktopPdfReflowDisplayName("Source")) + assertEquals("Source (Reflow)", desktopPdfReflowTitle("Source")) + assertEquals("Generated", desktopPdfReflowGeneratedAuthor()) + } + + @Test + fun `desktop reflow filename is safe for path-like book ids`() { + val fileName = desktopPdfReflowFileName("C:/Books/My Source.pdf", "My Source") + + assertEquals("C__Books_My_Source.pdf_reflow.html", fileName) + } + + @Test + fun `desktop reflow book item maps generated html into text reader format`() { + val source = BookItem( + id = "pdf-id", + path = "C:/Books/source.pdf", + type = FileType.PDF, + displayName = "source.pdf", + timestamp = 1L, + title = "Source" + ) + val generatedFile = File("build/test-tmp/source_reflow.html") + + val item = desktopPdfReflowBookItem( + sourceBook = source, + generatedFile = generatedFile, + nowMillis = 42L, + initialPageIndex = 7 + ) + + assertEquals("pdf-id_reflow", item.id) + assertEquals(FileType.HTML, item.type) + assertEquals(generatedFile.absolutePath, item.path) + assertEquals("Source (Text View)", item.displayName) + assertEquals("Source (Reflow)", item.title) + assertEquals("Generated", item.author) + assertEquals(42L, item.timestamp) + assertEquals(7, item.lastPageIndex) + assertTrue(item.isRecent) + } + + @Test + fun `desktop library projection hides generated reflow books but keeps tabs open`() { + val source = BookItem( + id = "pdf-id", + path = "C:/Books/source.pdf", + type = FileType.PDF, + displayName = "source.pdf", + timestamp = 1L, + title = "Source" + ) + val reflow = desktopPdfReflowBookItem( + sourceBook = source, + generatedFile = File("build/test-tmp/source_reflow.html"), + nowMillis = 2L, + initialPageIndex = 3 + ) + val projected = SharedLibraryStateProjector().projectDesktopLibraryState( + state = SharedReaderScreenState( + rawLibraryBooks = listOf(source, reflow), + openTabIds = listOf(reflow.id), + activeTabBookId = reflow.id + ), + shelfRecords = emptyList(), + shelfRefs = emptyList() + ) + + assertEquals(listOf(source.id), projected.libraryBooks.map { it.id }) + assertTrue(projected.rawLibraryBooks.any { it.id == reflow.id }) + assertTrue(projected.recentBooks.none { it.id == reflow.id }) + assertEquals(1, projected.toNonReaderLibraryOrganizationModel().allBooksCount) + assertEquals(listOf(reflow.id), projected.openTabIds) + assertEquals(reflow.id, projected.activeTabBookId) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt new file mode 100644 index 0000000..333f4bf --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfScrubbingTest.kt @@ -0,0 +1,60 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPdfScrubbingTest { + @Test + fun `scrub target clamps to valid page range`() { + val settings = ReaderSettings() + + assertEquals( + 0, + desktopPdfPageScrubTarget( + value = -10f, + pageCount = 6, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = settings + ) + ) + assertEquals( + 5, + desktopPdfPageScrubTarget( + value = 99f, + pageCount = 6, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = settings + ) + ) + } + + @Test + fun `paginated scrub target normalizes to spread start`() { + val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE) + + assertEquals( + 2, + desktopPdfPageScrubTarget( + value = 3f, + pageCount = 8, + displayMode = PdfDisplayMode.PAGINATION, + settings = settings + ) + ) + } + + @Test + fun `scrub commit prefers preview before page state catches up`() { + assertEquals( + 7, + desktopPdfPageScrubCommitTarget( + previewPage = 7, + currentPage = 2, + pageCount = 10 + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt new file mode 100644 index 0000000..c47a802 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfSidecarsTest.kt @@ -0,0 +1,16 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class DesktopPdfSidecarsTest { + @Test + fun `pdf sidecar keys avoid String hashCode collisions`() { + val first = desktopPdfDocumentKey("C:/Books/Aa.pdf") + val second = desktopPdfDocumentKey("C:/Books/BB.pdf") + + assertTrue("C:/Books/Aa.pdf".hashCode() == "C:/Books/BB.pdf".hashCode()) + assertNotEquals(first, second) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt new file mode 100644 index 0000000..19562e0 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfTextHighlightStateTest.kt @@ -0,0 +1,69 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind +import org.dueattendant149.bookreader.shared.pdf.PdfInkTool +import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderAction +import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState +import org.dueattendant149.bookreader.shared.pdf.reduce +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopPdfTextHighlightStateTest { + @Test + fun `text selection highlight keeps chosen text selection mode after creation`() { + val annotation = textSelectionHighlight() + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + + val next = state.withDesktopPdfTextSelectionHighlightAdded(annotation) + + assertEquals(listOf(annotation), next.annotations) + assertTrue(next.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, next.selectedTool) + assertNull(next.selectedAnnotationId) + } + + @Test + fun `dismissing selected text highlight sheet keeps chosen text selection mode`() { + val annotation = textSelectionHighlight() + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) + + val next = state.withDesktopPdfTextHighlightSheetDismissed() + + assertTrue(next.isTextSelectionMode) + assertEquals(PdfInkTool.NONE, next.selectedTool) + assertNull(next.selectedAnnotationId) + } + + @Test + fun `dismissing non text highlight annotation keeps text selection mode unchanged`() { + val annotation = textSelectionHighlight().copy(rangeStartIndex = null, rangeEndIndex = null) + val state = SharedPdfReaderState.initial(pageCount = 1) + .reduce(SharedPdfReaderAction.TextSelectionModeChanged(true)) + .copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id) + + val next = state.withDesktopPdfTextHighlightSheetDismissed() + + assertTrue(next.isTextSelectionMode) + assertNull(next.selectedAnnotationId) + } + + private fun textSelectionHighlight(): SharedPdfAnnotation { + return SharedPdfAnnotation( + id = "highlight-1", + pageIndex = 0, + kind = PdfAnnotationKind.HIGHLIGHT, + tool = PdfInkTool.HIGHLIGHTER, + text = "selected text", + colorArgb = 0x55FFEB3B, + rangeStartIndex = 1, + rangeEndIndex = 12, + createdAt = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt new file mode 100644 index 0000000..09648a7 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPdfThemeTest.kt @@ -0,0 +1,84 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderTheme +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPdfThemeTest { + @Test + fun `desktop pdf defaults to paginated display mode`() { + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) + assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap) + assertEquals(18.dp, DesktopDefaultPdfSpreadPageGap) + } + + @Test + fun `page background follows android pdf theme defaults`() { + val noTheme = ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false) + val reverse = ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true) + val sepia = ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false) + + assertEquals(Color.White, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.VERTICAL_SCROLL)) + assertEquals(Color.Black, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.PAGINATION)) + assertEquals(Color.Black, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.VERTICAL_SCROLL)) + assertEquals(Color.White, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.PAGINATION)) + assertEquals(Color(0xFFFBF0D9), desktopPdfPageBackgroundColor(sepia, PdfDisplayMode.VERTICAL_SCROLL)) + } + + @Test + fun `vertical viewport uses app gap color only when page gaps are visible`() { + val pageBackground = Color.White + val gapBackground = Color(0xFFE2E2E2) + + assertEquals( + gapBackground, + desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pageBackground, + gapBackgroundColor = gapBackground, + isPageGapVisible = true + ) + ) + assertEquals( + pageBackground, + desktopPdfVerticalViewportBackgroundColor( + pageBackgroundColor = pageBackground, + gapBackgroundColor = gapBackground, + isPageGapVisible = false + ) + ) + } + + @Test + fun `pagination viewport uses app theme color outside pages`() { + val pageBackground = Color.Black + val appBackground = Color(0xFFE2E2E2) + + assertEquals( + appBackground, + desktopPdfViewportBackgroundColor( + displayMode = PdfDisplayMode.PAGINATION, + pageBackgroundColor = pageBackground, + appBackgroundColor = appBackground, + isVerticalPageGapVisible = false + ) + ) + assertEquals( + appBackground, + desktopPdfViewportBackgroundColor( + displayMode = PdfDisplayMode.PAGINATION, + pageBackgroundColor = pageBackground, + appBackgroundColor = appBackground, + isVerticalPageGapVisible = true + ) + ) + } + + @Test + fun `spread page gap follows pdf page gap visibility setting`() { + assertEquals(18.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = true)) + assertEquals(0.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = false)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt new file mode 100644 index 0000000..95f55de --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPlatformPathsTest.kt @@ -0,0 +1,70 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopPlatformPathsTest { + @Test + fun `desktop platform detects linux x64 resource names`() { + val platform = currentDesktopPlatform(osName = "Linux", osArch = "amd64") + + assertEquals(DesktopOperatingSystem.LINUX, platform.os) + assertEquals(DesktopArchitecture.X64, platform.architecture) + assertEquals("linux-x64-v8", platform.pdfiumDirectoryName) + assertEquals("lib", platform.pdfiumLibraryDirectoryName) + assertEquals("libpdfium.so", platform.pdfiumLibraryFileName) + } + + @Test + fun `desktop platform keeps existing windows resource names`() { + val platform = currentDesktopPlatform(osName = "Windows 11", osArch = "amd64") + + assertEquals(DesktopOperatingSystem.WINDOWS, platform.os) + assertEquals(DesktopArchitecture.X64, platform.architecture) + assertEquals("win-x64-v8", platform.pdfiumDirectoryName) + assertEquals("bin", platform.pdfiumLibraryDirectoryName) + assertEquals("pdfium.dll", platform.pdfiumLibraryFileName) + } + + @Test + fun `linux user directories follow xdg environment variables`() { + val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val env = mapOf( + "XDG_DATA_HOME" to "/tmp/xdg-data", + "XDG_CONFIG_HOME" to "/tmp/xdg-config", + "XDG_CACHE_HOME" to "/tmp/xdg-cache" + ) + + assertEquals("/tmp/xdg-data/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/tmp/xdg-config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/tmp/xdg-cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath()) + } + + @Test + fun `linux user directories ignore relative xdg environment values`() { + val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val env = mapOf( + "XDG_DATA_HOME" to "relative-data", + "XDG_CONFIG_HOME" to "relative-config", + "XDG_CACHE_HOME" to "relative-cache" + ) + + assertEquals("/home/reader/.local/share/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/home/reader/.config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath()) + assertEquals("/home/reader/.cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath()) + } + + @Test + fun `windows user directories keep appdata compatible root`() { + val platform = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming") + + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserDataRoot(platform, env::get, "C:/Users/reader").portablePath()) + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserConfigRoot(platform, env::get, "C:/Users/reader").portablePath()) + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserCacheRoot(platform, env::get, "C:/Users/reader").portablePath()) + } +} + +private fun java.io.File.portablePath(): String { + return path.replace('\\', '/') +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt new file mode 100644 index 0000000..387ce4b --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopPptxDocumentTest.kt @@ -0,0 +1,154 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.pptx.SharedPptxDeckCache +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopPptxDocumentTest { + @Test + fun `pptx document loads text links and renderable slides for pdf reader surface`() = withTempDir { dir -> + val pptx = File(dir, "slides.pptx") + writeMinimalPptx(pptx) + + val sharedDeck = SharedPptxDeckCache.load(pptx) + assertEquals(1, sharedDeck.slides.size) + assertTrue(sharedDeck.slides.single().text.contains("Hello PPTX")) + + val document = DesktopPdfium.loadPptx(pptx) + try { + assertEquals("PPTX", document.formatLabel) + assertEquals(1, document.pageCount) + assertEquals(720f, document.pageSizes.single().width) + assertEquals(540f, document.pageSizes.single().height) + + val textPage = document.textPageData(0) + assertTrue(textPage.text.contains("Hello PPTX")) + assertTrue(textPage.chars.isNotEmpty()) + assertNotNull(DesktopPdfium.charIndexAt(document, pageIndex = 0, normalizedX = 0.13f, normalizedY = 0.16f)) + assertTrue(DesktopPdfium.textRectsForRange(document, pageIndex = 0, startIndex = 0, endIndex = 4).isNotEmpty()) + + val link = DesktopPdfium.linkAt(document, pageIndex = 0, normalizedX = 0.3f, normalizedY = 0.2f) + assertEquals("https://example.com/slides", link?.uri) + + val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 1f) + assertEquals(720, image.width) + assertEquals(540, image.height) + } finally { + document.close() + } + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-desktop-pptx").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun writeMinimalPptx(file: File) { + ZipOutputStream(file.outputStream()).use { zip -> + zip.writeEntry( + "[Content_Types].xml", + """ + + + + + + + """.trimIndent() + ) + zip.writeEntry( + "ppt/presentation.xml", + """ + + + + + + + """.trimIndent() + ) + zip.writeEntry( + "ppt/_rels/presentation.xml.rels", + """ + + + + """.trimIndent() + ) + zip.writeEntry( + "ppt/slides/slide1.xml", + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hello PPTX + + + + + + + + """.trimIndent() + ) + zip.writeEntry( + "ppt/slides/_rels/slide1.xml.rels", + """ + + + + """.trimIndent() + ) + } + } + + private fun ZipOutputStream.writeEntry(name: String, contents: String) { + putNextEntry(ZipEntry(name)) + write(contents.toByteArray(Charsets.UTF_8)) + closeEntry() + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt new file mode 100644 index 0000000..6e81891 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderDefaultsTest.kt @@ -0,0 +1,537 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.dueattendant149.bookreader.shared.BookItem +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.PdfDisplayMode +import org.dueattendant149.bookreader.shared.ReaderPlatform +import org.dueattendant149.bookreader.shared.SharedFileCapabilities +import org.dueattendant149.bookreader.shared.SharedLibrarySnapshot +import org.dueattendant149.bookreader.shared.pdf.PdfZoomSpec +import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopReaderDefaultsTest { + + @Test + fun `desktop open book dialog accepts every shared desktop readable format`() { + assertEquals( + SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP), + desktopBookFileTypesForDialog() + ) + assertTrue(FileType.PDF in desktopBookFileTypesForDialog()) + } + + @Test + fun `desktop uses global reader defaults when book has no local settings`() { + val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) + val book = bookItem("without-local") + + assertEquals(defaults, resolvedDesktopReaderSettings(book, defaults)) + } + + @Test + fun `desktop keeps local book reader settings ahead of global defaults`() { + val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL) + val local = ReaderSettings(fontSize = 17, readingMode = ReaderReadingMode.PAGINATED, themeId = "sepia") + val book = bookItem("with-local").copy(readerSettings = local) + + assertEquals(local, resolvedDesktopReaderSettings(book, defaults)) + } + + @Test + fun `desktop library defaults migrate untouched reader defaults to two page pagination`() { + val migrated = SharedLibrarySnapshot().withDesktopDefaults() + + assertEquals(DesktopReaderDefaultsVersion, migrated.desktopReaderDefaultsVersion) + assertEquals(ReaderReadingMode.PAGINATED, migrated.readerDefaultSettings.readingMode) + assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.readerDefaultSettings.pageSpreadMode) + assertEquals(ReaderReadingMode.PAGINATED, migrated.pdfReaderDefaultSettings.readingMode) + assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.pdfReaderDefaultSettings.pageSpreadMode) + assertEquals("no_theme", migrated.pdfReaderDefaultSettings.themeId) + } + + @Test + fun `desktop reader settings engines are separated by shared reader surface`() { + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.EPUB.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.MOBI.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.DOCX.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PDF.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBZ.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBT.desktopReaderSettingsEngine()) + assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PPTX.desktopReaderSettingsEngine()) + } + + @Test + fun `desktop engine settings update only matching reader family books`() { + val textSettings = ReaderSettings(themeId = "sepia", readingMode = ReaderReadingMode.PAGINATED) + val pdfSettings = ReaderSettings(themeId = "reverse", readingMode = ReaderReadingMode.PAGINATED) + val books = listOf( + bookItem("epub"), + bookItem("mobi").copy(path = "C:/Books/mobi.mobi", type = FileType.MOBI, displayName = "mobi.mobi"), + bookItem("pdf").copy(path = "C:/Books/pdf.pdf", type = FileType.PDF, displayName = "pdf.pdf") + ) + + val withTextDefaults = books.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.TEXT, textSettings) + assertEquals(textSettings, withTextDefaults[0].readerSettings) + assertEquals(textSettings, withTextDefaults[1].readerSettings) + assertEquals(null, withTextDefaults[2].readerSettings) + + val withPdfDefaults = withTextDefaults.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.PDF, pdfSettings) + assertEquals(textSettings, withPdfDefaults[0].readerSettings) + assertEquals(textSettings, withPdfDefaults[1].readerSettings) + assertEquals(pdfSettings, withPdfDefaults[2].readerSettings) + } + + @Test + fun `desktop pdf display mode is carried by pdf reader settings`() { + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode) + assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfReaderSettings.toDesktopPdfDisplayMode()) + assertEquals( + PdfDisplayMode.VERTICAL_SCROLL, + ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).toDesktopPdfDisplayMode() + ) + } + + @Test + fun `desktop pdf initial page is normalized before paginated spread display`() { + val spreadSettings = ReaderSettings( + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ) + + assertEquals( + 2, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.PAGINATION, + settings = spreadSettings + ) + ) + assertEquals( + 3, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.VERTICAL_SCROLL, + settings = spreadSettings + ) + ) + assertEquals( + 3, + desktopPdfInitialPageIndex( + requestedPageIndex = 3, + pageCount = 10, + displayMode = PdfDisplayMode.PAGINATION, + settings = spreadSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE) + ) + ) + } + + @Test + fun `desktop pdf zoom allows deeper page magnification`() { + val sharedDefaultMax = PdfZoomSpec().max + val letterPageScale = DesktopPdfZoomSpec.safeRenderScale( + pageWidth = 612f, + pageHeight = 792f, + requestedScale = 6f + ) + + assertEquals(8f, DesktopPdfZoomSpec.max) + assertTrue(letterPageScale > sharedDefaultMax) + } + + @Test + fun `desktop pdf touchpad zoom factors zoom in and out`() { + val zoomSpec = PdfZoomSpec(min = 0.5f, max = 8f, default = 1f) + + assertTrue(desktopPdfScrollZoomFactor(-1f) > 1.1f) + assertTrue(desktopPdfScrollZoomFactor(1f) < 0.9f) + assertEquals(8f, desktopPdfZoomTarget(currentZoom = 7.8f, zoomSpec = zoomSpec, factor = 2f)) + assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f)) + } + + @Test + fun `desktop pdf page navigation commits pending zoom preview position`() { + val preview = DesktopPdfZoomPreview( + baseZoom = 1f, + zoom = 2f, + anchor = Offset(100f, 80f), + displayMode = PdfDisplayMode.PAGINATION, + pageIndex = 0 + ) + val snapshot = desktopPdfNavigationZoomSnapshot( + preview = preview, + currentHorizontalScroll = 40, + currentVerticalScroll = 20 + ) ?: error("Expected navigation zoom snapshot") + + assertEquals(2f, snapshot.zoom) + assertEquals(180, snapshot.horizontalScroll) + assertEquals(120, snapshot.verticalScroll) + } + + @Test + fun `desktop paginated pdf page changes avoid high resolution first render`() { + assertEquals( + DesktopPdfPaginationFastFirstRenderMaxScale, + desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = false) + ) + assertEquals( + 6f, + desktopPdfPaginationFirstRenderScale( + requestedScale = 6f, + hasPageRender = false, + isOpeningRender = true + ) + ) + assertEquals( + 6f, + desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = true) + ) + assertEquals( + 1.25f, + desktopPdfPaginationFirstRenderScale(requestedScale = 1.25f, hasPageRender = false) + ) + assertEquals( + 0.75f, + desktopPdfPaginationFirstRenderScale(requestedScale = 0.75f, hasPageRender = false) + ) + } + + @Test + fun `desktop pdf only displays renders for the requested page`() { + assertTrue(desktopPdfRenderBelongsToPage(renderedPageIndex = 0, requestedPageIndex = 0)) + assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = null, requestedPageIndex = 0)) + assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = 1, requestedPageIndex = 0)) + } + + @Test + fun `desktop pdf render scale rerenders only for missing or lower quality renders`() { + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = null, requestedScale = 1f)) + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.02f)) + assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = Float.NaN, requestedScale = 1f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.005f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 2f, requestedScale = 1f)) + assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = Float.NaN)) + } + + @Test + fun `desktop pdf spread zoom anchors to page under cursor`() { + val visiblePages = listOf(199, 200) + val pageRoots = mapOf( + 199 to Offset(424f, 30f), + 200 to Offset(972f, 30f) + ) + val pageSizes = mapOf( + 199 to IntSize(525, 693), + 200 to IntSize(525, 693) + ) + + assertEquals( + 200, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = Offset(1048.75f, 465f), + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + assertEquals( + 199, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = Offset(500f, 465f), + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + assertEquals( + 199, + desktopPdfSpreadZoomAnchorPageIndex( + viewportRootOffset = Offset.Zero, + anchor = null, + visiblePageIndices = visiblePages, + pageRootOffsets = pageRoots, + pageSizes = pageSizes, + fallbackPageIndex = 199 + ) + ) + val fittedSpread = desktopPdfSpreadLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + visiblePageIndices = visiblePages, + pageCanvasSizes = mapOf( + 199 to IntSize(667, 881), + 200 to IntSize(667, 881) + ), + horizontalScroll = 0, + verticalScroll = 112, + paddingPx = 30f, + pageGapPx = 22.5f + ) ?: error("Expected fitted spread prediction") + assertEquals(0, fittedSpread.maxHorizontalScroll) + assertEquals(0, fittedSpread.maxVerticalScroll) + assertEquals(282f, fittedSpread.pageRootOffsets[199]?.x ?: -1f, 0.5f) + assertEquals(972f, fittedSpread.pageRootOffsets[200]?.x ?: -1f, 0.5f) + assertEquals(30f, fittedSpread.pageRootOffsets[200]?.y ?: -1f, 0.0001f) + + val scrollableSpread = desktopPdfSpreadLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + visiblePageIndices = visiblePages, + pageCanvasSizes = mapOf( + 199 to IntSize(1371, 1810), + 200 to IntSize(1371, 1810) + ), + horizontalScroll = 696, + verticalScroll = 575, + paddingPx = 30f, + pageGapPx = 22.5f + ) ?: error("Expected scrollable spread prediction") + assertEquals(905, scrollableSpread.maxHorizontalScroll) + assertEquals(879, scrollableSpread.maxVerticalScroll) + assertEquals(-666f, scrollableSpread.pageRootOffsets[199]?.x ?: 0f, 0.5f) + assertEquals(728f, scrollableSpread.pageRootOffsets[200]?.x ?: 0f, 0.5f) + assertEquals(-545f, scrollableSpread.pageRootOffsets[200]?.y ?: 0f, 0.0001f) + } + + @Test + fun `desktop pdf zoom preview bridges committed anchored zoom`() { + val preview = DesktopPdfZoomPreview( + baseZoom = 1f, + zoom = 2f, + anchor = Offset(100f, 100f), + displayMode = PdfDisplayMode.PAGINATION, + pageIndex = 0, + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset.Zero + ) + + assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 1f)) + assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 2f)) + assertFalse(desktopPdfZoomPreviewMatchesScale(preview, 1.5f)) + assertEquals( + Offset(-100f, -100f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f + ) + ) + assertEquals( + Offset.Zero, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset(-100f, -100f), + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f + ) + ) + assertEquals( + null, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 1f + ) + ) + assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 0, requestedDelta = 100)) + assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 200, requestedDelta = -40)) + assertEquals(-40, desktopPdfReachableScrollDelta(currentScroll = 80, maxScroll = 200, requestedDelta = -40)) + assertEquals( + Offset.Zero, + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = 0, + maxHorizontalScroll = 0, + currentVerticalScroll = 0, + maxVerticalScroll = 0 + ) + ) + ) + assertEquals( + Offset(-50f, -25f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = DesktopPdfZoomScrollBounds( + currentHorizontalScroll = 0, + maxHorizontalScroll = 50, + currentVerticalScroll = 0, + maxVerticalScroll = 25 + ) + ) + ) + val pendingCommitBounds = desktopPdfZoomScrollBoundsWithCommitTargets( + preview = preview.copy( + commitTargetHorizontalScroll = 300, + commitTargetVerticalScroll = 300 + ), + currentHorizontalScroll = 0, + maxHorizontalScroll = 0, + currentVerticalScroll = 0, + maxVerticalScroll = 0 + ) + assertEquals(300, pendingCommitBounds.maxHorizontalScroll) + assertEquals(300, pendingCommitBounds.maxVerticalScroll) + assertEquals( + Offset(-100f, -100f), + desktopPdfZoomCommitPreviewTranslation( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentAnchorPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f, + currentZoom = 2f, + scrollBounds = pendingCommitBounds + ) + ) + val fittingPagePrediction = desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + pageCanvasSize = IntSize(1216, 1605), + horizontalScroll = 0, + verticalScroll = 0, + paddingPx = 30f + ) ?: error("Expected fitting page prediction") + assertEquals(Offset(352f, 30f), fittingPagePrediction.rootOffset) + assertEquals(0, fittingPagePrediction.maxHorizontalScroll) + assertEquals(674, fittingPagePrediction.maxVerticalScroll) + + val oversizedPagePrediction = desktopPdfSinglePageLayoutPrediction( + viewportRootOffset = Offset.Zero, + viewportSize = IntSize(1920, 991), + pageCanvasSize = IntSize(2498, 3298), + horizontalScroll = 409, + verticalScroll = 1122, + paddingPx = 30f + ) ?: error("Expected oversized page prediction") + assertEquals(Offset(-379f, -1092f), oversizedPagePrediction.rootOffset) + assertEquals(638, oversizedPagePrediction.maxHorizontalScroll) + assertEquals(2367, oversizedPagePrediction.maxVerticalScroll) + } + + @Test + fun `desktop pdf anchored zoom keeps cursor content stable`() { + assertEquals( + 300, + desktopPdfAnchoredScrollTarget(currentScroll = 100, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + 25, + desktopPdfAnchoredScrollTarget(currentScroll = 150, anchor = 100f, oldZoom = 2f, newZoom = 1f) + ) + assertEquals( + 100, + desktopPdfAnchoredLazyItemScrollOffset(itemOffset = 0, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + 200, + desktopPdfAnchoredLazyItemScrollOffset(itemOffset = -50, anchor = 100f, oldZoom = 1f, newZoom = 2f) + ) + assertEquals( + IntOffset(100, 100), + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentPageRootOffset = Offset.Zero, + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f + ) + ) + assertEquals( + IntOffset(0, 0), + desktopPdfAnchoredPageScrollDelta( + viewportRootOffset = Offset.Zero, + oldPageRootOffset = Offset.Zero, + currentPageRootOffset = Offset(-100f, -100f), + anchor = Offset(100f, 100f), + oldZoom = 1f, + newZoom = 2f + ) + ) + val offCenterPivot = desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = Offset(20f, 30f), + pageRootOffset = Offset(120f, 230f), + anchor = Offset(250f, 450f), + pageCanvasSize = IntSize(500, 1000) + ) ?: error("Expected off-center pivot") + assertEquals(0.3f, offCenterPivot.x, 0.0001f) + assertEquals(0.25f, offCenterPivot.y, 0.0001f) + + val clampedPivot = desktopPdfZoomPreviewPivotFraction( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset.Zero, + anchor = Offset(900f, -20f), + pageCanvasSize = IntSize(500, 1000) + ) ?: error("Expected clamped pivot") + assertEquals(1f, clampedPivot.x, 0.0001f) + assertEquals(0f, clampedPivot.y, 0.0001f) + + val firstPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset(0f, 0f), + anchor = Offset(100f, 200f), + previewScale = 2f + ) ?: error("Expected first page document translation") + assertEquals(-100f, firstPageDocumentTranslation.x, 0.0001f) + assertEquals(-200f, firstPageDocumentTranslation.y, 0.0001f) + + val secondPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation( + viewportRootOffset = Offset.Zero, + pageRootOffset = Offset(0f, 900f), + anchor = Offset(100f, 200f), + previewScale = 2f + ) ?: error("Expected second page document translation") + assertEquals(-100f, secondPageDocumentTranslation.x, 0.0001f) + assertEquals(700f, secondPageDocumentTranslation.y, 0.0001f) + } + + private fun bookItem(id: String): BookItem { + return BookItem( + id = id, + path = "C:/Books/$id.epub", + type = FileType.EPUB, + displayName = "$id.epub", + timestamp = 1L + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt new file mode 100644 index 0000000..d383823 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderKeyCommandsTest.kt @@ -0,0 +1,174 @@ +package org.dueattendant149.bookreader.desktop + +import java.awt.Canvas +import java.awt.event.KeyEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopReaderKeyCommandsTest { + + @Test + fun `ctrl f opens epub reader search`() { + assertEquals( + DesktopReaderKeyNavigation.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopReaderKeyNavigationOrNull(fullscreen = false) + ) + } + + @Test + fun `epub right to left pagination swaps physical arrow navigation`() { + assertEquals( + DesktopReaderKeyNavigation.PREVIOUS, + awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + assertEquals( + DesktopReaderKeyNavigation.NEXT, + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + assertEquals( + DesktopReaderKeyNavigation.NEXT, + awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) + .desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true) + ) + } + + @Test + fun `ctrl f opens pdf reader search while reading`() { + assertEquals( + DesktopPdfKeyCommand.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = false) + ) + } + + @Test + fun `ctrl f opens pdf reader search while text editing`() { + assertEquals( + DesktopPdfKeyCommand.SEARCH, + awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F') + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) + ) + } + + @Test + fun `pdf text editing keeps unmodified arrows for the editor`() { + assertNull( + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true) + ) + } + + @Test + fun `pdf right to left pagination swaps physical arrow navigation`() { + assertEquals( + DesktopPdfKeyCommand.PREVIOUS_PAGE, + awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + assertEquals( + DesktopPdfKeyCommand.NEXT_PAGE, + awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + assertEquals( + DesktopPdfKeyCommand.NEXT_PAGE, + awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED) + .desktopPdfKeyCommandOrNull( + fullscreen = false, + editingText = false, + rightToLeftPagination = true + ) + ) + } + + @Test + fun `reader side panels can opt into global key dispatch without enabling popups`() { + assertEquals( + DesktopReaderModalWindowKind.PANEL, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}PanelLeft", + windowTitle = "Reader Navigation" + ) + ) + assertTrue( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.PANEL, + allowChromeModalWindows = false, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false + ) + ) + assertFalse( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.POPUP, + allowChromeModalWindows = true, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = true + ) + ) + } + + @Test + fun `reader chrome and owner window dispatch remain separately gated`() { + assertEquals( + DesktopReaderModalWindowKind.CHROME, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}ChromeTop", + windowTitle = "Reader Chrome Top" + ) + ) + assertEquals( + DesktopReaderModalWindowKind.POPUP, + desktopReaderModalWindowKind( + windowName = "${DesktopReaderModalWindowNamePrefix}Popup", + windowTitle = "Reader Popup" + ) + ) + assertNull(desktopReaderModalWindowKind(windowName = "", windowTitle = "Episteme")) + assertFalse( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = null, + allowChromeModalWindows = false, + allowPanelModalWindows = true, + dispatchWhenOwnerWindowActive = false + ) + ) + assertTrue( + desktopReaderKeyDispatchAllowedForActiveWindowKind( + activeReaderModalKind = DesktopReaderModalWindowKind.CHROME, + allowChromeModalWindows = true, + allowPanelModalWindows = false, + dispatchWhenOwnerWindowActive = false + ) + ) + } + + private fun awtKeyEvent( + keyCode: Int, + modifiers: Int, + keyChar: Char + ): KeyEvent { + return KeyEvent( + Canvas(), + KeyEvent.KEY_PRESSED, + 0L, + modifiers, + keyCode, + keyChar + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt new file mode 100644 index 0000000..b3c8c3b --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderTypographyTest.kt @@ -0,0 +1,69 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.unit.sp +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph +import org.dueattendant149.bookreader.shared.reader.ReaderPage +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopReaderTypographyTest { + + @Test + fun `same page layout includes semantic styling`() { + val plain = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + val styled = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(fontSize = 24.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + + assertFalse(listOf(plain).samePageLayoutAs(listOf(styled))) + } + + @Test + fun `same page layout still matches identical semantic pages`() { + val page = pageWith( + SemanticParagraph( + text = "Styled text", + spans = emptyList(), + style = CssStyle(fontSize = 24.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + + assertTrue(listOf(page).samePageLayoutAs(listOf(page.copy()))) + } + + private fun pageWith(block: SemanticParagraph): ReaderPage { + return ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = block.text, + startOffset = 0, + endOffset = block.text.length, + semanticBlocks = listOf(block) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt new file mode 100644 index 0000000..99e93cf --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopReaderWindowStateTest.kt @@ -0,0 +1,141 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import org.dueattendant149.bookreader.shared.FileType +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.ui.SharedAppTab +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopReaderWindowStateTest { + + @Test + fun `desktop starts on library instead of home`() { + assertEquals(SharedAppTab.LIBRARY, DesktopInitialAppTab) + } + + @Test + fun `opening a new reader creates a window`() { + val opening = readerOpening("book-1", requestId = 1) + + val decision = emptyList().openOrFocusDesktopReaderWindow( + opening = opening, + force = false + ) + + assertTrue(decision.shouldStartOpen) + assertEquals(listOf("book-1"), decision.windows.map { it.bookId }) + assertEquals(1L, decision.windows.single().focusRequestId) + } + + @Test + fun `opening an already open reader focuses the existing window`() { + val opening = readerOpening("book-1", requestId = 1) + val first = emptyList() + .openOrFocusDesktopReaderWindow(opening, force = false) + .windows + + val decision = first.openOrFocusDesktopReaderWindow( + opening = readerOpening("book-1", requestId = 2), + force = false + ) + + assertFalse(decision.shouldStartOpen) + assertEquals(1, decision.windows.size) + assertEquals(2L, decision.windows.single().focusRequestId) + assertEquals(1L, decision.windows.single().opening.requestId) + } + + @Test + fun `forcing an already open reader replaces the opening request`() { + val opening = readerOpening("book-1", requestId = 1) + val first = emptyList() + .openOrFocusDesktopReaderWindow(opening, force = false) + .windows + + val decision = first.openOrFocusDesktopReaderWindow( + opening = readerOpening("book-1", requestId = 2), + force = true + ) + + assertTrue(decision.shouldStartOpen) + assertEquals(1, decision.windows.size) + assertEquals(2L, decision.windows.single().opening.requestId) + assertEquals(2L, decision.windows.single().focusRequestId) + } + + @Test + fun `reader window uses persisted size instead of hardcoded fallback`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FLOATING, + widthDp = 1340f, + heightDp = 840f + ) + + val size = snapshot.toWindowSize(DesktopReaderWindowDefaultSize) + + assertEquals(1340.dp, size.width) + assertEquals(840.dp, size.height) + } + + @Test + fun `reader window defaults preserve previous detached reader size`() { + assertEquals(1120.dp, DesktopReaderWindowDefaultSize.width) + assertEquals(760.dp, DesktopReaderWindowDefaultSize.height) + } + + @Test + fun `reader window persistence ignores fullscreen snapshots`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FULLSCREEN, + widthDp = 1920f, + heightDp = 1080f + ) + + assertEquals(WindowPlacement.Floating, snapshot.toReaderWindowPlacement()) + assertNull(snapshot.toPersistableReaderWindowSnapshot()) + } + + @Test + fun `native webview text reader resets surface when switching from vertical to paginated`() { + assertTrue( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.VERTICAL, + currentMode = ReaderReadingMode.PAGINATED, + usesNativeWebView = true + ) + ) + } + + @Test + fun `text reader surface reset is limited to native webview vertical to paginated switches`() { + assertFalse( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.PAGINATED, + currentMode = ReaderReadingMode.VERTICAL, + usesNativeWebView = true + ) + ) + assertFalse( + shouldResetDesktopTextReaderWindowSurface( + previousMode = ReaderReadingMode.VERTICAL, + currentMode = ReaderReadingMode.PAGINATED, + usesNativeWebView = false + ) + ) + } + + private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening { + return DesktopReaderOpening( + requestId = requestId, + bookId = bookId, + title = "Book $bookId", + formatLabel = FileType.EPUB.name, + returnTab = SharedAppTab.LIBRARY + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt new file mode 100644 index 0000000..3538120 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStartupTest.kt @@ -0,0 +1,231 @@ +package org.dueattendant149.bookreader.desktop + +import org.dueattendant149.bookreader.shared.SharedReaderScreenState +import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode +import org.dueattendant149.bookreader.shared.reader.ReaderSettings +import org.dueattendant149.bookreader.shared.reader.SharedJvmBookLoadSemanticMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.io.File +import java.nio.file.Files + +class DesktopStartupTest { + @Test + fun `startup splash uses compact branded feedback`() { + val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("standard")) + + assertEquals(EpistemeDesktopWindowTitle, spec.title) + assertTrue(spec.message.isNotBlank()) + assertTrue(spec.width in 320..480) + assertTrue(spec.height in 180..280) + } + + @Test + fun `oss startup splash uses oss branding`() { + val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("oss-offline")) + + assertEquals(EpistemeDesktopOssAppName, spec.title) + } + + @Test + fun `desktop epub webview uses native browser backends without bundled runtime`() { + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(linux)) + assertEquals(DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, desktopEpubWebViewBackend(windows)) + assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(macos)) + assertEquals(DesktopEpubWebViewBackend.UNSUPPORTED, desktopEpubWebViewBackend(other)) + + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(linux)) + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(windows)) + assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(macos)) + assertFalse(desktopEpubWebViewUsesNativeSwtBrowser(other)) + } + + @Test + fun `native webviews can render without bundled runtime state`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), windows)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), linux)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), macos)) + assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), linux)) + assertFalse(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), other)) + } + + @Test + fun `desktop vertical epub native reader is Linux only`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertTrue(desktopShouldUseNativeVerticalEpubReader(linux)) + assertFalse(desktopShouldUseNativeVerticalEpubReader(windows)) + assertFalse(desktopShouldUseNativeVerticalEpubReader(macos)) + assertFalse(desktopShouldUseNativeVerticalEpubReader(other)) + } + + @Test + fun `desktop vertical epub load keeps semantic blocks for Linux native reader`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64) + val verticalSettings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL) + val paginatedSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED) + + assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(verticalSettings, linux)) + assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, windows)) + assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, macos)) + assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(paginatedSettings, windows)) + } + + @Test + fun `native webview unavailable messages point to the platform runtime`() { + val windowsMessage = desktopNativeWebViewUnavailableMessage( + backend = DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, + detail = "missing runtime" + ) + val linuxMessage = desktopNativeWebViewUnavailableMessage( + backend = DesktopEpubWebViewBackend.WEBKIT, + detail = "missing library" + ) + + assertTrue(windowsMessage.contains("WebView2 Runtime")) + assertTrue(windowsMessage.contains("missing runtime")) + assertTrue(linuxMessage.contains("WebKitGTK")) + assertTrue(linuxMessage.contains("Linux distribution packages")) + assertTrue(linuxMessage.contains("missing library")) + } + + @Test + fun `compose interop blending stays off by default for native swt webviews`() { + val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64) + val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64) + val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64) + + assertNull(composeInteropBlendingDefault(windows)) + assertNull(composeInteropBlendingDefault(linux)) + assertEquals(ComposeInteropBlendingEnabled, composeInteropBlendingDefault(other)) + } + + @Test + fun `silent startup folder sync does not surface missing folder banner`() { + val completed = desktopFolderSyncCompletedState( + state = SharedReaderScreenState(), + message = "Folder sync failed for 1 folder.", + failedFolderCount = 1, + showBanner = false + ) + + assertNull(completed.bannerMessage) + } + + @Test + fun `manual folder sync still surfaces missing folder banner`() { + val completed = desktopFolderSyncCompletedState( + state = SharedReaderScreenState(), + message = "Folder sync failed for 1 folder.", + failedFolderCount = 1, + showBanner = true + ) + + assertEquals("Folder sync failed for 1 folder.", completed.bannerMessage?.message) + assertTrue(completed.bannerMessage?.isError == true) + } + + @Test + fun `desktop account profile store restores cached profile for matching user`() { + val directory = Files.createTempDirectory("episteme-account-profile-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + store.save("user-1", DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L)) + + assertEquals( + DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L), + store.load("user-1") + ) + assertNull(store.load("user-2")) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account profile freshness uses fetched timestamp`() { + val now = 10_000L + val ttl = 1_000L + + assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl).isFresh(now, ttl)) + assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl - 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now + 1L).isFresh(now, ttl)) + assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = 0L).isFresh(now, ttl)) + } + + @Test + fun `desktop account profile repository ignores stale startup cache`() { + val directory = Files.createTempDirectory("episteme-account-profile-policy-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) + val now = DesktopAccountProfileCacheTtlMillis + 10_000L + val freshProfile = DesktopAccountProfile( + isProUser = true, + credits = 42, + fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis + 1L + ) + + repository.saveFetchedProfile("user-1", freshProfile) + assertEquals(freshProfile, repository.cachedProfile("user-1", now)) + + repository.saveFetchedProfile( + "user-1", + freshProfile.copy(fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis - 1L) + ) + assertNull(repository.cachedProfile("user-1", now)) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account profile repository clear removes sign out cache`() { + val directory = Files.createTempDirectory("episteme-account-profile-clear-test").toFile() + try { + val store = DesktopAccountProfileStore(File(directory, "account_profile.properties")) + val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store) + + repository.saveFetchedProfile( + "user-1", + DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 10_000L) + ) + repository.clearCachedProfiles() + + assertNull(repository.cachedProfile("user-1", 10_001L)) + assertNull(store.load("user-1")) + } finally { + directory.deleteRecursively() + } + } + + private fun testDesktopCloudConfig(): DesktopCloudConfig { + return DesktopCloudConfig( + aiWorkerUrl = "", + ttsWorkerUrl = "", + firebaseWebApiKey = "", + firebaseProjectId = "reader-test", + googleOAuthClientId = "", + googleOAuthClientSecret = "" + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt new file mode 100644 index 0000000..9c16d69 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopStringResourcesTest.kt @@ -0,0 +1,162 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.util.Locale + +class DesktopStringResourcesTest { + @Test + fun buildsAndroidResourcePathsForRegionalLocale() { + val paths = desktopAndroidStringResourcePaths(Locale("pt", "BR")) + + assertEquals( + listOf( + "desktop-android-res/values-pt-rBR/strings.xml", + "desktop-android-res/values-pt/strings.xml" + ), + paths + ) + } + + @Test + fun buildsAndroidPluralResourcePathsForRegionalLocale() { + val paths = desktopAndroidPluralResourcePaths(Locale("pt", "BR")) + + assertEquals( + listOf( + "desktop-android-res/values-pt-rBR/plurals.xml", + "desktop-android-res/values-pt/plurals.xml" + ), + paths + ) + } + + @Test + fun parsesAndroidStringXmlAndDecodesEscapes() { + val xml = """ + + One\nTwo + Don\'t stop + + """.trimIndent() + + val parsed = parseAndroidStringXml(ByteArrayInputStream(xml.toByteArray())) + + assertEquals("One\nTwo", parsed["line"]) + assertEquals("Don't stop", parsed["quote"]) + assertTrue(parsed.containsKey("line")) + } + + @Test + fun parsesAndroidPluralXmlAndDecodesEscapes() { + val xml = """ + + + %1${'$'}d book + %1${'$'}d books + + + Don\'t skip %1${'$'}d file + Don\'t skip %1${'$'}d files + + + """.trimIndent() + + val parsed = parseAndroidPluralXml(ByteArrayInputStream(xml.toByteArray())) + + assertEquals("%1${'$'}d book", parsed["book_count"]?.get("one")) + assertEquals("%1${'$'}d books", parsed["book_count"]?.get("other")) + assertEquals("Don't skip %1${'$'}d file", parsed["quoted_count"]?.get("one")) + } + + @Test + fun loadsAndroidToolbarTooltipDescriptionsForDesktop() { + val resources = DesktopAndroidStringResources.load( + locale = Locale.ENGLISH, + classLoader = Thread.currentThread().contextClassLoader + ?: DesktopStringResourcesTest::class.java.classLoader + ) + + assertEquals( + "Exit search and go back to the reader", + resources.stringOrNull("tooltip_close_search_desc") + ) + assertEquals( + "Jump to the next search match in the document", + resources.stringOrNull("tooltip_next_result_desc") + ) + } + + @Test + fun choosesDesktopPluralQuantityForSupportedLanguages() { + val slavicQuantities = setOf("one", "few", "many", "other") + val arabicQuantities = setOf("zero", "one", "two", "few", "many", "other") + + assertEquals("one", desktopAndroidPluralQuantity(Locale("ru"), 21, slavicQuantities)) + assertEquals("few", desktopAndroidPluralQuantity(Locale("ru"), 22, slavicQuantities)) + assertEquals("many", desktopAndroidPluralQuantity(Locale("ru"), 25, slavicQuantities)) + assertEquals("few", desktopAndroidPluralQuantity(Locale("pl"), 2, slavicQuantities)) + assertEquals("one", desktopAndroidPluralQuantity(Locale("fr"), 0, setOf("one", "other"))) + assertEquals("zero", desktopAndroidPluralQuantity(Locale("ar"), 0, arabicQuantities)) + assertEquals("other", desktopAndroidPluralQuantity(Locale("ja"), 1, setOf("other"))) + } + + @Test + fun fallsBackToOtherPluralQuantityWhenPreferredIsUnavailable() { + val selected = desktopAndroidPluralQuantity(Locale("ru"), 2, setOf("one", "other")) + + assertEquals("other", selected) + } + + @Test + fun normalizesDesktopLanguageTagsForAndroidResources() { + assertEquals(null, normalizeDesktopLanguageTag(null)) + assertEquals("id", normalizeDesktopLanguageTag("in")) + assertEquals("pt-BR", normalizeDesktopLanguageTag("pt_br")) + assertEquals("zh-CN", normalizeDesktopLanguageTag("zh-cn")) + } + + @Test + fun resolvesSelectedDesktopLanguageOptionByNormalizedTag() { + val option = selectedDesktopLanguageOption("pt_br") + + assertEquals("pt-BR", option.normalizedTag) + assertEquals("language_portuguese_brazilian", option.labelKey) + } + + @Test + fun resolvesSelectedDesktopLanguageOptionForEstonian() { + val option = selectedDesktopLanguageOption("et") + + assertEquals("et", option.normalizedTag) + assertEquals("language_estonian", option.labelKey) + } + + @Test + fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() { + val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test") + val settingsFile = tempDirectory.resolve("language.properties").toFile() + + try { + DesktopLanguageSettingsStore(settingsFile).save(DesktopLanguageSettings("pt_br")) + + assertEquals( + "pt-BR", + DesktopLanguageSettingsStore(settingsFile).load().languageTag + ) + + DesktopLanguageSettingsStore(settingsFile).save(DesktopLanguageSettings(null)) + + assertEquals( + null, + DesktopLanguageSettingsStore(settingsFile).load().languageTag + ) + } finally { + settingsFile.delete() + tempDirectory.toFile().delete() + } + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt new file mode 100644 index 0000000..0775a3f --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopSummaryCacheStoreTest.kt @@ -0,0 +1,48 @@ +package org.dueattendant149.bookreader.desktop + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DesktopSummaryCacheStoreTest { + @Test + fun `stores lists and deletes cached summaries`() { + val store = DesktopSummaryCacheStore(Files.createTempDirectory("reader-summary-cache").toFile()) + + store.saveSummary("book-a", 2, "Chapter 3", "A cached summary.") + store.saveSummary("book-a", 0, "Chapter 1", "The first cached summary.") + + assertEquals("A cached summary.", store.getSummary("book-a", 2)) + assertEquals( + listOf( + DesktopCachedSummaryItem(0, "Chapter 1", "The first cached summary."), + DesktopCachedSummaryItem(2, "Chapter 3", "A cached summary.") + ), + store.getAllSummaries("book-a") + ) + + store.deleteSummary("book-a", 2) + + assertNull(store.getSummary("book-a", 2)) + assertEquals(1, store.getAllSummaries("book-a").size) + } + + @Test + fun `keeps books isolated and ignores blank summaries`() { + val store = DesktopSummaryCacheStore(Files.createTempDirectory("reader-summary-cache-isolated").toFile()) + + store.saveSummary("book-a", 0, "Chapter 1", "A summary.") + store.saveSummary("book-b", 0, "Chapter 1", "Another summary.") + store.saveSummary("book-a", 1, "Chapter 2", " ") + + assertEquals("A summary.", store.getSummary("book-a", 0)) + assertEquals("Another summary.", store.getSummary("book-b", 0)) + assertNull(store.getSummary("book-a", 1)) + + store.clearBookCache("book-a") + + assertNull(store.getSummary("book-a", 0)) + assertEquals("Another summary.", store.getSummary("book-b", 0)) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt new file mode 100644 index 0000000..24e4728 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopTtsLogTest.kt @@ -0,0 +1,18 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopTtsLogTest { + @Test + fun `desktop tts preview redacts key and token query values`() { + val preview = "wss://example.test/live?key=gemini_secret&token=firebase_secret" + .desktopTtsPreview(300) + + assertFalse(preview.contains("gemini_secret")) + assertFalse(preview.contains("firebase_secret")) + assertTrue(preview.contains("key=")) + assertTrue(preview.contains("token=")) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt new file mode 100644 index 0000000..1ff5168 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWebView2LayoutTest.kt @@ -0,0 +1,44 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopWebView2LayoutTest { + @Test + fun `webview2 host bounds match the awt canvas logical size`() { + val bounds = desktopWebView2TargetBoundsForCanvas(width = 1440, height = 900) + + assertEquals(DesktopWebView2TargetBounds(x = 0, y = 0, width = 1440, height = 900), bounds) + } + + @Test + fun `webview2 host bounds are unavailable before the canvas has size`() { + assertNull(desktopWebView2TargetBoundsForCanvas(width = 0, height = 900)) + assertNull(desktopWebView2TargetBoundsForCanvas(width = 1440, height = 0)) + } + + @Test + fun `webview2 awt canvas is not retired while host window is closing`() { + assertFalse( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = true, + hostWindowDisplayable = true + ) + ) + assertFalse( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = false, + hostWindowDisplayable = false + ) + ) + assertTrue( + desktopWebView2ShouldRetireAwtCanvas( + hostWindowClosing = false, + hostWindowDisplayable = true + ) + ) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt new file mode 100644 index 0000000..c250c25 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowPolishTest.kt @@ -0,0 +1,46 @@ +package org.dueattendant149.bookreader.desktop + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopWindowPolishTest { + @Test + fun `desktop window defaults use app branding and a useful first launch size`() { + val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("standard")) + + assertEquals(EpistemeDesktopWindowTitle, defaults.title) + assertEquals(EpistemeDesktopWindowIconResource, defaults.iconResourcePath) + assertTrue(defaults.defaultSize.width.value > defaults.minimumSize.width.toFloat()) + assertTrue(defaults.defaultSize.height.value > defaults.minimumSize.height.toFloat()) + assertEquals(EpistemeDesktopWindowMinimumWidthPx, defaults.minimumSize.width) + assertEquals(EpistemeDesktopWindowMinimumHeightPx, defaults.minimumSize.height) + } + + @Test + fun `oss desktop window defaults use oss branding`() { + val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("oss-offline")) + + assertEquals(EpistemeDesktopOssAppName, defaults.title) + } + + @Test + fun `desktop chrome colors choose dark mode from dark theme surfaces`() { + val darkChrome = desktopWindowChromeColors( + captionColor = Color(0xFF12140E), + textColor = Color(0xFFE2E3D8), + borderColor = Color(0xFF0C0F09) + ) + + val lightChrome = desktopWindowChromeColors( + captionColor = Color(0xFFF9FAEF), + textColor = Color(0xFF1A1C16), + borderColor = Color(0xFFFFFFFF) + ) + + assertTrue(darkChrome.useDarkMode) + assertFalse(lightChrome.useDarkMode) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt new file mode 100644 index 0000000..23c27e6 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/DesktopWindowStateStoreTest.kt @@ -0,0 +1,43 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.io.path.createTempFile +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopWindowStateStoreTest { + + @Test + fun `desktop window state round trips through config store`() { + val file = createTempFile("episteme-window-state", ".json").toFile() + val store = DesktopWindowStateStore(file) + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FLOATING, + widthDp = 1440f, + heightDp = 900f, + xDp = 120f, + yDp = 80f + ) + + store.save(snapshot) + + assertEquals(snapshot, store.load()) + } + + @Test + fun `desktop window state clamps too small saved bounds`() { + val snapshot = DesktopWindowStateSnapshot( + placement = DesktopSavedWindowPlacement.FLOATING, + widthDp = 12f, + heightDp = 34f + ).sanitized() + + assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp) + assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp) + } + + @Test + fun `reader window state uses a separate config file`() { + assertEquals("window_state.json", DesktopWindowStateStore.defaultWindowStateFile().name) + assertEquals("reader_window_state.json", DesktopWindowStateStore.defaultReaderWindowStateFile().name) + } +} diff --git a/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt new file mode 100644 index 0000000..4ae7a89 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/desktop/LinuxSecretToolCodecTest.kt @@ -0,0 +1,115 @@ +package org.dueattendant149.bookreader.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LinuxSecretToolCodecTest { + @Test + fun `libsecret codec stores looks up legacy secret tool references and clears secrets by key`() { + val client = FakeLinuxSecretServiceClient() + val codec = LinuxLibsecretCodec(client) + + assertTrue(codec.isAvailable) + val reference = codec.protect("geminiKeyProtected", "linux_gemini_key") + + assertTrue(reference.startsWith("linux-libsecret:")) + assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference)) + assertEquals( + "linux_gemini_key", + codec.unprotect("geminiKeyProtected", "secret-tool:Episteme.Reader.geminiKeyProtected") + ) + codec.delete("geminiKeyProtected") + assertTrue(client.storedSecrets.isEmpty()) + } + + @Test + fun `linux secret service codec falls back to secret tool when libsecret is unavailable`() { + val runner = FakeSecretCommandRunner() + val codec = LinuxSecretServiceCodec( + libsecretCodec = LinuxLibsecretCodec(FakeLinuxSecretServiceClient(available = false)), + secretToolCodec = LinuxSecretToolCodec(runner) + ) + + assertTrue(codec.isAvailable) + val reference = codec.protect("geminiKeyProtected", "linux_gemini_key") + + assertTrue(reference.startsWith("secret-tool:")) + assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference)) + assertFalse(runner.storedSecrets.isEmpty()) + } + + @Test + fun `secret tool codec stores looks up and clears secrets by key`() { + val runner = FakeSecretCommandRunner() + val codec = LinuxSecretToolCodec(runner) + + assertTrue(codec.isAvailable) + val reference = codec.protect("firebaseRefreshTokenProtected", "linux_refresh") + + assertEquals("linux_refresh", codec.unprotect("firebaseRefreshTokenProtected", reference)) + codec.delete("firebaseRefreshTokenProtected") + assertTrue(runner.storedSecrets.isEmpty()) + } + + private class FakeLinuxSecretServiceClient( + private val available: Boolean = true + ) : LinuxSecretServiceClient { + val storedSecrets = linkedMapOf() + + override val isAvailable: Boolean + get() = available + + override fun store(key: String, label: String, password: String) { + check(available) { "libsecret unavailable" } + storedSecrets[key] = password + } + + override fun lookup(key: String): String? { + check(available) { "libsecret unavailable" } + return storedSecrets[key] + } + + override fun clear(key: String) { + if (available) { + storedSecrets.remove(key) + } + } + } + + private class FakeSecretCommandRunner : DesktopSecretCommandRunner { + val storedSecrets = linkedMapOf() + + override fun isExecutableAvailable(command: String): Boolean { + return command == "secret-tool" + } + + override fun run( + command: List, + input: String?, + timeoutMillis: Long + ): DesktopSecretCommandResult { + return when (command.getOrNull(1)) { + "--help" -> DesktopSecretCommandResult(0, "usage", "") + "store" -> { + storedSecrets[command.last()] = input.orEmpty() + DesktopSecretCommandResult(0, "", "") + } + "lookup" -> { + val secret = storedSecrets[command.last()] + if (secret == null) { + DesktopSecretCommandResult(1, "", "not found") + } else { + DesktopSecretCommandResult(0, "$secret\n", "") + } + } + "clear" -> { + storedSecrets.remove(command.last()) + DesktopSecretCommandResult(0, "", "") + } + else -> DesktopSecretCommandResult(1, "", "unexpected command") + } + } + } +} diff --git a/gradle.properties b/gradle.properties index 970471a..5c64886 100644 --- a/gradle.properties +++ b/gradle.properties @@ -43,15 +43,3 @@ org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.jvmargs=-Xmx3072M -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Dkotlin.daemon.jvm.options="-Xmx2048M" -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 org.gradle.vfs.watch=true -org.gradle.java.installations.auto-download=false -org.gradle.java.installations.auto-detect=true - -# Proxy settings for Gradle HTTPS connections -systemProp.http.proxyHost=127.0.0.1 -systemProp.http.proxyPort=2080 -systemProp.https.proxyHost=127.0.0.1 -systemProp.https.proxyPort=2080 -systemProp.http.nonProxyHosts=127.0.0.1|localhost|::1|192.168.*|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|*.dueattendant149.org - -# Do not auto-download SDK components during build -android.builder.sdkDownload=false diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6644d4c --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ff1d4fc92bcfc9d3799beabb4e70cfa3/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/08ce182188ada0b93565cd9ca4a4ab32/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c5760d82d08e6c26884debb23736ea57/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/08ce182188ada0b93565cd9ca4a4ab32/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/70ee42f1b0395356c016dbcb3e88f71d/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/6141bf023dcc7a96c47cad75c59b054e/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c5760d82d08e6c26884debb23736ea57/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/08ce182188ada0b93565cd9ca4a4ab32/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/8281425809f7cfe7f727eef599560d4c/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/a6eb06d81d82a782734ef3b616ba2684/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0c1ae8..3dd99a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -agp = "8.9.2" -kotlin = "2.2.0" +agp = "9.0.0" +kotlin = "2.2.10" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -22,12 +22,7 @@ androidxTestRunner = "1.6.2" material3WindowSizeClassAndroid = "1.3.2" credentials = "1.5.0" composeMultiplatform = "1.8.2" -ksp = "2.2.0-2.0.2" -hilt = "2.56.1" -hiltNavigationCompose = "1.2.0" -retrofit = "2.11.0" -retrofitKotlinxSerialization = "2.11.0" -okhttpLogging = "4.12.0" +ksp = "2.2.10-2.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -61,13 +56,6 @@ androidx-runner = { group = "androidx.test", name = "runner", version.ref = "and androidx-material3-window-size-class1-android = { group = "androidx.compose.material3", name = "material3-window-size-class-android", version.ref = "material3WindowSizeClassAndroid" } androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } -hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } -hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } -hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } -retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } -retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitKotlinxSerialization" } -okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttpLogging" } - [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -77,7 +65,6 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } # Add plugins required by pdfiumandroid android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1d7012d..efeb3c7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,6 @@ #Fri May 02 18:58:54 IST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip -networkTimeout=120000 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 18b1bac..093a81c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,12 @@ pluginManagement { repositories { - google() + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } mavenCentral() gradlePluginPortal() } @@ -12,9 +18,25 @@ dependencyResolutionManagement { mavenCentral() maven("https://jitpack.io") maven("https://jogamp.org/deployment/maven") + maven("https://jitpack.io") } } -rootProject.name = "BookReader" +rootProject.name = "Reader" -include(":app") +fun isDesktopOnlyBuild(): Boolean { + providers.gradleProperty("desktopOnly").orNull + ?.let { return it.equals("true", ignoreCase = true) } + + val requestedTasks = gradle.startParameter.taskNames + return requestedTasks.isNotEmpty() && requestedTasks.all { taskName -> + val normalized = taskName.removePrefix(":") + normalized.startsWith("desktopApp:") + } +} + +if (!isDesktopOnlyBuild()) { + include(":app") +} +include(":shared") +include(":desktopApp") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..20ae666 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,79 @@ +import com.android.build.api.dsl.LibraryExtension + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kover) +} + +fun isDesktopOnlyBuild(): Boolean { + providers.gradleProperty("desktopOnly").orNull + ?.let { return it.equals("true", ignoreCase = true) } + + val requestedTasks = gradle.startParameter.taskNames + return requestedTasks.isNotEmpty() && requestedTasks.all { taskName -> + val normalized = taskName.removePrefix(":") + normalized.startsWith("desktopApp:") + } +} + +val desktopOnlyBuild = isDesktopOnlyBuild() + +if (!desktopOnlyBuild) { + apply(plugin = "com.android.library") +} + +kotlin { + if (!desktopOnlyBuild) { + androidTarget() + } + jvm("desktop") + jvmToolchain(21) + + sourceSets { + val commonMain by getting + val desktopMain by getting + val readerJvmMain by creating { + dependsOn(commonMain) + dependencies { + implementation("org.jsoup:jsoup:1.17.2") + } + } + if (!desktopOnlyBuild) { + val androidMain by getting + androidMain.dependsOn(readerJvmMain) + } + desktopMain.dependsOn(readerJvmMain) + + commonMain.dependencies { + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3") + implementation("com.materialkolor:material-kolor:5.0.0-alpha07") + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} + +if (!desktopOnlyBuild) { + extensions.configure("android") { + namespace = "org.dueattendant149.bookreader.shared" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + buildFeatures { + buildConfig = true + } + } +} diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt new file mode 100644 index 0000000..cabc21b --- /dev/null +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.android.kt @@ -0,0 +1,8 @@ +package org.dueattendant149.bookreader.shared + +import java.security.MessageDigest + +internal actual fun localFolderSyncSha256ShortHex(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(12) +} diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt new file mode 100644 index 0000000..92ed9f2 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.android.kt @@ -0,0 +1,3 @@ +package org.dueattendant149.bookreader.shared + +actual fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt new file mode 100644 index 0000000..4555979 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.android.kt @@ -0,0 +1,17 @@ +package org.dueattendant149.bookreader.shared.reader + +import android.util.Log +import org.dueattendant149.bookreader.shared.BuildConfig + +internal actual val SharedReaderDiagnosticsEnabled: Boolean = BuildConfig.DEBUG + +internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { + if (!BuildConfig.DEBUG) return false + return tag == SharedEpubCutoffDiagnosticsTag || + runCatching { Log.isLoggable(tag, Log.DEBUG) }.getOrDefault(false) +} + +internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) { + if (!BuildConfig.DEBUG) return + Log.d(tag, message) +} diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt similarity index 95% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt rename to shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt index 035461c..bea2290 100644 --- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.android.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @Composable -internal fun LocalBookCoverImage( +internal actual fun LocalBookCoverImage( path: String, contentDescription: String?, modifier: Modifier diff --git a/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt new file mode 100644 index 0000000..b092083 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.android.kt @@ -0,0 +1,31 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +internal actual fun SharedReaderModalLayer( + onDismiss: () -> Unit, + level: SharedReaderModalLevel, + content: @Composable () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + content() + } +} + +internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean { + return false +} + +@Composable +actual fun SharedReaderModalOwnerWindowProvider( + ownerWindow: Any?, + content: @Composable () -> Unit +) { + content() +} diff --git a/app/src/main/kotlin/androidx/compose/material/icons/Icons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt similarity index 100% rename from app/src/main/kotlin/androidx/compose/material/icons/Icons.kt rename to shared/src/commonMain/kotlin/androidx/compose/material/icons/Icons.kt diff --git a/app/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt similarity index 100% rename from app/src/main/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt rename to shared/src/commonMain/kotlin/androidx/compose/material/icons/automirrored/filled/AutoMirroredFilledIcons.kt diff --git a/app/src/main/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt similarity index 100% rename from app/src/main/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt rename to shared/src/commonMain/kotlin/androidx/compose/material/icons/filled/FilledIcons.kt diff --git a/app/src/main/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt b/shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt similarity index 100% rename from app/src/main/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt rename to shared/src/commonMain/kotlin/androidx/compose/material/icons/outlined/OutlinedIcons.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/CssParser.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/FontFamilyMapper.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/PaginatedReaderData.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/SemanticModel.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/StyleUtils.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/UserAgentStylesheet.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/serialization/ComposeTypeSerializers.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppActions.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/AppModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisions.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/CustomFontModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilities.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInference.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ImportContracts.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryMutations.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt similarity index 99% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt index 2ea3920..ddff77e 100644 --- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryProjector.kt @@ -160,6 +160,8 @@ private fun ImportedFile.toImportedBookFile(): ImportedBookFile { ) } +expect fun currentTimestamp(): Long + fun String.toFileType(): FileType { return SharedFileCapabilities.fileTypeForName(this) } diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LibraryStateProjector.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt similarity index 99% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt index 42c244e..3cceb68 100644 --- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.kt @@ -19,10 +19,7 @@ const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData" const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations" const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_" -fun localFolderSyncSha256ShortHex(value: String): String { - val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) - return bytes.joinToString("") { "%02x".format(it) }.take(12) -} +internal expect fun localFolderSyncSha256ShortHex(value: String): String fun localFolderSyncSidecarStem(bookId: String): String { return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId) diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/PdfReaderModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAnnotationSerializer.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderSearchModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacements.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderWordReplacements.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/RepositoryContracts.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SampleLibrary.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ScreenProjectors.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFeaturePolicy.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedFormatters.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinks.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshot.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducers.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngine.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogs.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsController.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsUtilities.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfInteractionModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSession.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometry.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayout.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfVerticalLayout.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfiumBridge.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapper.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSidecarCodec.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRendering.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflow.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichText.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotations.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngine.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilder.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistory.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderModels.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt new file mode 100644 index 0000000..e639d3d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.kt @@ -0,0 +1,15 @@ +package org.dueattendant149.bookreader.shared.reader + +internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics" +internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags" +internal const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff" + +internal expect val SharedReaderDiagnosticsEnabled: Boolean +internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean +internal expect fun writeSharedReaderDiagnostic(tag: String, message: String) + +internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) { + if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) { + writeSharedReaderDiagnostic(tag, message()) + } +} diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderTextAlignment.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedTextBookFactory.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SimplePaginator.kt diff --git a/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt new file mode 100644 index 0000000..3533a66 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.kt @@ -0,0 +1,11 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal expect fun LocalBookCoverImage( + path: String, + contentDescription: String?, + modifier: Modifier +) diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderScreens.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderContentRenderPlan.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSlider.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderTooltips.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModels.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceShell.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppShell.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeSettings.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedLibraryDialogs.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedMarkdownText.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReader.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedOpdsScreen.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUi.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfRichTextUi.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderChrome.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt similarity index 79% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt index 8038072..b1730a5 100644 --- a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt +++ b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.kt @@ -2,8 +2,6 @@ package org.dueattendant149.bookreader.shared.ui import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.Composable -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -42,32 +40,20 @@ fun sharedReaderPopupWidth( return (availableWidth * widthFraction.coerceIn(0f, 1f)).coerceIn(lowerBound, upperBound) } - @Composable -internal fun SharedReaderModalLayer( +internal expect fun SharedReaderModalLayer( onDismiss: () -> Unit, level: SharedReaderModalLevel = SharedReaderModalLevel.Popup, content: @Composable () -> Unit -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - content() - } -} +) -internal fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean { - return false -} +internal expect fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean @Composable -fun SharedReaderModalOwnerWindowProvider( +expect fun SharedReaderModalOwnerWindowProvider( ownerWindow: Any?, content: @Composable () -> Unit -) { - content() -} +) @Composable fun SharedReaderPopupLayer( diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderScrollbars.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderTtsOverlayControls.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacement.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSettingsHub.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStableTextFields.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStrings.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUiTokens.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt b/shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt rename to shared/src/commonMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedUtilityScreens.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/CloudSyncDecisionsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/EpubAnnotationSerializerTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FileCapabilitiesTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/FontVariantInferenceTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSyncEngineTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderActionReducerTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderAppearanceModelsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderBookReplacementEngineTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderDefaultSettingsStateTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderExtrasModelsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderMarkdownParserTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderToolbarPreferencesTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsReplacementEngineTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SettingsHubModelsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedAppThemeReducerTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedImportPlannerTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLegalLinksTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryEditorTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibraryProjectorTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedLibrarySnapshotJsonTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SharedReducersTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/SmartCollectionEngineTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsCatalogsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsControllerTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfReaderSessionTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSelectionGeometryTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/PdfSpreadLayoutTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationCommentsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationExportMapperTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfAnnotationSerializerTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfInkRenderingTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfReflowTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfRichTextTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/pdf/SharedPdfTextAnnotationsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderEngineTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderHtmlDocumentBuilderTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderImageModelsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderJumpHistoryTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/ReaderSpreadLayoutTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/NonReaderLayoutModelsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderMinimalSliderTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/ReaderWorkspaceModelsTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedAppThemeColorMathTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativePaginatedReaderInteractionTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedNativeVerticalReaderFlowTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedPdfAnnotationUiTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalSizingTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedSelectionMenuPlacementTest.kt diff --git a/app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt b/shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt similarity index 100% rename from app/src/test/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt rename to shared/src/commonTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedStringsTest.kt diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.desktop.kt new file mode 100644 index 0000000..cabc21b --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/LocalFolderSync.desktop.kt @@ -0,0 +1,8 @@ +package org.dueattendant149.bookreader.shared + +import java.security.MessageDigest + +internal actual fun localFolderSyncSha256ShortHex(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(12) +} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.desktop.kt new file mode 100644 index 0000000..92ed9f2 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/Platform.desktop.kt @@ -0,0 +1,3 @@ +package org.dueattendant149.bookreader.shared + +actual fun currentTimestamp(): Long = System.currentTimeMillis() diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.desktop.kt new file mode 100644 index 0000000..091d2de --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedReaderDiagnostics.desktop.kt @@ -0,0 +1,36 @@ +package org.dueattendant149.bookreader.shared.reader + +private const val SharedReaderDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS" +private const val SharedReaderDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS" + +private val SharedReaderDiagnosticTags: Set = + listOfNotNull( + System.getProperty(SharedReaderDiagnosticsTagsProperty), + System.getenv(SharedReaderDiagnosticsTagsEnv) + ) + .joinToString(" ") + .split(',', ';', ' ', '\t', '\n') + .mapNotNull { rawTag -> + rawTag.trim() + .takeIf { it.isNotBlank() } + ?.lowercase() + } + .toSet() + +internal actual val SharedReaderDiagnosticsEnabled: Boolean = + System.getProperty(SharedReaderDiagnosticsProperty) + ?.trim() + ?.equals("true", ignoreCase = true) == true || + System.getenv(SharedReaderDiagnosticsEnv) + ?.trim() + ?.equals("true", ignoreCase = true) == true || + SharedReaderDiagnosticTags.isNotEmpty() + +internal actual fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean { + if (SharedReaderDiagnosticTags.isEmpty()) return true + return "*" in SharedReaderDiagnosticTags || tag.lowercase() in SharedReaderDiagnosticTags +} + +internal actual fun writeSharedReaderDiagnostic(tag: String, message: String) { + println("$tag $message") +} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCache.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCache.kt new file mode 100644 index 0000000..1b61da7 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCache.kt @@ -0,0 +1,110 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image as SkiaImage +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.File +import java.util.concurrent.Semaphore +import javax.imageio.ImageIO +import kotlin.math.roundToInt + +internal object DesktopBookCoverImageCache { + private const val MaxEntries = 160 + private const val MaxCoverDimensionPx = 512 + + private data class Entry( + val length: Long, + val lastModified: Long, + val bitmap: ImageBitmap + ) + + private val entries = LinkedHashMap(MaxEntries, 0.75f, true) + private val decodeSlots = Semaphore(2) + + fun peek(path: String): ImageBitmap? { + return synchronized(entries) { + entries[File(path).absolutePath]?.bitmap + } + } + + fun load(path: String): ImageBitmap? { + val file = File(path) + if (!file.isFile) return null + val key = file.absolutePath + val length = file.length() + val lastModified = file.lastModified() + synchronized(entries) { + val entry = entries[key] + if (entry != null && entry.length == length && entry.lastModified == lastModified) { + return entry.bitmap + } + entries.remove(key) + } + decodeSlots.acquireUninterruptibly() + val bitmap = try { + decodeCover(file) + } finally { + decodeSlots.release() + } ?: return null + val entry = Entry( + length = length, + lastModified = lastModified, + bitmap = bitmap + ) + synchronized(entries) { + entries[key] = entry + trimToMaxEntries() + } + return bitmap + } + + fun clearForTests() { + synchronized(entries) { + entries.clear() + } + } + + private fun trimToMaxEntries() { + while (entries.size > MaxEntries) { + val eldestKey = entries.keys.firstOrNull() ?: return + entries.remove(eldestKey) + } + } + + private fun decodeCover(file: File): ImageBitmap? { + runCatching { ImageIO.read(file) }.getOrNull() + ?.scaledToFit(MaxCoverDimensionPx) + ?.toComposeImageBitmap() + ?.let { return it } + + return runCatching { + SkiaImage.makeFromEncoded(file.readBytes()).toComposeImageBitmap() + }.getOrNull() + } + + private fun BufferedImage.scaledToFit(maxDimension: Int): BufferedImage { + val largestDimension = maxOf(width, height) + if (largestDimension <= maxDimension) return this + val scale = maxDimension.toDouble() / largestDimension.toDouble() + val targetWidth = (width * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (height * scale).roundToInt().coerceAtLeast(1) + val target = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB) + val graphics = target.createGraphics() + try { + graphics.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR + ) + graphics.setRenderingHint( + RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY + ) + graphics.drawImage(this, 0, 0, targetWidth, targetHeight, null) + } finally { + graphics.dispose() + } + return target + } +} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImage.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImage.desktop.kt new file mode 100644 index 0000000..852cadf --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImage.desktop.kt @@ -0,0 +1,326 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Data +import org.jetbrains.skia.Surface +import org.jetbrains.skia.svg.SVGDOM +import org.jetbrains.skia.svg.SVGLengthContext +import org.jetbrains.skia.Color as SkiaColor +import org.jetbrains.skia.Image as SkiaImage +import java.io.ByteArrayInputStream +import java.io.File +import java.util.Base64 +import javax.imageio.ImageIO +import kotlin.math.roundToInt + +@Composable +fun DesktopEpubNativeImage( + image: SemanticImage, + modifier: Modifier = Modifier +) { + var bitmap by remember(image.path) { + mutableStateOf(DesktopEpubNativeImageCache.peek(image.path)) + } + + LaunchedEffect(image.path) { + if (bitmap == null) { + bitmap = withContext(Dispatchers.IO) { + DesktopEpubNativeImageCache.load(image.path) + } + } + } + + val currentBitmap = bitmap + val isDecorative = image.altText != null && image.altText.isBlank() + if (currentBitmap != null) { + Image( + bitmap = currentBitmap, + contentDescription = image.altText + ?.takeIf { it.isNotBlank() } + ?: if (isDecorative) null else "Image from EPUB", + modifier = modifier, + contentScale = image.readerImageContentScale(), + alignment = desktopEpubImageContentAlignment(image.style.blockStyle.objectPosition), + colorFilter = image.readerImageColorFilter() + ) + } else if (isDecorative) { + Spacer(modifier = modifier) + } else { + Text( + text = image.altText?.takeIf { it.isNotBlank() } ?: image.path.substringAfterLast('/').substringAfterLast('\\'), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + style = MaterialTheme.typography.bodySmall + ) + } +} + +private object DesktopEpubNativeImageCache { + private const val MaxEntries = 160 + + private data class Entry( + val length: Long?, + val lastModified: Long?, + val bitmap: ImageBitmap + ) + + private val entries = LinkedHashMap(MaxEntries, 0.75f, true) + + fun peek(path: String): ImageBitmap? { + val source = DesktopEpubImageSource.from(path) ?: return null + return synchronized(entries) { + val entry = entries[source.key] + if (entry != null && entry.length == source.length && entry.lastModified == source.lastModified) { + entry.bitmap + } else { + entries.remove(source.key) + null + } + } + } + + fun load(path: String): ImageBitmap? { + peek(path)?.let { return it } + val source = DesktopEpubImageSource.from(path) ?: return null + val bitmap = decode(source) ?: return null + synchronized(entries) { + entries[source.key] = Entry( + length = source.length, + lastModified = source.lastModified, + bitmap = bitmap + ) + trimToMaxEntries() + } + return bitmap + } + + private fun trimToMaxEntries() { + while (entries.size > MaxEntries) { + val eldestKey = entries.keys.firstOrNull() ?: return + entries.remove(eldestKey) + } + } + + private fun decode(source: DesktopEpubImageSource): ImageBitmap? { + val bytes = source.bytes() ?: return null + if (source.isSvg) { + decodeSvg(bytes)?.let { return it } + } + runCatching { + ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap() + }.getOrNull()?.let { return it } + + return runCatching { + SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() + }.getOrNull() + } + + private fun decodeSvg(bytes: ByteArray): ImageBitmap? { + var data: Data? = null + var dom: SVGDOM? = null + var surface: Surface? = null + return runCatching { + data = Data.makeFromBytes(bytes) + dom = SVGDOM(data!!) + val root = dom?.root + val viewBox = root?.viewBox + val intrinsic = root?.getIntrinsicSize(SVGLengthContext(DefaultSvgViewportPx, DefaultSvgViewportPx)) + val width = (intrinsic?.x?.takeIf { it.isFinite() && it > 0f } + ?: viewBox?.width?.takeIf { it.isFinite() && it > 0f } + ?: DefaultSvgViewportPx) + .roundToInt() + .coerceIn(1, MaxSvgRasterDimensionPx) + val height = (intrinsic?.y?.takeIf { it.isFinite() && it > 0f } + ?: viewBox?.height?.takeIf { it.isFinite() && it > 0f } + ?: DefaultSvgViewportPx) + .roundToInt() + .coerceIn(1, MaxSvgRasterDimensionPx) + dom?.setContainerSize(width.toFloat(), height.toFloat()) + surface = Surface.makeRasterN32Premul(width, height) + val canvas = surface!!.canvas + canvas.clear(SkiaColor.TRANSPARENT) + dom?.render(canvas) + surface!!.makeImageSnapshot().toComposeImageBitmap() + }.getOrNull().also { + surface?.close() + dom?.close() + data?.close() + } + } +} + +private sealed class DesktopEpubImageSource( + val key: String, + val length: Long?, + val lastModified: Long?, + val mimeType: String? +) { + abstract fun bytes(): ByteArray? + + val isSvg: Boolean + get() = mimeType.equals("image/svg+xml", ignoreCase = true) || + key.substringBefore('?').substringBefore('#').endsWith(".svg", ignoreCase = true) + + data class FileSource(private val file: File) : DesktopEpubImageSource( + key = file.absolutePath, + length = file.length(), + lastModified = file.lastModified(), + mimeType = file.extension + .takeIf { it.equals("svg", ignoreCase = true) } + ?.let { "image/svg+xml" } + ) { + override fun bytes(): ByteArray? = runCatching { file.readBytes() }.getOrNull() + } + + data class DataUriSource(private val path: String) : DesktopEpubImageSource( + key = path, + length = path.length.toLong(), + lastModified = null, + mimeType = path.substringAfter("data:", missingDelimiterValue = "") + .substringBefore(';') + .substringBefore(',') + .takeIf { it.isNotBlank() } + ) { + override fun bytes(): ByteArray? { + val marker = "base64," + val markerIndex = path.indexOf(marker, ignoreCase = true) + if (markerIndex < 0) return null + val base64 = path.substring(markerIndex + marker.length) + if (base64.isBlank()) return null + return runCatching { Base64.getDecoder().decode(base64) }.getOrNull() + } + } + + companion object { + fun from(path: String): DesktopEpubImageSource? { + if (path.startsWith("data:image/", ignoreCase = true)) { + return DataUriSource(path) + } + val file = File(path) + return if (file.isFile) FileSource(file) else null + } + } +} + +private const val DefaultSvgViewportPx = 512f +private const val MaxSvgRasterDimensionPx = 4096 + +private fun SemanticImage.readerImageColorFilter(): ColorFilter? { + if (style.blockStyle.filter != "invert(100%)") return null + return ColorFilter.colorMatrix( + ColorMatrix( + floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ) + ) + ) +} + +private fun SemanticImage.readerImageContentScale(): ContentScale { + return when (style.blockStyle.objectFit) { + "cover" -> ContentScale.Crop + "fill" -> ContentScale.FillBounds + "contain", "scale-down" -> ContentScale.Fit + else -> ContentScale.Fit + } +} + +internal fun desktopEpubImageContentAlignment(objectPosition: String?): Alignment { + val tokens = objectPosition + ?.lowercase() + ?.split(Regex("\\s+")) + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + ?: return Alignment.Center + + val orderedHorizontal = tokens.getOrNull(0)?.toDesktopObjectPositionHorizontal() + val orderedVertical = tokens.getOrNull(1)?.toDesktopObjectPositionVertical() + val horizontal = orderedHorizontal + ?: tokens.firstNotNullOfOrNull { it.toDesktopObjectPositionHorizontalKeyword() } + ?: DesktopObjectPositionAxis.CENTER + val vertical = orderedVertical + ?: tokens.firstNotNullOfOrNull { it.toDesktopObjectPositionVerticalKeyword() } + ?: DesktopObjectPositionAxis.CENTER + + return when (vertical) { + DesktopObjectPositionAxis.START -> when (horizontal) { + DesktopObjectPositionAxis.START -> Alignment.TopStart + DesktopObjectPositionAxis.END -> Alignment.TopEnd + else -> Alignment.TopCenter + } + DesktopObjectPositionAxis.END -> when (horizontal) { + DesktopObjectPositionAxis.START -> Alignment.BottomStart + DesktopObjectPositionAxis.END -> Alignment.BottomEnd + else -> Alignment.BottomCenter + } + DesktopObjectPositionAxis.CENTER -> when (horizontal) { + DesktopObjectPositionAxis.START -> Alignment.CenterStart + DesktopObjectPositionAxis.END -> Alignment.CenterEnd + else -> Alignment.Center + } + } +} + +private enum class DesktopObjectPositionAxis { + START, + CENTER, + END +} + +private fun String.toDesktopObjectPositionHorizontal(): DesktopObjectPositionAxis? { + return when (this) { + "left", "0%" -> DesktopObjectPositionAxis.START + "center", "50%" -> DesktopObjectPositionAxis.CENTER + "right", "100%" -> DesktopObjectPositionAxis.END + else -> null + } +} + +private fun String.toDesktopObjectPositionVertical(): DesktopObjectPositionAxis? { + return when (this) { + "top", "0%" -> DesktopObjectPositionAxis.START + "center", "50%" -> DesktopObjectPositionAxis.CENTER + "bottom", "100%" -> DesktopObjectPositionAxis.END + else -> null + } +} + +private fun String.toDesktopObjectPositionHorizontalKeyword(): DesktopObjectPositionAxis? { + return when (this) { + "left" -> DesktopObjectPositionAxis.START + "center" -> DesktopObjectPositionAxis.CENTER + "right" -> DesktopObjectPositionAxis.END + else -> null + } +} + +private fun String.toDesktopObjectPositionVerticalKeyword(): DesktopObjectPositionAxis? { + return when (this) { + "top" -> DesktopObjectPositionAxis.START + "center" -> DesktopObjectPositionAxis.CENTER + "bottom" -> DesktopObjectPositionAxis.END + else -> null + } +} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.desktop.kt new file mode 100644 index 0000000..70d3145 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/LocalBookCoverImage.desktop.kt @@ -0,0 +1,42 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.foundation.Image +import androidx.compose.runtime.LaunchedEffect +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.layout.ContentScale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal actual fun LocalBookCoverImage( + path: String, + contentDescription: String?, + modifier: Modifier +) { + var bitmap by remember(path) { + mutableStateOf(DesktopBookCoverImageCache.peek(path)) + } + + LaunchedEffect(path) { + val loaded = withContext(Dispatchers.IO) { + DesktopBookCoverImageCache.load(path) + } + if (loaded != null && loaded != bitmap) { + bitmap = loaded + } + } + + if (bitmap != null) { + Image( + bitmap = bitmap!!, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Crop + ) + } +} diff --git a/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.desktop.kt b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.desktop.kt new file mode 100644 index 0000000..46e69a6 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayer.desktop.kt @@ -0,0 +1,499 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +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.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.Window as ComposeWindow +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.delay +import java.awt.EventQueue +import java.awt.KeyboardFocusManager +import java.awt.Point +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import java.awt.Window as AwtWindow +import java.util.Collections +import java.util.WeakHashMap +import javax.swing.RootPaneContainer + +private val LocalSharedReaderModalOwnerWindow = compositionLocalOf { null } +private val SharedReaderModalOwnerByWindow: MutableMap = + Collections.synchronizedMap(WeakHashMap()) + +@Composable +actual fun SharedReaderModalOwnerWindowProvider( + ownerWindow: Any?, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalSharedReaderModalOwnerWindow provides (ownerWindow as? AwtWindow), + content = content + ) +} + +@Composable +internal actual fun SharedReaderModalLayer( + onDismiss: () -> Unit, + level: SharedReaderModalLevel, + content: @Composable () -> Unit +) { + val anchor = LocalSharedReaderModalAnchorBounds.current + val density = LocalDensity.current + val focusableOverride = LocalSharedReaderModalFocusableOverride.current + val explicitOwnerWindow = LocalSharedReaderModalOwnerWindow.current + val fallbackOwnerWindow = remember { currentNonModalOwnerWindow() } + val ownerWindow = explicitOwnerWindow ?: fallbackOwnerWindow + val modalWindowFocusable = sharedReaderModalLayerWindowFocusable( + level = level, + focusableOverride = focusableOverride + ) + val dialogSize = with(density) { + anchor?.let { + when { + level.isChromeLayer() -> { + DpSize( + width = it.widthPx.toDp().coerceAtLeast(360.dp), + height = level.chromeLayerHeight().coerceAtMost(it.heightPx.toDp().coerceAtLeast(1.dp)) + ) + } + level.isEdgePanelLayer() -> { + DpSize( + width = sharedReaderModalEdgePanelLayerWidth( + level = level, + anchorWidth = it.widthPx.toDp() + ), + height = it.heightPx.toDp().coerceAtLeast(360.dp) + ) + } + else -> { + DpSize( + width = it.widthPx.toDp().coerceAtLeast(360.dp), + height = it.heightPx.toDp().coerceAtLeast(360.dp) + ) + } + } + } ?: DpSize(720.dp, 620.dp) + } + val dialogPosition = sharedReaderModalLayerPosition( + anchor = anchor, + ownerWindow = ownerWindow, + dialogSize = dialogSize, + level = level, + density = density + ) + val state = rememberWindowState(position = dialogPosition, size = dialogSize) + val windowTitle = when (level) { + SharedReaderModalLevel.Panel -> "Reader Panel" + SharedReaderModalLevel.PanelLeft -> "Reader Navigation" + SharedReaderModalLevel.PanelRight -> "Reader Tools" + SharedReaderModalLevel.Popup -> "Reader Popup" + SharedReaderModalLevel.ChromeTop -> "Reader Chrome Top" + SharedReaderModalLevel.ChromeBottom -> "Reader Chrome Bottom" + } + var modalVisible by remember(ownerWindow, explicitOwnerWindow, level) { + mutableStateOf(sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null)) + } + + LaunchedEffect(dialogPosition, dialogSize) { + if (state.position != dialogPosition) { + state.position = dialogPosition + } + if (state.size != dialogSize) { + state.size = dialogSize + } + } + if (!level.isChromeLayer()) { + DisposableEffect(ownerWindow) { + onDispose { + ownerWindow?.restoreFocusAfterSharedReaderModal() + } + } + } + DisposableEffect(ownerWindow, explicitOwnerWindow, level) { + if (ownerWindow == null || explicitOwnerWindow == null) { + modalVisible = true + onDispose {} + } else { + var disposed = false + fun hideImmediately(ownerClosing: Boolean) { + if (disposed) return + if ( + sharedReaderModalLayerShouldHideImmediately( + ownerShowing = ownerWindow.isShowing, + ownerDisplayable = ownerWindow.isDisplayable, + ownerClosing = ownerClosing + ) + ) { + modalVisible = false + } + } + fun syncVisibility(oppositeWindow: AwtWindow?) { + if (disposed) return + if ( + sharedReaderModalLayerShouldHideImmediately( + ownerShowing = ownerWindow.isShowing, + ownerDisplayable = ownerWindow.isDisplayable, + ownerClosing = false + ) + ) { + hideImmediately(ownerClosing = false) + return + } + val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow) + if (nextVisible) { + modalVisible = true + } else { + EventQueue.invokeLater { + if (!disposed) { + modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null) + } + } + } + } + val listener = object : WindowAdapter() { + override fun windowClosing(e: WindowEvent?) = hideImmediately(ownerClosing = true) + override fun windowClosed(e: WindowEvent?) = hideImmediately(ownerClosing = true) + override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowDeiconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + } + ownerWindow.addWindowListener(listener) + ownerWindow.addWindowFocusListener(listener) + syncVisibility(null) + onDispose { + disposed = true + ownerWindow.removeWindowListener(listener) + ownerWindow.removeWindowFocusListener(listener) + } + } + } + + if (modalVisible) { + ComposeWindow( + onCloseRequest = onDismiss, + state = state, + title = windowTitle, + undecorated = true, + transparent = true, + resizable = false, + alwaysOnTop = true, + focusable = modalWindowFocusable + ) { + val modalWindow = window + DisposableEffect(modalWindow, ownerWindow, explicitOwnerWindow, level) { + modalWindow.name = SharedReaderModalWindowNamePrefix + level.name + if (ownerWindow != null && explicitOwnerWindow != null) { + synchronized(SharedReaderModalOwnerByWindow) { + SharedReaderModalOwnerByWindow[modalWindow] = ownerWindow + } + } + var disposed = false + fun syncVisibility(oppositeWindow: AwtWindow?) { + if (disposed) return + if (ownerWindow != null && explicitOwnerWindow != null) { + if ( + sharedReaderModalLayerShouldHideImmediately( + ownerShowing = ownerWindow.isShowing, + ownerDisplayable = ownerWindow.isDisplayable, + ownerClosing = false + ) + ) { + modalVisible = false + return + } + val nextVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, oppositeWindow) + if (nextVisible) { + modalVisible = true + } else { + EventQueue.invokeLater { + if (!disposed) { + modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, null) + } + } + } + } + } + val listener = object : WindowAdapter() { + override fun windowActivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowDeactivated(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowGainedFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowLostFocus(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowIconified(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + override fun windowClosed(e: WindowEvent?) = syncVisibility(e?.oppositeWindow) + } + modalWindow.addWindowListener(listener) + modalWindow.addWindowFocusListener(listener) + onDispose { + disposed = true + modalWindow.removeWindowListener(listener) + modalWindow.removeWindowFocusListener(listener) + synchronized(SharedReaderModalOwnerByWindow) { + SharedReaderModalOwnerByWindow.remove(modalWindow) + } + } + } + LaunchedEffect(modalWindow, level, modalWindowFocusable) { + modalWindow.name = SharedReaderModalWindowNamePrefix + level.name + modalWindow.isAlwaysOnTop = true + modalWindow.setFocusableWindowState(modalWindowFocusable) + val frontAttempts = when (level) { + SharedReaderModalLevel.Popup -> 4 + SharedReaderModalLevel.Panel, + SharedReaderModalLevel.PanelLeft, + SharedReaderModalLevel.PanelRight -> 3 + SharedReaderModalLevel.ChromeTop, + SharedReaderModalLevel.ChromeBottom -> 1 + } + repeat(frontAttempts) { attempt -> + delay(if (attempt == 0) 30L else 80L) + if (!modalWindow.isDisplayable) return@repeat + runCatching { + modalWindow.isAlwaysOnTop = true + modalWindow.toFront() + if (modalWindowFocusable && !level.isEdgePanelLayer()) { + modalWindow.requestFocus() + modalWindow.requestFocusInWindow() + } + } + } + } + content() + } + } +} + +internal fun sharedReaderModalLayerWindowFocusable( + level: SharedReaderModalLevel, + focusableOverride: Boolean? +): Boolean { + return focusableOverride ?: !level.isChromeLayer() +} + +internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean { + return level.isEdgePanelLayer() +} + +private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:" +private val SharedReaderChromeTopLayerHeight = 104.dp +private val SharedReaderChromeBottomLayerHeight = 164.dp +private val SharedReaderChromeBottomLayerOverlap = 8.dp +private val SharedReaderLeftPanelWidth = 340.dp +private val SharedReaderRightPanelWidth = 380.dp +private val SharedReaderLeftNarrowPanelMaxWidth = 320.dp +private val SharedReaderRightNarrowPanelMaxWidth = 360.dp +private val SharedReaderNarrowPanelFraction = 0.92f +private val SharedReaderWidePanelBreakpoint = 1120.dp + +private fun sharedReaderModalLayerVisible( + ownerWindow: AwtWindow?, + explicitOwnerWindow: AwtWindow?, + oppositeWindow: AwtWindow? +): Boolean { + if (explicitOwnerWindow == null) return true + return ownerWindow?.sharedReaderChromeLayerVisible(oppositeWindow) == true +} + +internal fun sharedReaderModalChromeLayerVisible( + ownerShowing: Boolean, + ownerDisplayable: Boolean, + ownerMinimized: Boolean, + ownerActive: Boolean, + ownerFocused: Boolean, + ownerModalActive: Boolean +): Boolean { + return ownerShowing && + ownerDisplayable && + !ownerMinimized && + (ownerActive || ownerFocused || ownerModalActive) +} + +internal fun sharedReaderModalLayerShouldHideImmediately( + ownerShowing: Boolean, + ownerDisplayable: Boolean, + ownerClosing: Boolean +): Boolean { + return ownerClosing || !ownerShowing || !ownerDisplayable +} + +private fun AwtWindow.sharedReaderChromeLayerVisible(oppositeWindow: AwtWindow?): Boolean { + return sharedReaderModalChromeLayerVisible( + ownerShowing = isShowing, + ownerDisplayable = isDisplayable, + ownerMinimized = (this as? java.awt.Frame)?.let { frame -> + frame.extendedState and java.awt.Frame.ICONIFIED != 0 + } == true, + ownerActive = isActive, + ownerFocused = isFocused, + ownerModalActive = oppositeWindow.isSharedReaderModalWindowForOwner(this) || + sharedReaderModalWindowActiveForOwner(this) + ) +} + +private fun sharedReaderModalLayerPosition( + anchor: SharedReaderModalAnchorBounds?, + ownerWindow: AwtWindow?, + dialogSize: DpSize, + level: SharedReaderModalLevel, + density: Density +): WindowPosition { + return with(density) { + val ownerLocation = ownerWindow?.let { window -> + runCatching { window.sharedReaderModalContentLocationOnScreen() }.getOrNull() + } + if (anchor != null && ownerLocation != null) { + val topPx = when (level) { + SharedReaderModalLevel.ChromeBottom -> sharedReaderModalChromeBottomLayerTopPx( + anchorTopPx = anchor.topPx, + anchorHeightPx = anchor.heightPx, + dialogHeightPx = dialogSize.height.toPx(), + overlapPx = SharedReaderChromeBottomLayerOverlap.toPx() + ) + else -> anchor.topPx + } + val leftPx = when (level) { + SharedReaderModalLevel.PanelRight -> anchor.leftPx + anchor.widthPx - dialogSize.width.toPx() + else -> anchor.leftPx + } + WindowPosition( + (ownerLocation.x + leftPx).toDp(), + (ownerLocation.y + topPx).toDp() + ) + } else { + WindowPosition(Alignment.Center) + } + } +} + +internal fun sharedReaderModalChromeBottomLayerTopPx( + anchorTopPx: Float, + anchorHeightPx: Float, + dialogHeightPx: Float, + overlapPx: Float +): Float { + return anchorTopPx + anchorHeightPx - dialogHeightPx + overlapPx +} + +private fun AwtWindow.sharedReaderModalContentLocationOnScreen(): Point { + val contentPane = (this as? RootPaneContainer)?.contentPane + if (contentPane != null && contentPane.isShowing) { + return contentPane.locationOnScreen + } + return locationOnScreen +} + +private fun SharedReaderModalLevel.isChromeLayer(): Boolean { + return this == SharedReaderModalLevel.ChromeTop || this == SharedReaderModalLevel.ChromeBottom +} + +private fun SharedReaderModalLevel.isEdgePanelLayer(): Boolean { + return this == SharedReaderModalLevel.PanelLeft || this == SharedReaderModalLevel.PanelRight +} + +private fun SharedReaderModalLevel.chromeLayerHeight() = when (this) { + SharedReaderModalLevel.ChromeTop -> SharedReaderChromeTopLayerHeight + SharedReaderModalLevel.ChromeBottom -> SharedReaderChromeBottomLayerHeight + else -> 0.dp +} + +internal fun sharedReaderModalEdgePanelLayerWidth( + level: SharedReaderModalLevel, + anchorWidth: Dp +): Dp { + val preferredWideWidth = when (level) { + SharedReaderModalLevel.PanelLeft -> SharedReaderLeftPanelWidth + else -> SharedReaderRightPanelWidth + } + val preferredNarrowWidth = when (level) { + SharedReaderModalLevel.PanelLeft -> minOf(SharedReaderLeftNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction) + else -> minOf(SharedReaderRightNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction) + } + return if (anchorWidth >= SharedReaderWidePanelBreakpoint) { + preferredWideWidth.coerceAtMost(anchorWidth) + } else { + preferredNarrowWidth.coerceAtMost(anchorWidth) + }.coerceAtLeast(1.dp) +} + +private fun AwtWindow.restoreFocusAfterSharedReaderModal() { + EventQueue.invokeLater { + if (!isDisplayable || !isShowing) return@invokeLater + if (this is java.awt.Frame && extendedState and java.awt.Frame.ICONIFIED != 0) { + extendedState = extendedState and java.awt.Frame.ICONIFIED.inv() + } + toFront() + requestFocus() + requestFocusInWindow() + focusOwner?.requestFocus() + } +} + +private fun currentNonModalOwnerWindow(): AwtWindow? { + val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + if (activeWindow != null && !activeWindow.isSharedReaderModalWindow()) { + return activeWindow + } + return AwtWindow.getWindows() + .filter { window -> window.isShowing && window.isDisplayable && !window.isSharedReaderModalWindow() } + .maxByOrNull { window -> + when { + window.isFocused -> 3 + window.isActive -> 2 + window.isVisible -> 1 + else -> 0 + } + } +} + +private fun AwtWindow.isSharedReaderModalWindow(): Boolean { + val windowTitle = when (this) { + is java.awt.Dialog -> title + is java.awt.Frame -> title + else -> "" + } + return name?.startsWith(SharedReaderModalWindowNamePrefix) == true || + windowTitle.startsWith("Reader Panel") || + windowTitle.startsWith("Reader Popup") || + windowTitle.startsWith("Reader Chrome") +} + +private fun AwtWindow?.isSharedReaderModalWindowForOwner(ownerWindow: AwtWindow): Boolean { + val window = this ?: return false + return window.sharedReaderModalOwnerInWindowChain() == ownerWindow +} + +private fun sharedReaderModalWindowActiveForOwner(ownerWindow: AwtWindow): Boolean { + return AwtWindow.getWindows().any { window -> + window.isShowing && + window.isDisplayable && + window.isSharedReaderModalWindowForOwner(ownerWindow) && + (window.isActive || window.isFocused) + } +} + +private fun AwtWindow.sharedReaderModalOwnerInWindowChain(): AwtWindow? { + var current: AwtWindow? = this + while (current != null) { + val window = current + synchronized(SharedReaderModalOwnerByWindow) { + SharedReaderModalOwnerByWindow[window] + }?.let { owner -> return owner } + current = window.owner + } + return null +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserLinkTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserLinkTest.kt new file mode 100644 index 0000000..5a99006 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParserLinkTest.kt @@ -0,0 +1,151 @@ +package org.dueattendant149.bookreader.paginatedreader + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.sp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HtmlParserLinkTest { + @Test + fun `block anchor propagates href to paragraph text`() { + val blocks = parse( + """ + + +

Continue reading

+ + + """.trimIndent() + ) + + val paragraph = blocks.single() as SemanticParagraph + val linkSpan = paragraph.spans.single { it.linkHref == "chapter2.xhtml#start" } + + assertEquals("Continue reading", paragraph.text) + assertEquals(0, linkSpan.start) + assertEquals(paragraph.text.length, linkSpan.end) + } + + @Test + fun `block anchor propagates href to heading text`() { + val blocks = parse( + """ + + +

Details

+ + + """.trimIndent() + ) + + val heading = blocks.single() as SemanticHeader + + assertEquals("Details", heading.text) + assertTrue(heading.spans.any { span -> + span.linkHref == "#details" && + span.start == 0 && + span.end == heading.text.length + }) + } + + @Test + fun `nested inline spans inherit anchor href`() { + val blocks = parse( + """ + + +

note

+ + + """.trimIndent() + ) + + val paragraph = blocks.single() as SemanticParagraph + + assertEquals("note", paragraph.text) + assertTrue(paragraph.spans.any { span -> + span.tag == "span" && + span.linkHref == "notes.xhtml#n1" && + span.start == 0 && + span.end == paragraph.text.length + }) + } + + @Test + fun `namespaced anchor href is treated as link`() { + val blocks = parse( + """ + + +

Appendix

+ + + """.trimIndent() + ) + + val paragraph = blocks.single() as SemanticParagraph + + assertEquals("Appendix", paragraph.text) + assertTrue(paragraph.spans.any { span -> + span.linkHref == "appendix.xhtml#more" && + span.start == 0 && + span.end == paragraph.text.length + }) + } + + @Test + fun `css font family resolves onto block and inline span styles`() { + val cssRules = CssParser.parse( + cssContent = """ + p { font-family: "BodyFace"; } + i { font-style: italic; } + """.trimIndent(), + cssPath = null, + baseFontSizeSp = 16f, + density = 1f, + constraints = Constraints(maxWidth = 400, maxHeight = 800), + isDarkTheme = false + ).rules + + val blocks = parse( + html = """ + + +

plain italic

+ + + """.trimIndent(), + cssRules = cssRules, + fontFamilyMap = mapOf("bodyface" to FontFamily.Serif) + ) + + val paragraph = blocks.single() as SemanticParagraph + val italicSpan = paragraph.spans.single { it.tag == "i" } + + assertEquals(FontFamily.Serif, paragraph.style.spanStyle.fontFamily) + assertEquals(FontFamily.Serif, italicSpan.style.spanStyle.fontFamily) + assertEquals(FontStyle.Italic, italicSpan.style.spanStyle.fontStyle) + } + + private fun parse( + html: String, + cssRules: OptimizedCssRules = OptimizedCssRules(), + fontFamilyMap: Map = emptyMap() + ): List { + return htmlToSemanticBlocks( + html = html, + cssRules = cssRules, + textStyle = TextStyle(fontSize = 16.sp), + chapterAbsPath = "OEBPS/chapter1.xhtml", + extractionBasePath = "", + density = Density(1f), + fontFamilyMap = fontFamilyMap, + constraints = Constraints(maxWidth = 400, maxHeight = 800) + ) + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManagerTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManagerTest.kt new file mode 100644 index 0000000..40f1527 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManagerTest.kt @@ -0,0 +1,48 @@ +package org.dueattendant149.bookreader.shared + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReaderTtsFileCacheManagerTest { + + @Test + fun `cache files are stable for book chapter text and speaker`() { + val root = Files.createTempDirectory("reader-tts-cache").toFile() + try { + val cache = ReaderTtsFileCacheManager(root) + + val first = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Aoede") + val second = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Aoede") + val otherSpeaker = cache.getCacheFile("Book: One", "Chapter/One", "Hello world.", "Kore") + + assertEquals(first.absolutePath, second.absolutePath) + assertFalse(first.absolutePath == otherSpeaker.absolutePath) + assertTrue(first.parentFile.exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `cache summary filters current speaker`() { + val root = Files.createTempDirectory("reader-tts-cache").toFile() + try { + val cache = ReaderTtsFileCacheManager(root) + cache.saveTotalChunks("Book", "One", 3) + cache.getCacheFile("Book", "One", "Hello.", "Aoede").writeBytes(ByteArray(144)) + cache.getCacheFile("Book", "One", "World.", "Kore").writeBytes(ByteArray(244)) + + val summary = cache.getCacheSummary("Book", "Aoede") + + assertEquals(2, summary.cachedChunkCount) + assertEquals(1, summary.currentVoiceChunkCount) + assertEquals(388, summary.totalSizeBytes) + assertEquals(144, summary.currentVoiceSizeBytes) + } finally { + root.deleteRecursively() + } + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParserTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParserTest.kt new file mode 100644 index 0000000..56281ce --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParserTest.kt @@ -0,0 +1,264 @@ +package org.dueattendant149.bookreader.shared.opds + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedOpdsParserTest { + @Test + fun `parse OPDS 2 feed resolves links facets navigation publications and metadata`() { + val feed = SharedOpdsParser().parse( + bodyString = """ + { + "metadata": {"title": "Catalog"}, + "links": [ + {"rel": "next", "href": "page/2"}, + {"rel": ["search"], "href": "search{?query}"} + ], + "facets": [ + { + "metadata": {"title": "Format"}, + "links": [ + {"title": "EPUB", "href": "?format=epub", "properties": {"active": true}} + ] + } + ], + "navigation": [ + {"title": "Authors", "href": "../authors", "description": "Browse authors"} + ], + "publications": [ + { + "metadata": { + "identifier": "pub-1", + "title": "Example Book", + "description": "Long summary", + "author": [{"name": "Ada Writer", "links": [{"href": "/authors/ada"}]}], + "language": "en", + "publisher": "Example Press", + "published": "2026-01-02", + "subject": [{"name": "Fiction"}], + "belongsTo": {"series": {"name": "Series", "position": 2}} + }, + "images": [ + {"href": "images/thumb.jpg"}, + {"rel": "cover", "href": "images/cover.jpg"} + ], + "links": [ + { + "rel": "http://opds-spec.org/acquisition", + "href": "downloads/book.epub", + "type": "application/epub+zip" + }, + { + "rel": ["http://vaemendis.net/opds-pse/stream"], + "href": "stream/{pageNumber}", + "properties": {"numberOfItems": 12} + } + ] + } + ] + } + """.trimIndent(), + baseUrl = "https://example.org/opds/catalog/index.json" + ) + + assertEquals("Catalog", feed.title) + assertEquals("https://example.org/opds/catalog/page/2", feed.nextUrl) + assertEquals("https://example.org/opds/catalog/search{?query}", feed.searchUrl) + assertEquals(OpdsFacet("EPUB", "Format", "https://example.org/opds/catalog/?format=epub", true), feed.facets.single()) + + val navigation = feed.entries.first { it.isNavigation } + assertEquals("Authors", navigation.title) + assertEquals("https://example.org/opds/authors", navigation.navigationUrl) + + val publication = feed.entries.first { it.isAcquisition } + assertEquals("pub-1", publication.id) + assertEquals("Example Book", publication.title) + assertEquals("Ada Writer", publication.author) + assertEquals("https://example.org/authors/ada", publication.authors.single().url) + assertEquals("Long summary", publication.summary) + assertEquals("https://example.org/opds/catalog/images/cover.jpg", publication.coverUrl) + assertEquals("Example Press", publication.publisher) + assertEquals("2026-01-02", publication.published) + assertEquals("en", publication.language) + assertEquals("Series", publication.series) + assertEquals("2", publication.seriesIndex) + assertEquals(listOf("Fiction"), publication.categories) + assertEquals("https://example.org/opds/catalog/downloads/book.epub", publication.bestAcquisition?.url) + assertEquals("EPUB", publication.bestAcquisition?.formatName) + assertEquals(12, publication.pseCount) + assertEquals("https://example.org/opds/catalog/stream/{pageNumber}", publication.pseUrlTemplate) + assertTrue(publication.isStreamable) + } + + @Test + fun `parse OPDS 1 feed extracts metadata acquisitions and stream info`() { + val feed = SharedOpdsParser().parse( + bodyString = """ + + + XML Catalog + + + + + xml-1 + XML Book + Summary text + + XML Author + /people/xml-author + + XML Press + en + 2025-12-31 + + XML Series + 3 + + + + + + + """.trimIndent(), + baseUrl = "https://example.org/root/feed.xml" + ) + + assertEquals("XML Catalog", feed.title) + assertEquals("https://example.org/root/next.xml", feed.nextUrl) + assertEquals("https://example.org/search.xml", feed.searchUrl) + assertEquals(OpdsFacet("English", "Language", "https://example.org/root/?lang=en", true), feed.facets.single()) + + val entry = feed.entries.single() + assertEquals("xml-1", entry.id) + assertEquals("XML Book", entry.title) + assertEquals("Summary text", entry.summary) + assertEquals(OpdsAuthor("XML Author", "https://example.org/people/xml-author"), entry.authors.single()) + assertEquals("https://example.org/root/thumb.jpg", entry.coverUrl) + assertEquals("XML Press", entry.publisher) + assertEquals("2025-12-31", entry.published) + assertEquals("en", entry.language) + assertEquals("XML Series", entry.series) + assertEquals("3", entry.seriesIndex) + assertEquals(listOf("Fiction"), entry.categories) + assertEquals(OpdsAcquisition("https://example.org/root/book.pdf", "application/pdf"), entry.acquisitions.single()) + assertEquals(8, entry.pseCount) + assertEquals("https://example.org/root/stream/{pageNumber}", entry.pseUrlTemplate) + } + + @Test + fun `parse cover links from opds cover relations and image typed links`() { + val xmlFeed = SharedOpdsParser().parse( + bodyString = """ + + + XML Catalog + + xml-cover + XML Cover Book + + + + + """.trimIndent(), + baseUrl = "https://grimmory.example/api/v1/opds/catalog" + ) + + assertEquals("https://grimmory.example/api/v1/opds/42/cover", xmlFeed.entries.single().coverUrl) + + val jsonFeed = SharedOpdsParser().parse( + bodyString = """ + { + "publications": [ + { + "metadata": {"identifier": "json-cover", "title": "JSON Cover Book"}, + "links": [ + {"rel": "cover", "href": "/api/v1/opds/77/cover", "type": "image/jpeg"}, + {"rel": "http://opds-spec.org/acquisition", "href": "/api/v1/opds/77/download", "type": "application/pdf"} + ] + } + ] + } + """.trimIndent(), + baseUrl = "https://grimmory.example/api/v1/opds/catalog" + ) + + assertEquals("https://grimmory.example/api/v1/opds/77/cover", jsonFeed.entries.single().coverUrl) + } + + @Test + fun `extract OpenSearch template prefers OPDS acquisition feeds over generic Atom feeds`() { + val template = SharedOpdsParser().extractOpenSearchTemplate( + bodyString = """ + + + + + + """.trimIndent(), + openSearchUrl = "https://example.org/opensearch" + ) + + assertEquals( + "https://example.org/feeds/opds/all?query={searchTerms}&per-page={count}&page={startPage}", + template + ) + } + + @Test + fun `parse ebook enclosure links as acquisitions`() { + val feed = SharedOpdsParser().parse( + bodyString = """ + + + Atom Search + + atom-book + Atom Book + + + + + """.trimIndent(), + baseUrl = "https://example.org/feeds/atom/all" + ) + + assertEquals( + OpdsAcquisition("https://example.org/feeds/atom/downloads/book.epub", "application/epub+zip"), + feed.entries.single().acquisitions.single() + ) + } + + @Test + fun `parse OPDS 2 groups and fallback metadata produce navigation entries`() { + val feed = SharedOpdsParser().parse( + bodyString = """ + { + "groups": [ + { + "metadata": {"title": "Group Title"}, + "links": [{"href": "group-feed"}], + "navigation": [{"title": "Nested Nav", "href": "nested"}], + "publications": [{"links": [], "metadata": {"title": "No Identifier"}}] + } + ] + } + """.trimIndent(), + baseUrl = "https://example.org/catalog/" + ) + + assertEquals("OPDS 2.0 Feed", feed.title) + assertEquals("Nested Nav", feed.entries[0].title) + assertEquals("https://example.org/catalog/nested", feed.entries[0].navigationUrl) + assertEquals("Group Title", feed.entries[2].title) + assertEquals("https://example.org/catalog/group-feed", feed.entries[2].navigationUrl) + assertEquals("No Identifier", feed.entries[1].title) + assertFalse(feed.entries[1].isAcquisition) + assertNull(feed.entries[1].bestAcquisition) + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditorTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditorTest.kt new file mode 100644 index 0000000..36b0f0b --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditorTest.kt @@ -0,0 +1,166 @@ +package org.dueattendant149.bookreader.shared.reader + +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SharedEpubMetadataEditorTest { + @Test + fun `rewrite updates existing OPF metadata`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source, metadata = """ + + Old + Old Author + Old summary + + + + """.trimIndent()) + + val result = SharedEpubMetadataEditor.rewrite( + source = source, + destination = output, + update = update() + ) + + assertEquals("New Title", result.title) + assertEquals("New Author", result.author) + assertEquals("New summary", result.description) + assertEquals("New Series", result.seriesName) + assertEquals(2.5, result.seriesIndex) + assertEquals(result, SharedEpubMetadataEditor.readMetadata(output)) + } + + @Test + fun `rewrite creates missing metadata elements`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source, metadata = "") + + val result = SharedEpubMetadataEditor.rewrite(source, output, update()) + + assertEquals("New Title", result.title) + assertEquals("New Author", result.author) + assertEquals("New summary", result.description) + assertEquals("New Series", result.seriesName) + assertEquals(2.5, result.seriesIndex) + } + + @Test + fun `rewrite preserves non OPF zip entries`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source) + + SharedEpubMetadataEditor.rewrite(source, output, update()) + + ZipFile(output).use { zip -> + assertEquals("chapter", zip.getInputStream(assertNotNull(zip.getEntry("OEBPS/chapter.xhtml"))).reader().readText()) + } + } + + @Test + fun `rewrite keeps mimetype first and stored`() = withTempDir { dir -> + val source = File(dir, "source.epub") + val output = File(dir, "output.epub") + writeEpub(source) + + SharedEpubMetadataEditor.rewrite(source, output, update()) + + ZipInputStream(output.inputStream()).use { zip -> + val first = assertNotNull(zip.nextEntry) + assertEquals("mimetype", first.name) + assertEquals(ZipEntry.STORED, first.method) + } + } + + @Test + fun `rewrite in place rejects invalid epub without replacing source`() = withTempDir { dir -> + val source = File(dir, "broken.epub").apply { writeText("not an epub") } + val backup = File(dir, "backup.epub") + + assertFailsWith { + SharedEpubMetadataEditor.rewriteInPlace(source, backup, update()) + } + + assertEquals("not an epub", source.readText()) + assertTrue(!backup.exists()) + } + + private fun update(): SharedEpubMetadataUpdate { + return SharedEpubMetadataUpdate( + title = "New Title", + author = "New Author", + description = "New summary", + seriesName = "New Series", + seriesIndex = 2.5 + ) + } + + private fun writeEpub( + target: File, + metadata: String = """ + + Old + + """.trimIndent() + ) { + ZipOutputStream(target.outputStream()).use { zip -> + zip.putStoredText("mimetype", "application/epub+zip") + zip.putText( + "META-INF/container.xml", + """""" + ) + zip.putText( + "OEBPS/content.opf", + """ + + $metadata + + + + """.trimIndent() + ) + zip.putText("OEBPS/chapter.xhtml", "chapter") + } + } + + private fun ZipOutputStream.putText(name: String, text: String) { + putNextEntry(ZipEntry(name)) + write(text.toByteArray()) + closeEntry() + } + + private fun ZipOutputStream.putStoredText(name: String, text: String) { + val bytes = text.toByteArray() + val crc = CRC32().apply { update(bytes) }.value + val entry = ZipEntry(name).apply { + method = ZipEntry.STORED + size = bytes.size.toLong() + compressedSize = bytes.size.toLong() + this.crc = crc + } + putNextEntry(entry) + write(bytes) + closeEntry() + } + + private inline fun withTempDir(block: (File) -> Unit) { + val dir = createTempDir(prefix = "epub-metadata-test") + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCacheTest.kt new file mode 100644 index 0000000..dab48e6 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCacheTest.kt @@ -0,0 +1,280 @@ +package org.dueattendant149.bookreader.shared.reader + +import androidx.compose.ui.unit.sp +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SharedEpubPaginationCacheTest { + + @Test + fun `page cache round trips measured pages`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val settings = ReaderSettings(fontSize = 19, lineSpacing = 1.5f) + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val pages = listOf( + ReaderPage( + pageIndex = 12, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 4, + endOffset = 15 + ) + ) + + cache.save(book, settings, viewport, pages) + val loaded = cache.load(book, settings, viewport) + + assertNotNull(loaded) + assertEquals(1, loaded.size) + assertEquals(0, loaded.first().pageIndex) + assertEquals("Cached page", loaded.first().text) + assertEquals(4, loaded.first().startOffset) + assertEquals(15, loaded.first().endOffset) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `page cache misses when viewport or chapter content changes`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val settings = ReaderSettings() + val viewport = ReaderViewportSpec(widthPx = 900, heightPx = 700) + val pages = listOf( + ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 0, + endOffset = 11 + ) + ) + + cache.save(book, settings, viewport, pages) + + assertNull(cache.load(book, settings, viewport.copy(widthPx = 901))) + assertNull( + cache.load( + book.copy( + chapters = book.chapters.map { chapter -> + chapter.copy(plainText = chapter.plainText + " Changed.") + } + ), + settings, + viewport + ) + ) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `chapter page cache round trips one measured chapter`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook().copy( + chapters = listOf( + cacheBook().chapters.first(), + cacheBook().chapters.first().copy(id = "chapter-2", title = "Two", plainText = "Second chapter.") + ) + ) + val settings = ReaderSettings() + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val pages = listOf( + ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "First cached page", + startOffset = 0, + endOffset = 17 + ), + ReaderPage( + pageIndex = 1, + chapterIndex = 1, + chapterTitle = "Two", + text = "Second cached page", + startOffset = 0, + endOffset = 18 + ) + ) + + cache.save(book, settings, viewport, pages) + val loadedChapter = cache.loadChapter(book, settings, viewport, chapterIndex = 1) + + assertNotNull(loadedChapter) + assertEquals(1, loadedChapter.size) + assertEquals(1, loadedChapter.first().pageIndex) + assertEquals(1, loadedChapter.first().chapterIndex) + assertEquals("Second cached page", loadedChapter.first().text) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `pagination cache key changes for spread mode`() { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val single = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE), viewport) + val spread = cache.keyFor(book, ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE), viewport) + + assertFalse(single.configHash == spread.configHash) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `page cache ignores semanticless pages for semantic books`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook().copy( + chapters = listOf( + cacheBook().chapters.first().copy( + semanticBlocks = listOf( + SemanticParagraph( + text = "Cached page content.", + spans = emptyList(), + style = CssStyle(fontSize = 22.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ) + ) + ) + ) + val settings = ReaderSettings() + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val pages = listOf( + ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 0, + endOffset = 11 + ) + ) + + cache.save(book, settings, viewport, pages) + + assertNull(cache.load(book, settings, viewport)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `clear all removes persisted and memory pagination pages`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val cache = SharedEpubPaginationCache(root) + val book = cacheBook() + val settings = ReaderSettings() + val viewport = ReaderViewportSpec(widthPx = 960, heightPx = 720) + val pages = listOf( + ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 0, + endOffset = 11 + ) + ) + + cache.save(book, settings, viewport, pages) + assertNotNull(cache.load(book, settings, viewport)) + + cache.clearAll() + + assertNull(cache.load(book, settings, viewport)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `saving more than three configurations removes oldest page cache`() = runBlocking { + val root = Files.createTempDirectory("reader-page-cache").toFile() + try { + val book = cacheBook() + val settings = ReaderSettings() + val pages = listOf( + ReaderPage( + pageIndex = 0, + chapterIndex = 0, + chapterTitle = "One", + text = "Cached page", + startOffset = 0, + endOffset = 11 + ) + ) + val viewports = listOf( + ReaderViewportSpec(widthPx = 900, heightPx = 700), + ReaderViewportSpec(widthPx = 901, heightPx = 700), + ReaderViewportSpec(widthPx = 902, heightPx = 700), + ReaderViewportSpec(widthPx = 903, heightPx = 700) + ) + val writer = SharedEpubPaginationCache(root) + + viewports.take(3).forEachIndexed { index, viewport -> + writer.save(book, settings, viewport, pages) + val key = writer.keyFor(book, settings, viewport) + val file = root + .resolve(key.bookHash) + .resolve("${key.configHash.toUInt().toString(16)}.pages.pb") + file.setLastModified((index + 1) * 1_000L) + } + writer.save(book, settings, viewports.last(), pages) + val reader = SharedEpubPaginationCache(root) + + assertNull(reader.load(book, settings, viewports.first())) + assertNotNull(reader.load(book, settings, viewports.last())) + } finally { + root.deleteRecursively() + } + } + + private fun cacheBook(): SharedEpubBook { + return SharedEpubBook( + id = "book-id", + fileName = "book.epub", + title = "Book", + author = "Author", + chapters = listOf( + SharedEpubChapter( + id = "chapter-1", + title = "One", + plainText = "Cached page content.", + htmlContent = "

Cached page content.

", + baseHref = "one.xhtml" + ) + ) + ) + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCacheTest.kt new file mode 100644 index 0000000..48a3116 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCacheTest.kt @@ -0,0 +1,126 @@ +package org.dueattendant149.bookreader.shared.reader + +import androidx.compose.ui.unit.sp +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph +import org.dueattendant149.bookreader.shared.FileType +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SharedJvmBookLoadCacheTest { + + @Test + fun `book load cache round trips parsed shared book`() { + val root = Files.createTempDirectory("reader-book-load-cache").toFile() + try { + val cache = SharedJvmBookLoadCache(root) + val key = SharedJvmBookLoadCacheKey( + canonicalPath = "C:/Books/book.epub", + type = FileType.EPUB, + length = 1234L, + lastModified = 5678L + ) + val book = SharedEpubBook( + id = "C:/Books/book.epub", + fileName = "book.epub", + title = "Cached Book", + author = "Author", + css = mapOf("style.css" to "p { margin: 0; }"), + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Hello cache.", + htmlContent = "

Hello cache.

", + semanticBlocks = listOf( + SemanticParagraph( + text = "Hello cache.", + spans = emptyList(), + style = CssStyle(fontSize = 18.sp), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 0 + ) + ), + baseHref = "one.xhtml" + ) + ) + ) + + cache.save(key, book) + val loaded = cache.load(key) + + assertNotNull(loaded) + assertEquals(book.title, loaded.title) + assertEquals(book.css, loaded.css) + assertEquals("Hello cache.", loaded.chapters.single().plainText) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `book load cache rejects styled reader books without semantic blocks`() { + val root = Files.createTempDirectory("reader-book-load-cache").toFile() + try { + val cache = SharedJvmBookLoadCache(root) + val key = SharedJvmBookLoadCacheKey( + canonicalPath = "C:/Books/book.epub", + type = FileType.EPUB, + length = 1234L, + lastModified = 5678L + ) + val book = SharedEpubBook( + id = "C:/Books/book.epub", + fileName = "book.epub", + title = "Cached Book", + chapters = listOf( + SharedEpubChapter( + id = "one", + title = "One", + plainText = "Hello cache.", + htmlContent = "

Hello cache.

", + baseHref = "one.xhtml" + ) + ) + ) + + cache.save(key, book) + + assertNull(cache.load(key)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `book load cache misses when source fingerprint changes`() { + val root = Files.createTempDirectory("reader-book-load-cache").toFile() + try { + val cache = SharedJvmBookLoadCache(root) + val key = SharedJvmBookLoadCacheKey( + canonicalPath = "C:/Books/book.epub", + type = FileType.EPUB, + length = 1234L, + lastModified = 5678L + ) + val book = SharedEpubBook( + id = "C:/Books/book.epub", + fileName = "book.epub", + title = "Cached Book", + chapters = listOf(SharedEpubChapter("one", "One", "Hello cache.")) + ) + + cache.save(key, book) + + assertNull(cache.load(key.copy(lastModified = 5679L))) + assertNull(cache.load(key.copy(length = 1235L))) + } finally { + root.deleteRecursively() + } + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoaderTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoaderTest.kt new file mode 100644 index 0000000..1b72d9a --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoaderTest.kt @@ -0,0 +1,476 @@ +package org.dueattendant149.bookreader.shared.reader + +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import org.dueattendant149.bookreader.shared.FileType +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SharedJvmBookLoaderTest { + @Test + fun `docx loader extracts core metadata and body text`() = withTempDir { dir -> + val file = File(dir, "sample.docx") + writeZip(file) { + text( + "docProps/core.xml", + """ + + Portable DOCX + Casey Writer + + """.trimIndent() + ) + text( + "word/document.xml", + """ + + + Hello from DOCX. + + + """.trimIndent() + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.DOCX) + + assertEquals("Portable DOCX", book.title) + assertEquals("Casey Writer", book.author) + assertTrue(book.chapters.single().plainText.contains("Hello from DOCX.")) + } + + @Test + fun `odt loader extracts metadata and document text`() = withTempDir { dir -> + val file = File(dir, "sample.odt") + writeZip(file) { + text( + "meta.xml", + """ + + + Portable ODT + Open Author + + + """.trimIndent() + ) + text( + "content.xml", + """ + + + + ODT Heading + Hello from ODT. + + + + """.trimIndent() + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.ODT) + + assertEquals("Portable ODT", book.title) + assertEquals("Open Author", book.author) + assertTrue(book.chapters.single().plainText.contains("Hello from ODT.")) + } + + @Test + fun `fb2 loader splits readable sections`() = withTempDir { dir -> + val file = File(dir, "sample.fb2").apply { + writeText( + """ + + + + AdaByron + Portable FB2 + + + +
+ <p>First Section</p> +

Hello from FB2.

+
+ +
+ """.trimIndent() + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.FB2) + + assertEquals("Portable FB2", book.title) + assertEquals("Ada Byron", book.author) + assertEquals("First Section", book.chapters.single().title) + assertTrue(book.chapters.single().plainText.contains("Hello from FB2.")) + } + + @Test + fun `mobi loader reads uncompressed palmdoc text records`() = withTempDir { dir -> + val file = File(dir, "sample.mobi").apply { + writeBytes( + minimalMobi( + "

Hello from MOBI.

".toByteArray(Charsets.UTF_8) + ) + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.MOBI) + + assertEquals("sample", book.title) + assertTrue(book.chapters.single().plainText.contains("Hello from MOBI.")) + } + + @Test + fun `mobi loader reads bundled huff cdic sample`() { + val file = findRepoFile("app/src/main/cpp/libmobi/tests/samples/sample-unicode-huffdic.mobi") + + val book = SharedJvmBookLoader.load(file, FileType.MOBI) + + assertEquals("Libmobi", book.title) + assertTrue(book.chapters.joinToString("\n") { it.plainText }.length > 100) + } + + @Test + fun `html loader splits generated pdf reflow page breaks`() = withTempDir { dir -> + val file = File(dir, "source_reflow.html").apply { + writeText( + """ + + + Source (Reflow) + +

-- Page 1 --

First page text.

+ +

-- Page 2 --

Second page text.

+ + + """.trimIndent() + ) + } + + val book = SharedJvmBookLoader.load(file, FileType.HTML) + + assertEquals("Source (Reflow)", book.title) + assertEquals(2, book.chapters.size) + assertTrue(book.chapters[0].plainText.contains("First page text.")) + assertEquals("Page 2", book.chapters[1].title) + assertTrue(book.chapters[1].plainText.contains("Second page text.")) + } + + @Test + fun `epub loader keeps embedded images in semantic pagination blocks`() = withTempDir { dir -> + val file = File(dir, "image-book.epub") + writeImageEpub(file) + + val book = SharedJvmBookLoader.loadEpub(file) + val image = book.chapters.single().semanticBlocks.filterIsInstance().single() + + assertTrue(image.path.startsWith("data:image/png;base64,")) + assertEquals("Pixel", image.altText) + } + + @Test + fun `epub loader can skip semantic blocks for vertical fast path`() = withTempDir { dir -> + val file = File(dir, "image-book.epub") + writeImageEpub(file) + + val book = SharedJvmBookLoader.loadEpub(file, parseSemanticBlocks = false) + val chapter = book.chapters.single() + + assertTrue(chapter.semanticBlocks.isEmpty()) + assertTrue(chapter.htmlContent.contains("data:image/png;base64,")) + assertTrue(chapter.plainText.contains("Before")) + assertTrue(chapter.plainText.contains("After")) + } + + @Test + fun `epub loader can prepare html for selected vertical chapters only`() = withTempDir { dir -> + val file = File(dir, "two-chapters.epub") + writeTwoChapterEpub(file) + + val book = SharedJvmBookLoader.loadEpub( + file = file, + parseSemanticBlocks = false, + preparedHtmlChapterRange = 1..1 + ) + + assertEquals(2, book.chapters.size) + assertTrue(book.chapters[0].plainText.contains("First chapter text")) + assertTrue(book.chapters[0].htmlContent.isBlank()) + assertTrue(book.chapters[1].plainText.contains("Second chapter text")) + assertTrue(book.chapters[1].htmlContent.contains("Second chapter text")) + } + + @Test + fun `epub loader does not inline stylesheet font resources`() = withTempDir { dir -> + val file = File(dir, "font-book.epub") + writeImageEpub(file) + + val css = SharedJvmBookLoader.loadEpub(file).css.values.single() + + assertTrue(css.contains("fonts/reader.woff2")) + assertTrue(!css.contains("data:font/woff2")) + } + + @Test + fun `epub loader uses spine toc id when manifest contains volume ncx files first`() = withTempDir { dir -> + val file = File(dir, "merged-volumes.epub") + writeMergedVolumeTocEpub(file) + + val book = SharedJvmBookLoader.loadEpub(file) + + assertEquals( + listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"), + book.tableOfContents.map { it.label } + ) + assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth }) + assertEquals("2/title.xhtml", book.tableOfContents[2].href) + assertEquals(4, book.chapters.size) + } + + private fun withTempDir(block: (File) -> Unit) { + val dir = Files.createTempDirectory("reader-shared-loader").toFile() + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun findRepoFile(path: String): File { + return generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile } + .take(8) + .map { File(it, path) } + .firstOrNull { it.isFile } + ?: error("Missing test fixture: $path") + } + + private fun writeZip(file: File, block: ZipBuilder.() -> Unit) { + ZipOutputStream(file.outputStream()).use { zip -> + ZipBuilder(zip).block() + } + } + + private fun writeImageEpub(file: File) { + writeZip(file) { + text( + "META-INF/container.xml", + """ + + + + + + """.trimIndent() + ) + text( + "OPS/content.opf", + """ + + + Image Book + + + + + + + + + + + + """.trimIndent() + ) + text( + "OPS/chapter.xhtml", + """ + + +

One

+

Before

+ Pixel +

After

+ + + """.trimIndent() + ) + text( + "OPS/styles/book.css", + """ + @font-face { + font-family: "Fixture Serif"; + src: url("fonts/reader.woff2") format("woff2"); + } + body { font-family: "Fixture Serif"; } + """.trimIndent() + ) + bytes("OPS/images/pixel.png", onePixelPng) + bytes("OPS/styles/fonts/reader.woff2", byteArrayOf(0, 1, 2, 3)) + } + } + + private fun writeMergedVolumeTocEpub(file: File) { + writeZip(file) { + text( + "META-INF/container.xml", + """ + + + + + + """.trimIndent() + ) + text( + "content.opf", + """ + + + Merged Volumes + + + + + + + + + + + + + + + + + """.trimIndent() + ) + text( + "1/toc.ncx", + """ + + Volume 1 + + """.trimIndent() + ) + text( + "toc.ncx", + """ + + Volume 1 + Chapter 1 + + Volume 2 + Chapter 2 + + + """.trimIndent() + ) + text("1/title.xhtml", "

Volume 1

Volume one.

") + text("1/chapter1.xhtml", "

Chapter 1

Chapter one.

") + text("2/title.xhtml", "

Volume 2

Volume two.

") + text("2/chapter1.xhtml", "

Chapter 2

Chapter two.

") + } + } + + private fun writeTwoChapterEpub(file: File) { + writeZip(file) { + text( + "META-INF/container.xml", + """ + + + + + + """.trimIndent() + ) + text( + "OPS/content.opf", + """ + + + Two Chapters + + + + + + + + + + + """.trimIndent() + ) + text( + "OPS/first.xhtml", + """ +

First

First chapter text.

+ """.trimIndent() + ) + text( + "OPS/second.xhtml", + """ +

Second

Second chapter text.

+ """.trimIndent() + ) + } + } + + private fun minimalMobi(textRecord: ByteArray): ByteArray { + val record0 = ByteArray(16) + record0.writeU16(0, 1) + record0.writeU32(4, textRecord.size) + record0.writeU16(8, 1) + record0.writeU16(10, 4096) + record0.writeU16(12, 0) + + val record0Offset = 78 + 16 + val record1Offset = record0Offset + record0.size + val header = ByteArray(record0Offset) + header.writeU16(76, 2) + header.writeU32(78, record0Offset) + header.writeU32(86, record1Offset) + return header + record0 + textRecord + } + + private fun ByteArray.writeU16(offset: Int, value: Int) { + this[offset] = ((value ushr 8) and 0xFF).toByte() + this[offset + 1] = (value and 0xFF).toByte() + } + + private fun ByteArray.writeU32(offset: Int, value: Int) { + this[offset] = ((value ushr 24) and 0xFF).toByte() + this[offset + 1] = ((value ushr 16) and 0xFF).toByte() + this[offset + 2] = ((value ushr 8) and 0xFF).toByte() + this[offset + 3] = (value and 0xFF).toByte() + } + + private class ZipBuilder(private val zip: ZipOutputStream) { + fun text(path: String, value: String) { + bytes(path, value.toByteArray(Charsets.UTF_8)) + } + + fun bytes(path: String, value: ByteArray) { + zip.putNextEntry(ZipEntry(path)) + zip.write(value) + zip.closeEntry() + } + } + + private val onePixelPng: ByteArray = + Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=") +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt new file mode 100644 index 0000000..3cb4d20 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCacheTest.kt @@ -0,0 +1,22 @@ +package org.dueattendant149.bookreader.shared.reader + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SharedJvmLruMemoryCacheTest { + @Test + fun `cache evicts least recently used entry`() { + val cache = SharedJvmLruMemoryCache(maxEntries = 2) + + cache["one"] = 1 + cache["two"] = 2 + assertEquals(1, cache["one"]) + + cache["three"] = 3 + + assertEquals(1, cache["one"]) + assertNull(cache["two"]) + assertEquals(3, cache["three"]) + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectoriesTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectoriesTest.kt new file mode 100644 index 0000000..9ff502e --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectoriesTest.kt @@ -0,0 +1,32 @@ +package org.dueattendant149.bookreader.shared.reader + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedJvmUserDirectoriesTest { + @Test + fun `shared jvm cache root uses xdg cache on linux`() { + val root = sharedJvmEpistemeCacheRoot( + env = mapOf("XDG_CACHE_HOME" to "/tmp/reader-cache")::get, + userHome = "/home/reader", + osName = "Linux" + ) + + assertEquals("/tmp/reader-cache/episteme", root.portablePath()) + } + + @Test + fun `shared jvm cache root preserves windows appdata location`() { + val root = sharedJvmEpistemeCacheRoot( + env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming")::get, + userHome = "C:/Users/reader", + osName = "Windows 11" + ) + + assertEquals("C:/Users/reader/AppData/Roaming/Episteme", root.portablePath()) + } +} + +private fun java.io.File.portablePath(): String { + return path.replace('\\', '/') +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt new file mode 100644 index 0000000..b6f7766 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginatorTest.kt @@ -0,0 +1,211 @@ +package org.dueattendant149.bookreader.shared.reader + +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.style.Hyphens +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.dueattendant149.bookreader.paginatedreader.BlockStyle +import org.dueattendant149.bookreader.paginatedreader.BoxBorders +import org.dueattendant149.bookreader.paginatedreader.CssStyle +import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class SharedMeasuredEpubPaginatorTest { + + @Test + fun `two page geometry caps each page to rendered page width on wide viewports`() { + val geometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + margin = 48, + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ), + viewport = ReaderViewportSpec(widthPx = 2_400, heightPx = 1_200) + ) + + assertEquals(760, geometry.pageWidthPx) + assertEquals(1_104, geometry.pageHeightPx) + } + + @Test + fun `two page geometry subtracts margins inside each rendered page on constrained viewports`() { + val geometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + horizontalMargin = 80, + verticalMargin = 40, + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ), + viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900) + ) + + assertEquals(476, geometry.pageWidthPx) + assertEquals(820, geometry.pageHeightPx) + } + + @Test + fun `paginated single page geometry matches one rendered page in a spread`() { + val singlePageGeometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + horizontalMargin = 80, + verticalMargin = 40, + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.SINGLE + ), + viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900) + ) + val twoPageGeometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + horizontalMargin = 80, + verticalMargin = 40, + readingMode = ReaderReadingMode.PAGINATED, + pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE + ), + viewport = ReaderViewportSpec(widthPx = 1_300, heightPx = 900) + ) + + assertEquals(twoPageGeometry, singlePageGeometry) + assertEquals(476, singlePageGeometry.pageWidthPx) + assertEquals(820, singlePageGeometry.pageHeightPx) + } + + @Test + fun `geometry does not invent minimum page space beyond the rendered viewport`() { + val geometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + horizontalMargin = 80, + verticalMargin = 120 + ), + viewport = ReaderViewportSpec(widthPx = 300, heightPx = 220) + ) + + assertEquals(140, geometry.pageWidthPx) + assertEquals(1, geometry.pageHeightPx) + } + + @Test + fun `geometry scales css-sized page settings to measured desktop pixels`() { + val geometry = measuredPageGeometryFor( + settings = ReaderSettings( + pageWidth = 760, + horizontalMargin = 0, + verticalMargin = 0 + ), + viewport = ReaderViewportSpec(widthPx = 1_900, heightPx = 860), + densityScale = 1.25f + ) + + assertEquals(950, geometry.pageWidthPx) + assertEquals(860, geometry.pageHeightPx) + } + + @Test + fun `paragraph split trims whitespace and prepares continuation styling`() { + val paragraph = SemanticParagraph( + text = "Alpha beta gamma delta", + spans = emptyList(), + style = CssStyle( + paragraphStyle = ParagraphStyle( + textIndent = TextIndent(firstLine = 24.sp, restLine = 8.sp) + ), + blockStyle = BlockStyle( + margin = BoxBorders(top = 12.dp) + ) + ), + elementId = null, + cfi = null, + startCharOffsetInSource = 100, + blockIndex = 7 + ) + + val split = assertNotNull(splitSemanticTextBlockAtOffsetForPagination(paragraph, 11)) + + assertEquals("Alpha beta", split.first.text) + assertEquals(100, split.first.startCharOffsetInSource) + assertEquals("gamma delta", split.second.text) + assertEquals(112, split.second.startCharOffsetInSource) + assertEquals( + TextIndent(firstLine = 0.sp, restLine = 8.sp), + split.second.style.paragraphStyle.textIndent + ) + assertEquals(0.dp, split.second.style.blockStyle.margin.top) + } + + @Test + fun `measurement paragraph style keeps css indent hyphenation and android justify rule`() { + val paragraph = SemanticParagraph( + text = "Indented paragraph", + spans = emptyList(), + style = CssStyle( + paragraphStyle = ParagraphStyle( + textAlign = TextAlign.Justify, + textIndent = TextIndent(firstLine = 20.sp, restLine = 4.sp) + ), + hyphens = "auto" + ), + elementId = null, + cfi = null, + startCharOffsetInSource = 0, + blockIndex = 1 + ) + + val defaultAlignStyle = paragraph.toMeasurementParagraphStyleForPagination(TextAlign.Start) + val forcedJustifyStyle = paragraph.toMeasurementParagraphStyleForPagination(TextAlign.Justify) + + assertEquals(TextAlign.Left, defaultAlignStyle.textAlign) + assertEquals(TextAlign.Justify, forcedJustifyStyle.textAlign) + assertEquals(TextIndent(firstLine = 20.sp, restLine = 4.sp), defaultAlignStyle.textIndent) + assertEquals(Hyphens.Auto, defaultAlignStyle.hyphens) + } + + @Test + fun `pagination stack collapses adjacent margins and can ignore trailing bottom margin`() { + val items = listOf( + PaginationStackItem(contentHeightPx = 100, marginTopPx = 18, marginBottomPx = 18), + PaginationStackItem(contentHeightPx = 80, marginTopPx = 18, marginBottomPx = 18) + ) + + assertEquals( + 216, + collapsedPaginationStackHeight(items, includeTrailingBottomMargin = false) + ) + assertEquals( + 234, + collapsedPaginationStackHeight(items, includeTrailingBottomMargin = true) + ) + } + + @Test + fun `pagination stack prefix fitting includes trailing bottom margin`() { + val items = listOf( + PaginationStackItem(contentHeightPx = 100, marginTopPx = 10, marginBottomPx = 30), + PaginationStackItem(contentHeightPx = 80, marginTopPx = 10, marginBottomPx = 30) + ) + + assertEquals( + 1, + paginationStackPrefixCountThatFits( + items = items, + availableHeightPx = 220, + includeTrailingBottomMargin = true + ) + ) + assertEquals( + 2, + paginationStackPrefixCountThatFits( + items = items, + availableHeightPx = 220, + includeTrailingBottomMargin = false + ) + ) + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCacheTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCacheTest.kt new file mode 100644 index 0000000..b8af7a8 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopBookCoverImageCacheTest.kt @@ -0,0 +1,69 @@ +package org.dueattendant149.bookreader.shared.ui + +import java.awt.Color +import java.awt.image.BufferedImage +import java.nio.file.Files +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopBookCoverImageCacheTest { + + @Test + fun `cover cache reloads when source fingerprint changes`() { + val root = Files.createTempDirectory("reader-cover-cache").toFile() + try { + DesktopBookCoverImageCache.clearForTests() + val cover = root.resolve("cover.png") + writeImage(cover.absolutePath, width = 64, height = 64) + + val first = DesktopBookCoverImageCache.load(cover.absolutePath) + assertNotNull(first) + assertEquals(64, first.width) + + writeImage(cover.absolutePath, width = 96, height = 48) + cover.setLastModified(cover.lastModified() + 2_000L) + + val second = DesktopBookCoverImageCache.load(cover.absolutePath) + assertNotNull(second) + assertEquals(96, second.width) + assertEquals(48, second.height) + } finally { + DesktopBookCoverImageCache.clearForTests() + root.deleteRecursively() + } + } + + @Test + fun `large covers are cached at thumbnail size`() { + val root = Files.createTempDirectory("reader-cover-cache").toFile() + try { + DesktopBookCoverImageCache.clearForTests() + val cover = root.resolve("large-cover.png") + writeImage(cover.absolutePath, width = 1_200, height = 800) + + val bitmap = DesktopBookCoverImageCache.load(cover.absolutePath) + + assertNotNull(bitmap) + assertTrue(bitmap.width <= 512) + assertTrue(bitmap.height <= 512) + } finally { + DesktopBookCoverImageCache.clearForTests() + root.deleteRecursively() + } + } + + private fun writeImage(path: String, width: Int, height: Int) { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val graphics = image.createGraphics() + try { + graphics.color = Color(0x2A, 0x5C, 0x88) + graphics.fillRect(0, 0, width, height) + } finally { + graphics.dispose() + } + ImageIO.write(image, "png", java.io.File(path)) + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImageTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImageTest.kt new file mode 100644 index 0000000..4829313 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/DesktopEpubNativeImageTest.kt @@ -0,0 +1,16 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.ui.Alignment +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopEpubNativeImageTest { + @Test + fun `desktop native epub image maps css object position to compose alignment`() { + assertEquals(Alignment.TopStart, desktopEpubImageContentAlignment("left top")) + assertEquals(Alignment.TopStart, desktopEpubImageContentAlignment("top left")) + assertEquals(Alignment.BottomStart, desktopEpubImageContentAlignment("0% 100%")) + assertEquals(Alignment.CenterEnd, desktopEpubImageContentAlignment("right center")) + assertEquals(Alignment.Center, desktopEpubImageContentAlignment(null)) + } +} diff --git a/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt new file mode 100644 index 0000000..3808608 --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/dueattendant149/bookreader/reader/shared/ui/SharedReaderModalLayerDesktopTest.kt @@ -0,0 +1,179 @@ +package org.dueattendant149.bookreader.shared.ui + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SharedReaderModalLayerDesktopTest { + + @Test + fun `left reader panel uses extension width instead of blocking full reader`() { + assertEquals( + 340.dp, + sharedReaderModalEdgePanelLayerWidth( + level = SharedReaderModalLevel.PanelLeft, + anchorWidth = 1280.dp + ) + ) + } + + @Test + fun `right reader panel remains an inspector width`() { + assertEquals( + 380.dp, + sharedReaderModalEdgePanelLayerWidth( + level = SharedReaderModalLevel.PanelRight, + anchorWidth = 1280.dp + ) + ) + } + + @Test + fun `bottom chrome layer overlaps reader edge to avoid desktop rounding gap`() { + assertEquals( + 948f, + sharedReaderModalChromeBottomLayerTopPx( + anchorTopPx = 100f, + anchorHeightPx = 1000f, + dialogHeightPx = 160f, + overlapPx = 8f + ) + ) + } + + @Test + fun `chrome top layer can opt into focus for search input`() { + assertFalse( + sharedReaderModalLayerWindowFocusable( + level = SharedReaderModalLevel.ChromeTop, + focusableOverride = null + ) + ) + assertTrue( + sharedReaderModalLayerWindowFocusable( + level = SharedReaderModalLevel.ChromeTop, + focusableOverride = true + ) + ) + } + + @Test + fun `chrome bottom layer stays non focusable by default`() { + assertFalse( + sharedReaderModalLayerWindowFocusable( + level = SharedReaderModalLevel.ChromeBottom, + focusableOverride = null + ) + ) + } + + @Test + fun `chrome layer remains visible when owner window is focused`() { + assertTrue( + sharedReaderModalChromeLayerVisible( + ownerShowing = true, + ownerDisplayable = true, + ownerMinimized = false, + ownerActive = false, + ownerFocused = true, + ownerModalActive = false + ) + ) + } + + @Test + fun `chrome layer remains visible while its own reader modal is active`() { + assertTrue( + sharedReaderModalChromeLayerVisible( + ownerShowing = true, + ownerDisplayable = true, + ownerMinimized = false, + ownerActive = false, + ownerFocused = false, + ownerModalActive = true + ) + ) + } + + @Test + fun `chrome layer hides when owner loses focus to another app`() { + assertFalse( + sharedReaderModalChromeLayerVisible( + ownerShowing = true, + ownerDisplayable = true, + ownerMinimized = false, + ownerActive = false, + ownerFocused = false, + ownerModalActive = false + ) + ) + } + + @Test + fun `chrome layer hides when owner window is unavailable`() { + assertFalse( + sharedReaderModalChromeLayerVisible( + ownerShowing = false, + ownerDisplayable = true, + ownerMinimized = false, + ownerActive = true, + ownerFocused = true, + ownerModalActive = false + ) + ) + assertFalse( + sharedReaderModalChromeLayerVisible( + ownerShowing = true, + ownerDisplayable = false, + ownerMinimized = false, + ownerActive = true, + ownerFocused = true, + ownerModalActive = false + ) + ) + assertFalse( + sharedReaderModalChromeLayerVisible( + ownerShowing = true, + ownerDisplayable = true, + ownerMinimized = true, + ownerActive = true, + ownerFocused = true, + ownerModalActive = false + ) + ) + } + + @Test + fun `modal layer hides immediately while owner window is closing`() { + assertTrue( + sharedReaderModalLayerShouldHideImmediately( + ownerShowing = true, + ownerDisplayable = true, + ownerClosing = true + ) + ) + assertTrue( + sharedReaderModalLayerShouldHideImmediately( + ownerShowing = false, + ownerDisplayable = true, + ownerClosing = false + ) + ) + assertTrue( + sharedReaderModalLayerShouldHideImmediately( + ownerShowing = true, + ownerDisplayable = false, + ownerClosing = false + ) + ) + assertFalse( + sharedReaderModalLayerShouldHideImmediately( + ownerShowing = true, + ownerDisplayable = true, + ownerClosing = false + ) + ) + } +} diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/paginatedreader/HtmlParser.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/ReaderTtsFileCacheManager.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/opds/SharedOpdsParser.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/pptx/SharedPptxDocument.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubMetadataEditor.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedEpubPaginationCache.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoadCache.kt diff --git a/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoader.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoader.kt new file mode 100644 index 0000000..f91efed --- /dev/null +++ b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmBookLoader.kt @@ -0,0 +1,1827 @@ +package org.dueattendant149.bookreader.shared.reader + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.sp +import org.dueattendant149.bookreader.paginatedreader.CssParser +import org.dueattendant149.bookreader.paginatedreader.HtmlResourceResolver +import org.dueattendant149.bookreader.paginatedreader.OptimizedCssRules +import org.dueattendant149.bookreader.paginatedreader.SemanticBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer +import org.dueattendant149.bookreader.paginatedreader.SemanticImage +import org.dueattendant149.bookreader.paginatedreader.SemanticList +import org.dueattendant149.bookreader.paginatedreader.SemanticMath +import org.dueattendant149.bookreader.paginatedreader.SemanticSpacer +import org.dueattendant149.bookreader.paginatedreader.SemanticTable +import org.dueattendant149.bookreader.paginatedreader.SemanticTextBlock +import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock +import org.dueattendant149.bookreader.paginatedreader.UserAgentStylesheet +import org.dueattendant149.bookreader.paginatedreader.htmlToSemanticBlocks +import org.dueattendant149.bookreader.shared.FileType +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.parser.Parser +import java.io.ByteArrayOutputStream +import java.io.ByteArrayInputStream +import java.io.File +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.Charset +import java.util.Base64 +import java.util.UUID +import java.util.zip.ZipFile +import javax.imageio.ImageIO + +private const val JvmBookOpenTraceTag = "EpistemeDesktopOpenTrace" + +private fun logJvmBookOpenTrace(message: () -> String) { + logSharedReaderDiagnostic(JvmBookOpenTraceTag, message) +} + +private fun Long.jvmBookOpenTraceElapsedMs(nowNanos: Long = System.nanoTime()): Long { + return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L +} + +private fun String.jvmBookOpenTracePreview(maxLength: Int = 96): String { + return replace(Regex("\\s+"), " ") + .trim() + .let { if (it.length <= maxLength) it else it.take(maxLength) + "..." } + .replace("\"", "\\\"") +} + +private fun SharedEpubBook.jvmBookOpenTraceSummary(): String { + return "title=\"${title.jvmBookOpenTracePreview(120)}\" chapters=${chapters.size} " + + "textChars=${chapters.sumOf { it.plainText.length }} " + + "htmlChars=${chapters.sumOf { it.htmlContent.length }} " + + "semanticBlocks=${chapters.sumOf { it.semanticBlocks.size }} " + + "cssFiles=${css.size} cssChars=${css.values.sumOf { it.length }} toc=${tableOfContents.size}" +} + +private fun IntRange.toOpenTraceRangeKey(): String { + return "$first..$last" +} + +object SharedJvmBookLoader { + private val persistentBookCache = SharedJvmBookLoadCache() + private val loadedBookCache = SharedJvmLruMemoryCache(maxEntries = 12) + private val htmlPageBreakRegex = Regex("(?is)]*>(?:\\s*)?") + + fun load( + file: File, + type: FileType, + titleOverride: String? = null, + authorOverride: String? = null, + semanticMode: SharedJvmBookLoadSemanticMode = SharedJvmBookLoadSemanticMode.FULL, + preparedHtmlChapterRange: IntRange? = null + ): SharedEpubBook { + val loadStartedAt = System.nanoTime() + require(file.isFile) { "Missing reader file: ${file.absolutePath}" } + val preparedHtmlChapterRangeKey = preparedHtmlChapterRange?.toOpenTraceRangeKey() + val key = SharedJvmBookLoadCacheKey( + canonicalPath = file.canonicalPath, + type = type, + length = file.length(), + lastModified = file.lastModified(), + semanticMode = semanticMode, + htmlChapterRange = preparedHtmlChapterRangeKey + ) + logJvmBookOpenTrace { + "event=shared_load_start type=${type.name} file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "semanticMode=${semanticMode.name} preparedHtmlChapters=${preparedHtmlChapterRangeKey ?: "all"} " + + "bytes=${file.length()} lastModified=${file.lastModified()} cacheId=${key.cacheId} " + + "path=\"${file.absolutePath.jvmBookOpenTracePreview(220)}\"" + } + synchronized(loadedBookCache) { + loadedBookCache[key]?.let { cached -> + logJvmBookOpenTrace { + "event=shared_load_memory_hit cacheId=${key.cacheId} " + + "elapsedMs=${loadStartedAt.jvmBookOpenTraceElapsedMs()} ${cached.jvmBookOpenTraceSummary()}" + } + return cached.withOverrides(titleOverride = titleOverride, authorOverride = authorOverride) + } + } + + val diskCacheStartedAt = System.nanoTime() + val diskCached = persistentBookCache.load(key) + logJvmBookOpenTrace { + "event=shared_load_disk_cache_lookup result=${if (diskCached != null) "hit" else "miss"} " + + "cacheId=${key.cacheId} durationMs=${diskCacheStartedAt.jvmBookOpenTraceElapsedMs()}" + } + val source: String + val loaded = if (diskCached != null) { + source = "disk_cache" + diskCached + } else { + val parseStartedAt = System.nanoTime() + logJvmBookOpenTrace { + "event=shared_parse_start type=${type.name} semanticMode=${semanticMode.name} cacheId=${key.cacheId} " + + "file=\"${file.name.jvmBookOpenTracePreview(120)}\"" + } + val parsed = when (type) { + FileType.EPUB -> loadEpub( + file = file, + parseSemanticBlocks = semanticMode == SharedJvmBookLoadSemanticMode.FULL, + preparedHtmlChapterRange = preparedHtmlChapterRange + ) + FileType.HTML -> loadHtml(file) + FileType.TXT, + FileType.MD -> loadPlainText(file) + FileType.FB2 -> loadFb2(file) + FileType.DOCX -> loadDocx(file) + FileType.ODT -> loadOdt(file, isFlat = false) + FileType.FODT -> loadOdt(file, isFlat = true) + FileType.MOBI -> loadMobi(file) + else -> error("${type.name} is not supported by the shared JVM reader loader.") + } + logJvmBookOpenTrace { + "event=shared_parse_done type=${type.name} semanticMode=${semanticMode.name} cacheId=${key.cacheId} " + + "durationMs=${parseStartedAt.jvmBookOpenTraceElapsedMs()} ${parsed.jvmBookOpenTraceSummary()}" + } + val saveStartedAt = System.nanoTime() + persistentBookCache.save(key, parsed) + logJvmBookOpenTrace { + "event=shared_load_disk_cache_save cacheId=${key.cacheId} " + + "durationMs=${saveStartedAt.jvmBookOpenTraceElapsedMs()}" + } + source = "parsed" + parsed + } + + synchronized(loadedBookCache) { + loadedBookCache[key] = loaded + } + logJvmBookOpenTrace { + "event=shared_load_done source=$source cacheId=${key.cacheId} " + + "elapsedMs=${loadStartedAt.jvmBookOpenTraceElapsedMs()} ${loaded.jvmBookOpenTraceSummary()}" + } + return loaded.withOverrides(titleOverride = titleOverride, authorOverride = authorOverride) + } + + fun clearCache() { + persistentBookCache.clear() + synchronized(loadedBookCache) { + loadedBookCache.clear() + } + } + + fun loadEpub( + file: File, + parseSemanticBlocks: Boolean = true, + preparedHtmlChapterRange: IntRange? = null + ): SharedEpubBook { + val parseStartedAt = System.nanoTime() + val preparedHtmlChapterRangeKey = preparedHtmlChapterRange?.toOpenTraceRangeKey() + logJvmBookOpenTrace { + "event=epub_parse_start file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "parseSemanticBlocks=$parseSemanticBlocks preparedHtmlChapters=${preparedHtmlChapterRangeKey ?: "all"} " + + "bytes=${file.length()} " + + "path=\"${file.absolutePath.jvmBookOpenTracePreview(220)}\"" + } + val zipStartedAt = System.nanoTime() + ZipFile(file).use { zip -> + logJvmBookOpenTrace { + "event=epub_zip_opened file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${zipStartedAt.jvmBookOpenTraceElapsedMs()} entries=${zip.size()}" + } + val opfStartedAt = System.nanoTime() + val container = zip.readTextOrNull("META-INF/container.xml") + val opfPath = container + ?.substringAfter("full-path=\"", missingDelimiterValue = "") + ?.substringBefore("\"") + ?.takeIf { it.isNotBlank() } + ?: zip.entries().asSequence() + .map { it.name } + .firstOrNull { it.endsWith(".opf", ignoreCase = true) } + ?: error("EPUB container does not point to an OPF package.") + val opf = zip.readText(opfPath) + val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "") + .let { if (it.isBlank()) "" else "$it/" } + + val title = opf.tagText("title").ifBlank { file.nameWithoutExtension } + val author = opf.tagText("creator").ifBlank { null } + val manifest = parseEpubManifest(opf) + logJvmBookOpenTrace { + "event=epub_opf_ready file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${opfStartedAt.jvmBookOpenTraceElapsedMs()} opfPath=\"${opfPath.jvmBookOpenTracePreview(160)}\" " + + "basePath=\"${basePath.jvmBookOpenTracePreview(120)}\" manifestItems=${manifest.size} opfChars=${opf.length} " + + "title=\"${title.jvmBookOpenTracePreview(120)}\"" + } + val cssStartedAt = System.nanoTime() + val cssByPath = loadEpubCss(zip, manifest, basePath) + logJvmBookOpenTrace { + "event=epub_css_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${cssStartedAt.jvmBookOpenTraceElapsedMs()} cssFiles=${cssByPath.size} " + + "cssChars=${cssByPath.values.sumOf { it.length }}" + } + val cssRulesStartedAt = System.nanoTime() + val cssRules = if (parseSemanticBlocks) parseCssRules(cssByPath) else OptimizedCssRules() + logJvmBookOpenTrace { + "event=epub_css_rules_parsed file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${cssRulesStartedAt.jvmBookOpenTraceElapsedMs()} cssFiles=${cssByPath.size} " + + "skipped=${!parseSemanticBlocks}" + } + val tocStartedAt = System.nanoTime() + val tableOfContents = parseEpubTableOfContents(zip, opf, manifest, basePath) + logJvmBookOpenTrace { + "event=epub_toc_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${tocStartedAt.jvmBookOpenTraceElapsedMs()} entries=${tableOfContents.size}" + } + val spineStartedAt = System.nanoTime() + val spine = Regex("]*idref=[\"']([^\"']+)[\"'][^>]*/?>") + .findAll(opf) + .mapNotNull { match -> manifest[match.groupValues[1]] } + .toList() + + val chapterPaths = spine.ifEmpty { + manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) } + } + logJvmBookOpenTrace { + "event=epub_spine_ready file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${spineStartedAt.jvmBookOpenTraceElapsedMs()} spineItems=${spine.size} " + + "chapterCandidates=${chapterPaths.size}" + } + + val chaptersStartedAt = System.nanoTime() + val chapters = chapterPaths.mapIndexedNotNull { index, href -> + val chapterStartedAt = System.nanoTime() + val path = normalizeZipPath(basePath + href) + val html = zip.readTextOrNull(path) + if (html == null) { + logJvmBookOpenTrace { + "event=epub_chapter_missing file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "index=$index path=\"${path.jvmBookOpenTracePreview(160)}\"" + } + return@mapIndexedNotNull null + } + val shouldPrepareHtml = parseSemanticBlocks || + preparedHtmlChapterRange == null || + index in preparedHtmlChapterRange + val resourcesStartedAt = System.nanoTime() + val resourceReadyHtml = if (shouldPrepareHtml) { + html.sanitizeReaderHtml().withEmbeddedResources(zip, path) + } else { + "" + } + val resourceDurationMs = resourcesStartedAt.jvmBookOpenTraceElapsedMs() + val textStartedAt = System.nanoTime() + val text = if (parseSemanticBlocks) html.htmlToText() else html.fastHtmlToText() + val textDurationMs = textStartedAt.jvmBookOpenTraceElapsedMs() + val chapter = chapterFromHtml( + id = "chapter_$index", + title = html.tagText("h1") + .ifBlank { html.tagText("h2") } + .ifBlank { html.tagText("title") } + .ifBlank { "Chapter ${index + 1}" }, + html = resourceReadyHtml, + plainText = text, + baseHref = path, + cssRules = cssRules, + parseSemanticBlocks = parseSemanticBlocks + ) + val accepted = text.isNotBlank() || chapter.semanticBlocks.isNotEmpty() || resourceReadyHtml.hasVisualHtmlContent() + val chapterDurationMs = chapterStartedAt.jvmBookOpenTraceElapsedMs() + if (parseSemanticBlocks || shouldPrepareHtml || chapterDurationMs >= 50) { + logJvmBookOpenTrace { + "event=epub_chapter_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "index=$index accepted=$accepted durationMs=$chapterDurationMs " + + "htmlPrepared=$shouldPrepareHtml resourceMs=$resourceDurationMs textMs=$textDurationMs " + + "path=\"${path.jvmBookOpenTracePreview(160)}\" title=\"${chapter.title.jvmBookOpenTracePreview(120)}\" " + + "htmlChars=${html.length} embeddedHtmlChars=${resourceReadyHtml.length} textChars=${text.length} " + + "semanticBlocks=${chapter.semanticBlocks.size}" + } + } + chapter.takeIf { accepted } + } + logJvmBookOpenTrace { + "event=epub_chapters_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${chaptersStartedAt.jvmBookOpenTraceElapsedMs()} chapters=${chapters.size} " + + "candidates=${chapterPaths.size}" + } + + val book = SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + author = author, + css = cssByPath, + tableOfContents = tableOfContents, + chapters = chapters.ifEmpty { + listOf( + SharedEpubChapter( + id = UUID.randomUUID().toString(), + title = title, + plainText = "This EPUB opened, but no readable spine text was found by the shared JVM loader." + ) + ) + } + ) + logJvmBookOpenTrace { + "event=epub_parse_done file=\"${file.name.jvmBookOpenTracePreview(120)}\" " + + "durationMs=${parseStartedAt.jvmBookOpenTraceElapsedMs()} ${book.jvmBookOpenTraceSummary()}" + } + return book + } + } + + private fun loadPlainText(file: File): SharedEpubBook { + val text = file.readTextLenient() + return SharedTextBookFactory.fromPlainText( + id = file.absolutePath, + fileName = file.name, + title = file.nameWithoutExtension, + plainText = text + ) + } + + private fun loadHtml(file: File): SharedEpubBook { + val html = file.readTextLenient() + val sanitized = html.sanitizeReaderHtml() + val title = sanitized.tagText("title").ifBlank { sanitized.tagText("h1") }.ifBlank { file.nameWithoutExtension } + val pageChapters = sanitized.splitHtmlPageBreakChapters(title) + if (pageChapters.size > 1) { + return ParsedDocument( + title = title, + chapters = pageChapters + ).toBook(file, parseCssRules(emptyMap())) + } + return SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + chapters = listOf( + chapterFromHtml( + id = "chapter_0", + title = sanitized.tagText("h1").ifBlank { title }, + html = sanitized, + plainText = sanitized.htmlToText().ifBlank { title }, + baseHref = file.absolutePath, + cssRules = parseCssRules(emptyMap()) + ) + ) + ) + } + + private fun loadFb2(file: File): SharedEpubBook { + val bytes = if (file.extension.equals("zip", ignoreCase = true)) { + ZipFile(file).use { zip -> + val entry = zip.entries().asSequence().firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) } + ?: error("No .fb2 file found inside the ZIP archive.") + zip.getInputStream(entry).use { it.readBytes() } + } + } else { + file.readBytes() + } + val parsed = parseFb2(bytes, file.nameWithoutExtension) + return parsed.toBook(file, parseCssRules(emptyMap())) + } + + private fun loadDocx(file: File): SharedEpubBook { + ZipFile(file).use { zip -> + val documentXml = zip.readBytesOrNull("word/document.xml") + ?: error("word/document.xml not found in DOCX archive.") + val metadata = zip.readBytesOrNull("docProps/core.xml")?.let(::parseCoreMetadata) ?: ParsedMetadata() + val html = parseDocxBody(documentXml) + val title = metadata.title.takeUnlessBlank() ?: file.nameWithoutExtension + return htmlBook( + file = file, + title = title, + author = metadata.author.takeUnlessBlank(), + html = html.ifBlank { "

This DOCX did not contain readable text.

" }, + chapterTitle = title + ) + } + } + + private fun loadOdt(file: File, isFlat: Boolean): SharedEpubBook { + val contentBytes: ByteArray + val metadata: ParsedMetadata + if (isFlat) { + contentBytes = file.readBytes() + metadata = parseCoreMetadata(contentBytes) + } else { + ZipFile(file).use { zip -> + contentBytes = zip.readBytesOrNull("content.xml") ?: error("content.xml not found in ODT archive.") + metadata = zip.readBytesOrNull("meta.xml")?.let(::parseCoreMetadata) + ?: parseCoreMetadata(contentBytes) + } + } + + val title = metadata.title.takeUnlessBlank() ?: file.nameWithoutExtension + val html = parseOdtBody(contentBytes) + return htmlBook( + file = file, + title = title, + author = metadata.author.takeUnlessBlank(), + html = html.ifBlank { "

This document did not contain readable text.

" }, + chapterTitle = title + ) + } + + private fun loadMobi(file: File): SharedEpubBook { + val mobi = parseMobi(file.readBytes(), file.nameWithoutExtension) + val title = mobi.title.takeUnlessBlank() ?: file.nameWithoutExtension + val author = mobi.author.takeUnlessBlank() + return if (mobi.chapters.isNotEmpty()) { + val cssRules = parseCssRules(emptyMap()) + SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + author = author, + chapters = mobi.chapters.mapIndexed { index, chapter -> + chapterFromHtml( + id = "mobi_chapter_$index", + title = chapter.title.takeUnlessBlank() ?: "Chapter ${index + 1}", + html = chapter.html, + plainText = chapter.plainText.takeUnlessBlank() ?: chapter.html.htmlToText(), + baseHref = file.absolutePath, + cssRules = cssRules + ) + } + ) + } else if (mobi.html.isNotBlank()) { + htmlBook( + file = file, + title = title, + author = author, + html = mobi.html, + chapterTitle = title + ) + } else { + SharedTextBookFactory.fromPlainText( + id = file.absolutePath, + fileName = file.name, + title = title, + plainText = mobi.text.ifBlank { "This MOBI did not contain readable text." }, + author = author + ) + } + } + + private fun htmlBook( + file: File, + title: String, + author: String?, + html: String, + chapterTitle: String + ): SharedEpubBook { + val sanitized = html.sanitizeReaderHtml() + return SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = title, + author = author, + chapters = listOf( + chapterFromHtml( + id = "chapter_0", + title = sanitized.tagText("h1").ifBlank { sanitized.tagText("h2") }.ifBlank { chapterTitle }, + html = sanitized, + plainText = sanitized.htmlToText().ifBlank { title }, + baseHref = file.absolutePath, + cssRules = parseCssRules(emptyMap()) + ) + ) + ) + } + + private fun ParsedDocument.toBook(file: File, cssRules: OptimizedCssRules): SharedEpubBook { + val safeTitle = title.takeUnlessBlank() ?: file.nameWithoutExtension + val chapterDrafts = chapters.ifEmpty { + listOf( + ParsedChapter( + title = safeTitle, + html = "

${plainText.escapeHtml()}

", + plainText = plainText + ) + ) + } + return SharedEpubBook( + id = file.absolutePath, + fileName = file.name, + title = safeTitle, + author = author.takeUnlessBlank(), + chapters = chapterDrafts.mapIndexed { index, chapter -> + val html = chapter.html.ifBlank { "

${chapter.plainText.escapeHtml()}

" } + chapterFromHtml( + id = "chapter_$index", + title = chapter.title.takeUnlessBlank() ?: "Chapter ${index + 1}", + html = html, + plainText = chapter.plainText.takeUnlessBlank() ?: html.htmlToText(), + baseHref = file.absolutePath, + cssRules = cssRules + ) + } + ) + } + + private fun chapterFromHtml( + id: String, + title: String, + html: String, + plainText: String, + baseHref: String?, + cssRules: OptimizedCssRules, + parseSemanticBlocks: Boolean = true + ): SharedEpubChapter { + val semanticStartedAt = System.nanoTime() + var semanticError: Throwable? = null + val semanticBlocks = if (parseSemanticBlocks) { + runCatching { + htmlToSemanticBlocks( + html = html, + cssRules = cssRules, + textStyle = TextStyle(fontSize = 18.sp), + chapterAbsPath = baseHref.orEmpty(), + extractionBasePath = "", + density = Density(1f), + fontFamilyMap = emptyMap(), + constraints = Constraints(maxWidth = 980, maxHeight = 720), + resourceResolver = SharedJvmHtmlResourceResolver + ) + }.onFailure { error -> + semanticError = error + }.getOrDefault(emptyList()) + } else { + emptyList() + } + if (parseSemanticBlocks) { + logJvmBookOpenTrace { + "event=semantic_blocks_built title=\"${title.jvmBookOpenTracePreview(120)}\" skipped=false " + + "baseHref=\"${baseHref.orEmpty().jvmBookOpenTracePreview(160)}\" " + + "durationMs=${semanticStartedAt.jvmBookOpenTraceElapsedMs()} htmlChars=${html.length} " + + "plainTextChars=${plainText.length} blocks=${semanticBlocks.size} " + + "error=\"${semanticError?.message.orEmpty().jvmBookOpenTracePreview(160)}\"" + } + } + return SharedEpubChapter( + id = id, + title = title, + plainText = plainText.takeUnlessBlank() ?: semanticBlocks.semanticFallbackText().ifBlank { title }, + semanticBlocks = semanticBlocks, + htmlContent = html.extractBodyOrSelf(), + baseHref = baseHref + ) + } + + private fun parseFb2(bytes: ByteArray, fallbackTitle: String): ParsedDocument { + val document = xmlDocument(bytes) + val titleInfo = document.allElementsByLocalTag("title-info").firstOrNull() + val bookTitle = titleInfo + ?.allElementsByLocalTag("book-title") + ?.firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + val authors = titleInfo + ?.childrenByLocalTag("author") + ?.mapNotNull { it.fb2AuthorName() } + ?.distinct() + .orEmpty() + val body = document.allElementsByLocalTag("body").firstOrNull() + val topLevelSections = body?.childrenByLocalTag("section").orEmpty() + val chapters = if (topLevelSections.isNotEmpty()) { + topLevelSections.mapIndexedNotNull { index, section -> + section.toFb2Chapter(index) + } + } else { + val chapter = body?.toFb2Chapter(0) + if (chapter == null) emptyList() else listOf(chapter) + } + return ParsedDocument( + title = bookTitle.takeUnlessBlank() ?: fallbackTitle, + author = authors.joinToString(", ").takeUnlessBlank(), + chapters = chapters + ) + } + + private fun parseDocxBody(bytes: ByteArray): String { + val document = xmlDocument(bytes) + val html = StringBuilder() + document.allElementsByLocalTag("p").forEach { paragraph -> + val paragraphStyle = paragraph.allElementsByLocalTag("pstyle") + .firstOrNull() + ?.xmlAttr("val") + val text = StringBuilder() + paragraph.getAllElements().forEach { element -> + when (element.xmlTag()) { + "t" -> text.append(element.wholeText().escapeHtml()) + "tab" -> text.append(" ") + "br" -> text.append("
") + } + } + val paragraphHtml = text.toString() + if (paragraphHtml.htmlToText().isNotBlank()) { + val tag = if (paragraphStyle.orEmpty().contains("heading", ignoreCase = true)) "h2" else "p" + html.append("<$tag>").append(paragraphHtml).append("\n") + } + } + return html.toString() + } + + private fun parseOdtBody(bytes: ByteArray): String { + val document = xmlDocument(bytes) + val body = document.allElementsByLocalTag("text").firstOrNull() ?: document + val html = StringBuilder() + val plain = StringBuilder() + body.childNodes().forEach { appendOdtNode(it, html, plain) } + return html.toString() + } + + private fun parseCoreMetadata(bytes: ByteArray): ParsedMetadata { + val document = xmlDocument(bytes) + val title = document.allElementsByLocalTag("title") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + val author = document.allElementsByLocalTag("creator") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + ?: document.allElementsByLocalTag("initial-creator") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + return ParsedMetadata(title = title, author = author) + } + + private fun Element.toFb2Chapter(index: Int): ParsedChapter? { + val html = StringBuilder() + val plain = StringBuilder() + if (xmlTag() == "section" || xmlTag() == "body") { + childNodes().forEach { appendFb2Node(it, html, plain, headingLevel = 2) } + } else { + appendFb2Element(this, html, plain, headingLevel = 2) + } + val text = plain.toString().normalizeReaderWhitespace() + if (text.isBlank() && html.isBlank()) return null + val title = childrenByLocalTag("title") + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + .takeUnlessBlank() + ?: "Chapter ${index + 1}" + return ParsedChapter( + title = title, + html = html.toString(), + plainText = text + ) + } + + private fun Element.fb2AuthorName(): String? { + return listOf("first-name", "middle-name", "last-name", "nickname") + .mapNotNull { part -> + childrenByLocalTag(part) + .firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + .takeUnlessBlank() + } + .joinToString(" ") + .takeUnlessBlank() + } + + private fun appendFb2Node(node: Node, html: StringBuilder, plain: StringBuilder, headingLevel: Int) { + when (node) { + is TextNode -> { + val text = node.text() + if (text.isNotBlank()) { + html.append(text.escapeHtml()) + plain.append(text) + } + } + is Element -> appendFb2Element(node, html, plain, headingLevel) + } + } + + private fun appendFb2Element(element: Element, html: StringBuilder, plain: StringBuilder, headingLevel: Int) { + when (element.xmlTag()) { + "section" -> element.childNodes().forEach { + appendFb2Node(it, html, plain, (headingLevel + 1).coerceAtMost(6)) + } + "title" -> { + val tag = "h${headingLevel.coerceIn(2, 6)}" + val text = element.text().normalizeReaderWhitespace() + if (text.isNotBlank()) { + html.append("<$tag>").append(text.escapeHtml()).append("\n") + plain.append(text).append('\n') + } + } + "p", "v" -> appendWrappedFb2Children(element, "p", html, plain, headingLevel) + "subtitle" -> appendWrappedFb2Children(element, "h3", html, plain, headingLevel) + "empty-line" -> { + html.append("
") + plain.append('\n') + } + "strong" -> appendWrappedFb2Children(element, "b", html, plain, headingLevel, block = false) + "emphasis" -> appendWrappedFb2Children(element, "i", html, plain, headingLevel, block = false) + "strikethrough" -> appendWrappedFb2Children(element, "s", html, plain, headingLevel, block = false) + "sup" -> appendWrappedFb2Children(element, "sup", html, plain, headingLevel, block = false) + "sub" -> appendWrappedFb2Children(element, "sub", html, plain, headingLevel, block = false) + "poem", "stanza", "epigraph" -> appendWrappedFb2Children(element, "div", html, plain, headingLevel) + "cite" -> appendWrappedFb2Children(element, "blockquote", html, plain, headingLevel) + "a" -> { + val href = element.xmlAttr("href") + html.append(if (href.isNullOrBlank()) "" else "") + element.childNodes().forEach { appendFb2Node(it, html, plain, headingLevel) } + html.append("") + } + "image" -> { + val href = element.xmlAttr("href")?.removePrefix("#").orEmpty() + if (href.isNotBlank()) { + html.append("

").append(href.escapeHtml()).append("

\n") + plain.append(href).append('\n') + } + } + else -> element.childNodes().forEach { + appendFb2Node(it, html, plain, headingLevel) + } + } + } + + private fun appendWrappedFb2Children( + element: Element, + tag: String, + html: StringBuilder, + plain: StringBuilder, + headingLevel: Int, + block: Boolean = true + ) { + html.append("<$tag>") + element.childNodes().forEach { appendFb2Node(it, html, plain, headingLevel) } + html.append("") + if (block) { + html.append('\n') + plain.append('\n') + } + } + + private fun appendOdtNode(node: Node, html: StringBuilder, plain: StringBuilder) { + when (node) { + is TextNode -> { + val text = node.text() + if (text.isNotBlank()) { + html.append(text.escapeHtml()) + plain.append(text) + } + } + is Element -> appendOdtElement(node, html, plain) + } + } + + private fun appendOdtElement(element: Element, html: StringBuilder, plain: StringBuilder) { + when (element.xmlTag()) { + "h" -> { + val level = element.xmlAttr("outline-level") + ?.toIntOrNull() + ?.coerceIn(1, 6) + ?: 2 + appendOdtWrappedElement(element, "h$level", html, plain) + } + "p" -> appendOdtWrappedElement(element, "p", html, plain) + "span" -> appendOdtWrappedElement(element, "span", html, plain, block = false) + "a" -> { + val href = element.xmlAttr("href") + html.append(if (href.isNullOrBlank()) "" else "") + element.childNodes().forEach { appendOdtNode(it, html, plain) } + html.append("") + } + "list" -> appendOdtWrappedElement(element, "ul", html, plain) + "list-item" -> appendOdtWrappedElement(element, "li", html, plain) + "table" -> appendOdtWrappedElement(element, "table", html, plain) + "table-row" -> appendOdtWrappedElement(element, "tr", html, plain) + "table-cell" -> appendOdtWrappedElement(element, "td", html, plain, block = false) + "line-break" -> { + html.append("
") + plain.append('\n') + } + "tab" -> { + html.append("    ") + plain.append(" ") + } + else -> element.childNodes().forEach { appendOdtNode(it, html, plain) } + } + } + + private fun appendOdtWrappedElement( + element: Element, + tag: String, + html: StringBuilder, + plain: StringBuilder, + block: Boolean = true + ) { + html.append("<$tag>") + element.childNodes().forEach { appendOdtNode(it, html, plain) } + html.append("") + if (block) { + html.append('\n') + plain.append('\n') + } + } + + private fun parseMobi(bytes: ByteArray, fallbackTitle: String): ParsedMobi { + require(bytes.size > 86) { "Invalid MOBI/Palm database." } + val recordCount = bytes.u16(76) + require(recordCount > 1) { "MOBI file does not contain text records." } + val offsets = (0 until recordCount).map { index -> + bytes.u32(78 + index * 8).toInt() + }.filter { it in bytes.indices } + require(offsets.size > 1) { "MOBI file has invalid record offsets." } + val records = offsets.mapIndexed { index, offset -> + val end = offsets.getOrNull(index + 1) ?: bytes.size + bytes.copyOfRange(offset, end.coerceAtLeast(offset)) + } + val header = records.first() + require(header.size >= 16) { "MOBI text header is missing." } + + val compression = header.u16(0) + val textLength = header.u32(4).toInt() + val textRecordCount = header.u16(8).coerceAtMost(records.lastIndex) + val textRecordSize = header.u16(10).takeIf { it > 0 } ?: 4096 + val encryption = header.u16(12) + require(encryption == 0) { "Encrypted MOBI files are not supported." } + require(compression == MOBI_COMPRESSION_NONE || + compression == MOBI_COMPRESSION_PALMDOC || + compression == MOBI_COMPRESSION_HUFFCDIC + ) { + "MOBI compression $compression is not supported by the shared JVM loader." + } + + val mobiHeader = parseMobiHeaderInfo(header) + val encoding = mobiHeader.encoding ?: 1252 + val charset = when (encoding) { + 65001 -> Charsets.UTF_8 + 1200 -> Charsets.UTF_16 + 1252 -> Charset.forName("windows-1252") + else -> Charsets.UTF_8 + } + val huffCdic = if (compression == MOBI_COMPRESSION_HUFFCDIC) { + parseMobiHuffCdic(records, mobiHeader.huffRecordIndex, mobiHeader.huffRecordCount) + } else { + null + } + + val rawTextBytes = buildList { + for (index in 1..textRecordCount) { + val record = records.getOrNull(index) ?: continue + val textRecord = record.withoutMobiTrailingData(mobiHeader.extraFlags) + add( + when (compression) { + MOBI_COMPRESSION_NONE -> textRecord.withoutOldMobiZeros() + MOBI_COMPRESSION_PALMDOC -> decompressPalmDoc(textRecord) + MOBI_COMPRESSION_HUFFCDIC -> decompressHuffman(textRecord, huffCdic, textRecordSize) + else -> textRecord + } + ) + } + }.flattenBytes() + .let { if (textLength in 1 until it.size) it.copyOf(textLength) else it } + + val resourceMap = mobiHeader.imageIndex + ?.let { imageIndex -> parseMobiResources(records, imageIndex) } + .orEmpty() + val rawText = decodeMobiText(rawTextBytes, charset).withMobiEmbeddedResources(resourceMap) + val metadata = parseMobiMetadata(header, charset) + val title = metadata.title.takeUnlessBlank() ?: fallbackTitle + val author = metadata.author.takeUnlessBlank() + val looksLikeHtml = rawText.contains(" header.size) return@repeat + val type = header.u32(offset).toInt() + val size = header.u32(offset + 4).toInt() + if (size < 8 || offset + size > header.size) return@repeat + val value = header.safeString(offset + 8, size - 8, charset) + when (type) { + 100 -> author = author ?: value + 99 -> exthTitle = exthTitle ?: value + 503 -> exthTitle = exthTitle ?: value + } + offset += size + } + } + return ParsedMetadata(title = exthTitle.takeUnlessBlank() ?: fullName.takeUnlessBlank(), author = author) + } + + private fun parseMobiHeaderInfo(header: ByteArray): MobiHeaderInfo { + if (header.size < 32 || header.asciiAt(16, 4) != "MOBI") return MobiHeaderInfo() + val mobiHeaderLength = header.u32(20).toInt() + fun u32InHeader(offset: Int): Int? { + if (mobiHeaderLength < offset + 4 || 16 + offset + 4 > header.size) return null + return header.u32(16 + offset).toInt() + .takeIf { it >= 0 && it != MOBI_NOT_SET } + } + fun u16InHeader(offset: Int): Int { + if (mobiHeaderLength < offset + 2 || 16 + offset + 2 > header.size) return 0 + return header.u16(16 + offset) + } + return MobiHeaderInfo( + encoding = u32InHeader(12), + imageIndex = u32InHeader(92), + huffRecordIndex = u32InHeader(96), + huffRecordCount = u32InHeader(100), + extraFlags = u16InHeader(242) + ) + } + + private fun parseMobiHuffCdic( + records: List, + huffRecordIndex: Int?, + huffRecordCount: Int? + ): MobiHuffCdic { + val start = huffRecordIndex ?: error("HUFF/CDIC MOBI is missing HUFF record metadata.") + val count = huffRecordCount ?: error("HUFF/CDIC MOBI is missing CDIC record metadata.") + require(count >= 2 && start > 0 && start + count <= records.size) { + "HUFF/CDIC record metadata points outside the MOBI record table." + } + + val huff = records[start] + require(huff.size >= HUFF_RECORD_MIN_SIZE && huff.asciiAt(0, 4) == "HUFF") { + "MOBI HUFF record is missing or corrupt." + } + val huffHeaderLength = huff.u32(4).toInt() + require(huffHeaderLength >= HUFF_HEADER_LENGTH) { "MOBI HUFF record header is too short." } + val data1Offset = huff.u32(8).toInt() + val data2Offset = huff.u32(12).toInt() + require(data1Offset >= 0 && data1Offset + 256 * 4 <= huff.size) { "MOBI HUFF table 1 is corrupt." } + require(data2Offset >= 0 && data2Offset + 64 * 4 <= huff.size) { "MOBI HUFF table 2 is corrupt." } + + val table1 = IntArray(256) { index -> huff.u32(data1Offset + index * 4).toInt() } + val mincodeTable = LongArray(HUFF_CODETABLE_SIZE) + val maxcodeTable = LongArray(HUFF_CODETABLE_SIZE) + mincodeTable[0] = 0L + maxcodeTable[0] = UINT32_MAX + var tableOffset = data2Offset + for (index in 1 until HUFF_CODETABLE_SIZE) { + val mincode = huff.u32(tableOffset) + val maxcode = huff.u32(tableOffset + 4) + mincodeTable[index] = (mincode shl (32 - index)) and UINT32_MAX + maxcodeTable[index] = (((maxcode + 1L) shl (32 - index)) - 1L) and UINT32_MAX + tableOffset += 8 + } + + var codeLength = 0 + var indexCount = 0 + var indexRead = 0 + val symbolOffsets = mutableListOf() + val symbols = mutableListOf() + + for (recordOffset in 1 until count) { + val cdic = records[start + recordOffset] + require(cdic.size >= CDIC_HEADER_LENGTH && cdic.asciiAt(0, 4) == "CDIC") { + "MOBI CDIC record is missing or corrupt." + } + val cdicHeaderLength = cdic.u32(4).toInt() + require(cdicHeaderLength >= CDIC_HEADER_LENGTH) { "MOBI CDIC record header is too short." } + val totalIndexCount = cdic.u32(8).toInt() + val currentCodeLength = cdic.u32(12).toInt() + require(currentCodeLength in 1..HUFF_CODELEN_MAX) { "MOBI CDIC code length is invalid." } + if (codeLength == 0) codeLength = currentCodeLength + if (indexCount == 0) indexCount = totalIndexCount + require(codeLength == currentCodeLength && indexCount == totalIndexCount) { + "MOBI CDIC records disagree about dictionary dimensions." + } + + var entriesToRead = totalIndexCount - indexRead + if ((entriesToRead ushr codeLength) > 0) { + entriesToRead = 1 shl codeLength + } + require(entriesToRead >= 0 && CDIC_HEADER_LENGTH + entriesToRead * 2 <= cdic.size) { + "MOBI CDIC symbol table is corrupt." + } + var offset = CDIC_HEADER_LENGTH + repeat(entriesToRead) { + val symbolOffset = cdic.u16(offset) + val symbolStart = CDIC_HEADER_LENGTH + symbolOffset + require(symbolStart + 2 <= cdic.size) { "MOBI CDIC symbol offset is corrupt." } + val symbolLength = cdic.u16(symbolStart) and 0x7FFF + require(symbolStart + 2 + symbolLength <= cdic.size) { "MOBI CDIC symbol data is corrupt." } + symbolOffsets += symbolOffset + indexRead += 1 + offset += 2 + } + symbols += cdic.copyOfRange(CDIC_HEADER_LENGTH, cdic.size) + } + + require(indexCount == indexRead && symbolOffsets.size == indexCount) { + "MOBI CDIC dictionary did not provide all symbol offsets." + } + return MobiHuffCdic( + indexCount = indexCount, + codeLength = codeLength, + table1 = table1, + mincodeTable = mincodeTable, + maxcodeTable = maxcodeTable, + symbolOffsets = symbolOffsets.toIntArray(), + symbols = symbols + ) + } + + private fun decompressHuffman(input: ByteArray, huffCdic: MobiHuffCdic?, textRecordSize: Int): ByteArray { + require(huffCdic != null) { "MOBI HUFF/CDIC dictionary is missing." } + val output = ByteArrayOutputStream((textRecordSize * 2).coerceAtLeast(input.size)) + decompressHuffmanInto(input, output, huffCdic, depth = 0) + return output.toByteArray() + } + + private fun decompressHuffmanInto( + input: ByteArray, + output: ByteArrayOutputStream, + huffCdic: MobiHuffCdic, + depth: Int + ) { + require(depth <= MOBI_HUFFMAN_MAX_DEPTH) { "MOBI HUFF/CDIC recursion limit exceeded." } + var bitCount = 32 + var bitsLeft = input.size * 8 + var inputOffset = 0 + var buffer = input.huffmanFill64(inputOffset) + inputOffset += 4 + + while (true) { + if (bitCount <= 0) { + bitCount += 32 + buffer = input.huffmanFill64(inputOffset) + inputOffset += 4 + } + val code = (buffer ushr bitCount) and UINT32_MAX + val tableEntry = huffCdic.table1[(code ushr 24).toInt()].toLong() and UINT32_MAX + var codeLength = (tableEntry and 0x1F).toInt() + if (codeLength <= 0 || codeLength >= HUFF_CODETABLE_SIZE) { + break + } + var maxcode = ((((tableEntry ushr 8) + 1L) shl (32 - codeLength)) - 1L) and UINT32_MAX + if ((tableEntry and 0x80L) == 0L) { + while (code < huffCdic.mincodeTable[codeLength]) { + codeLength += 1 + require(codeLength < HUFF_CODETABLE_SIZE) { "MOBI HUFF code table offset is corrupt." } + } + maxcode = huffCdic.maxcodeTable[codeLength] + } + + bitCount -= codeLength + bitsLeft -= codeLength + if (bitsLeft < 0) break + + val symbolIndex = ((maxcode - code) ushr (32 - codeLength)).toInt() + require(symbolIndex in 0 until huffCdic.indexCount) { "MOBI HUFF symbol index is corrupt." } + val cdicIndex = symbolIndex ushr huffCdic.codeLength + val symbols = huffCdic.symbols.getOrNull(cdicIndex) + ?: error("MOBI HUFF symbol record is missing.") + val offset = huffCdic.symbolOffsets[symbolIndex] + require(offset + 2 <= symbols.size) { "MOBI HUFF symbol offset is corrupt." } + val symbolHeader = symbols.u16(offset) + val isDecompressed = (symbolHeader and 0x8000) != 0 + val symbolLength = symbolHeader and 0x7FFF + require(offset + 2 + symbolLength <= symbols.size) { "MOBI HUFF symbol data is corrupt." } + + if (isDecompressed) { + output.write(symbols, offset + 2, symbolLength) + } else { + decompressHuffmanInto( + input = symbols.copyOfRange(offset + 2, offset + 2 + symbolLength), + output = output, + huffCdic = huffCdic, + depth = depth + 1 + ) + } + } + } + + private fun ByteArray.huffmanFill64(offset: Int): Long { + var value = 0L + var shiftIndex = 8 + var index = offset + var bytesLeft = (size - offset).coerceAtLeast(0) + while (shiftIndex > 0 && bytesLeft > 0) { + shiftIndex -= 1 + value = value or ((this[index].toLong() and 0xFFL) shl (shiftIndex * 8)) + index += 1 + bytesLeft -= 1 + } + return value + } + + private fun ByteArray.withoutMobiTrailingData(extraFlags: Int): ByteArray { + if (extraFlags == 0 || isEmpty()) return this + val extraSize = mobiTrailingDataSize(extraFlags) + return if (extraSize in 1 until size) copyOf(size - extraSize) else this + } + + private fun ByteArray.mobiTrailingDataSize(extraFlags: Int): Int { + var position = lastIndex + var extraSize = 0 + for (bit in 15 downTo 1) { + if ((extraFlags and (1 shl bit)) == 0) continue + val value = readBackwardVarlen(position) ?: return 0 + position = value.nextPosition - (value.size - value.byteCount) + if (position < -1) return 0 + extraSize += value.size + } + if ((extraFlags and 1) != 0 && position in indices) { + extraSize += (this[position].toInt() and 0x03) + 1 + } + return extraSize.coerceIn(0, size) + } + + private fun ByteArray.readBackwardVarlen(start: Int): MobiBackwardVarlen? { + var value = 0 + var shift = 0 + var count = 0 + var index = start + while (index >= 0 && count < 4) { + val byte = this[index].toInt() and 0xFF + value = value or ((byte and 0x7F) shl shift) + count += 1 + index -= 1 + if ((byte and 0x80) != 0) { + return MobiBackwardVarlen(size = value, byteCount = count, nextPosition = start - count) + } + shift += 7 + } + return null + } + + private fun ByteArray.withoutOldMobiZeros(): ByteArray { + return if (0.toByte() in this) filter { it != 0.toByte() }.toByteArray() else this + } + + private fun parseMobiResources(records: List, imageIndex: Int): Map { + if (imageIndex <= 0 || imageIndex >= records.size) return emptyMap() + var imageNumber = 1 + val resources = mutableMapOf() + for (recordIndex in imageIndex until records.size) { + val bytes = records[recordIndex] + val mimeType = bytes.mobiResourceMimeType() ?: continue + resources[imageNumber] = "data:$mimeType;base64,${Base64.getEncoder().encodeToString(bytes)}" + imageNumber += 1 + } + return resources + } + + private fun ByteArray.mobiResourceMimeType(): String? { + return when { + size >= 3 && + (this[0].toInt() and 0xFF) == 0xFF && + (this[1].toInt() and 0xFF) == 0xD8 && + (this[2].toInt() and 0xFF) == 0xFF -> "image/jpeg" + size >= 8 && asciiAt(1, 3) == "PNG" -> "image/png" + size >= 6 && (asciiAt(0, 6) == "GIF87a" || asciiAt(0, 6) == "GIF89a") -> "image/gif" + size >= 12 && asciiAt(0, 4) == "RIFF" && asciiAt(8, 4) == "WEBP" -> "image/webp" + size >= 2 && asciiAt(0, 2) == "BM" -> "image/bmp" + else -> null + } + } + + private fun String.withMobiEmbeddedResources(resources: Map): String { + if (resources.isEmpty() || !contains("kindle:", ignoreCase = true) && !contains("recindex", ignoreCase = true)) { + return this + } + val document = Jsoup.parse(this) + document.select("img").forEach { image -> + val embedIndex = image.attr("src") + .substringAfter("kindle:embed:", missingDelimiterValue = "") + .substringBefore("?") + .toIntOrNull() + val recordIndex = image.attr("recindex").toIntOrNull() + val replacement = embedIndex?.let(resources::get) + ?: recordIndex?.let(resources::get) + if (replacement != null) { + image.attr("src", replacement) + image.removeAttr("recindex") + } + } + return document.outerHtml() + } + + private fun splitMobiHtmlChapters(html: String, fallbackTitle: String): List { + val parts = Regex("(?is)]*>").split(html) + .map { it.trim() } + .filter { it.htmlToText().isNotBlank() } + if (parts.size <= 1) return emptyList() + return parts.mapIndexed { index, chapterHtml -> + val title = chapterHtml.tagText("h1") + .ifBlank { chapterHtml.tagText("h2") } + .ifBlank { if (index == 0) fallbackTitle else "Chapter ${index + 1}" } + ParsedChapter( + title = title, + html = chapterHtml, + plainText = chapterHtml.htmlToText() + ) + } + } + + private fun String.splitHtmlPageBreakChapters(fallbackTitle: String): List { + if (!contains(" + ParsedChapter( + title = if (index == 0) { + chapterHtml.tagText("h1") + .ifBlank { chapterHtml.tagText("h2") } + .ifBlank { fallbackTitle } + } else { + "Page ${index + 1}" + }, + html = chapterHtml, + plainText = chapterHtml.htmlToText() + ) + } + } + + private fun decompressPalmDoc(input: ByteArray): ByteArray { + val output = ArrayList(input.size * 2) + var i = 0 + while (i < input.size) { + val c = input[i].toInt() and 0xFF + i += 1 + when (c) { + 0 -> output.add(0) + in 1..8 -> { + repeat(c) { + if (i < input.size) output.add(input[i++]) + } + } + in 9..0x7F -> output.add(c.toByte()) + in 0x80..0xBF -> { + if (i >= input.size) return output.toByteArray() + val pair = (c shl 8) or (input[i].toInt() and 0xFF) + i += 1 + val distance = (pair shr 3) and 0x7FF + val length = (pair and 0x7) + 3 + val start = output.size - distance + if (distance > 0 && start >= 0) { + repeat(length) { index -> + output.add(output[start + index]) + } + } + } + else -> { + output.add(' '.code.toByte()) + output.add((c xor 0x80).toByte()) + } + } + } + return output.toByteArray() + } + + private fun parseEpubManifest(opf: String): Map { + return Regex("]*>").findAll(opf).mapNotNull { match -> + val item = match.value + val id = item.attr("id") + val href = item.attr("href") + if (id.isBlank() || href.isBlank()) null else id to href + }.toMap() + } + + private fun parseEpubTableOfContents( + zip: ZipFile, + opf: String, + manifest: Map, + basePath: String + ): List { + val manifestNcxHref = resolveEpubNcxHref(opf, manifest) + val ncxPath = manifestNcxHref + ?.let { normalizeZipPath(basePath + it) } + ?: zip.entries().asSequence() + .map { it.name } + .firstOrNull { it.endsWith(".ncx", ignoreCase = true) } + ?: return emptyList() + val document = zip.readBytesOrNull(ncxPath)?.let(::xmlDocument) ?: return emptyList() + val navMap = document.allElementsByLocalTag("navmap").firstOrNull() ?: return emptyList() + val ncxBasePath = ncxPath.substringBeforeLast('/', missingDelimiterValue = "") + val entries = mutableListOf() + + fun visit(parent: Element, depth: Int) { + parent.childrenByLocalTag("navpoint").forEach { navPoint -> + val label = navPoint.childrenByLocalTag("navlabel") + .firstOrNull() + ?.allElementsByLocalTag("text") + ?.firstOrNull() + ?.text() + ?.normalizeReaderWhitespace() + .takeUnlessBlank() + ?: "Section ${entries.size + 1}" + val src = navPoint.childrenByLocalTag("content") + .firstOrNull() + ?.xmlAttr("src") + .orEmpty() + .trim() + val href = src.substringBefore('#').substringBefore('?').percentDecodedOrSelf() + val fragmentId = src.substringAfter('#', missingDelimiterValue = "") + .substringBefore('?') + .takeUnlessBlank() + ?.percentDecodedOrSelf() + if (href.isNotBlank()) { + val absoluteHref = normalizeZipPath( + if (ncxBasePath.isBlank()) href else "$ncxBasePath/$href" + ) + entries += SharedEpubTocEntry( + label = label, + href = absoluteHref, + fragmentId = fragmentId, + depth = depth.coerceAtLeast(0) + ) + } + visit(navPoint, depth + 1) + } + } + + visit(navMap, depth = 0) + return entries + } + + private fun resolveEpubNcxHref(opf: String, manifest: Map): String? { + Regex("<(?:[^:>]+:)?spine\\b[^>]*>", RegexOption.IGNORE_CASE) + .find(opf) + ?.value + ?.attr("toc") + ?.takeIf { it.isNotBlank() } + ?.let { tocId -> manifest[tocId] } + ?.let { return it } + + return manifest.values.firstOrNull { it.endsWith(".ncx", ignoreCase = true) } + } + + private fun loadEpubCss(zip: ZipFile, manifest: Map, basePath: String): Map { + return manifest.values + .filter { it.endsWith(".css", ignoreCase = true) } + .mapNotNull { href -> + val path = normalizeZipPath(basePath + href) + val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty() + if (css.isBlank()) null else path to css + } + .toMap() + } + + private fun parseCssRules(cssByPath: Map): OptimizedCssRules { + val constraints = Constraints(maxWidth = 980, maxHeight = 720) + val baseRules = CssParser.parse( + cssContent = UserAgentStylesheet.default, + cssPath = null, + baseFontSizeSp = 18f, + density = 1f, + constraints = constraints, + isDarkTheme = false, + adaptThemeColors = false + ).rules + + return cssByPath.entries.fold(baseRules) { rules, (path, css) -> + if (css.isBlank()) { + rules + } else { + rules.merge( + CssParser.parse( + cssContent = css, + cssPath = path, + baseFontSizeSp = 18f, + density = 1f, + constraints = constraints, + isDarkTheme = false, + adaptThemeColors = false + ).rules + ) + } + } + } + + private fun xmlDocument(bytes: ByteArray): Element { + return ByteArrayInputStream(bytes).use { input -> + Jsoup.parse(input, null, "", Parser.xmlParser()) + } + } + + private fun Element.xmlTag(): String { + return tagName().substringAfter(':').lowercase() + } + + private fun Element.xmlAttr(name: String): String? { + val expectedLocal = name.substringAfter(':') + for (attribute in attributes().asList()) { + val key = attribute.key + if (key.equals(name, ignoreCase = true) || + key.substringAfter(':').equals(expectedLocal, ignoreCase = true) + ) { + return attribute.value.takeUnlessBlank() + } + } + return null + } + + private fun Element.allElementsByLocalTag(tag: String): List { + return getAllElements().filter { it.xmlTag() == tag } + } + + private fun Element.childrenByLocalTag(tag: String): List { + return children().filter { it.xmlTag() == tag } + } + + private fun ZipFile.readText(path: String): String { + val entry = getEntry(path) ?: error("Missing EPUB entry: $path") + return getInputStream(entry).bufferedReader().use { it.readText() } + } + + private fun ZipFile.readTextOrNull(path: String): String? { + val entry = getEntry(path) ?: return null + return getInputStream(entry).bufferedReader().use { it.readText() } + } + + private fun 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=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty() + } + + private fun String.tagText(tag: String): String { + return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)]+:)?$tag>", RegexOption.IGNORE_CASE) + .find(this) + ?.groupValues + ?.get(1) + ?.htmlToText() + .orEmpty() + } + + private fun normalizeZipPath(path: String): String { + val parts = ArrayDeque() + path.split('/').forEach { part -> + when (part) { + "", "." -> Unit + ".." -> if (parts.isNotEmpty()) parts.removeLast() + else -> parts.addLast(part) + } + } + return parts.joinToString("/") + } + + private fun String.percentDecodedOrSelf(): String { + return runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this) + } + + private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String { + return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match -> + val attr = match.groupValues[1] + val raw = match.groupValues[2] + if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) { + return@replace match.value + } + val dataUri = zip.toDataUri(raw, chapterPath) + if (dataUri != null) "$attr=\"$dataUri\"" else match.value + } + } + + private fun String.looksLikeEmbeddableResource(): Boolean { + return substringBefore('#') + .substringBefore('?') + .substringAfterLast('.', "") + .lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2") + } + + private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String { + return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match -> + val raw = match.groupValues[2].trim() + if (raw.isFontResourceReference()) return@replace match.value + val dataUri = zip.toDataUri(raw, cssPath) + if (dataUri != null) "url('$dataUri')" else match.value + } + } + + private fun String.isFontResourceReference(): Boolean { + return substringBefore('#') + .substringBefore('?') + .substringAfterLast('.', "") + .lowercase() in setOf("ttf", "otf", "woff", "woff2") + } + + private fun String.hasVisualHtmlContent(): Boolean { + return contains(Regex("""<\s*(img|svg|math|video|audio|object|canvas)\b""", RegexOption.IGNORE_CASE)) + } + + private fun ZipFile.toZipResourcePath(rawRef: String, ownerPath: String): String? { + val ref = rawRef.substringBefore('#').substringBefore('?').trim() + if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null + if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null + val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "") + val decodedRef = ref.percentDecodedOrSelf() + return normalizeZipPath(if (base.isBlank()) decodedRef else "$base/$decodedRef") + } + + private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? { + val path = toZipResourcePath(rawRef, ownerPath) ?: return null + val entry = getEntry(path) ?: return null + val bytes = getInputStream(entry).use { it.readBytes() } + return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}" + } + + private fun mimeType(path: String): String { + return when (path.substringAfterLast('.', "").lowercase()) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "svg" -> "image/svg+xml" + "webp" -> "image/webp" + "ttf" -> "font/ttf" + "otf" -> "font/otf" + "woff" -> "font/woff" + "woff2" -> "font/woff2" + "css" -> "text/css" + "js" -> "text/javascript" + else -> "application/octet-stream" + } + } + + private fun File.readTextLenient(): String { + val bytes = readBytes() + return bytes.toString(Charsets.UTF_8).takeIf { '\uFFFD' !in it } + ?: bytes.toString(Charset.forName("windows-1252")) + } + + private fun String.extractBodyOrSelf(): String { + return Regex("(?is)]*>(.*?)") + .find(this) + ?.groupValues + ?.get(1) + ?.trim() + ?: this + } + + private fun String.htmlToText(): String { + return Jsoup.parse(this).text().normalizeReaderWhitespace() + } + + private fun String.fastHtmlToText(): String { + return Parser.unescapeEntities( + extractBodyOrSelf() + .replace(Regex("(?is)"), " ") + .replace(Regex("(?is)"), " ") + .replace(Regex("(?i)<\\s*br\\s*/?\\s*>"), "\n") + .replace(Regex("(?i)"), "\n") + .replace(Regex("(?is)<[^>]+>"), " "), + false + ).normalizeReaderWhitespace() + } + + private fun String.sanitizeReaderHtml(): String { + return replace(Regex("(?is)"), "") + .replace(Regex("(?is)"), "") + .replace(Regex("(?is)]*>"), "") + .replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "") + } + + private fun String.escapeHtml(): String { + return replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + + private fun String.escapeHtmlAttribute(): String { + return escapeHtml() + } + + private fun String.normalizeReaderWhitespace(): String { + return replace('\u0000', ' ') + .replace(Regex("[ \\t\\x0B\\f\\r]+"), " ") + .replace(Regex(" *\\n *"), "\n") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() + } + + private fun String?.takeUnlessBlank(): String? { + return this?.trim()?.takeIf { it.isNotBlank() } + } + + private fun SharedEpubBook.withOverrides(titleOverride: String?, authorOverride: String?): SharedEpubBook { + return copy( + title = titleOverride.takeUnlessBlank() ?: title, + author = authorOverride.takeUnlessBlank() ?: author + ) + } + + private object SharedJvmHtmlResourceResolver : HtmlResourceResolver { + override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? { + val raw = src.trim().takeIf { it.isNotBlank() } ?: return null + if (raw.startsWith("data:", ignoreCase = true)) return raw + if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) return raw + if (raw.startsWith("file:", ignoreCase = true)) return null + + val clean = raw.substringBefore('#').substringBefore('?').takeIf { it.isNotBlank() } ?: return null + val decoded = runCatching { URLDecoder.decode(clean, Charsets.UTF_8.name()) }.getOrDefault(clean) + val chapterFile = chapterAbsPath.trim().takeIf { it.isNotBlank() }?.let { path -> + val file = File(path) + if (file.isAbsolute) file else null + } + val extractionRoot = extractionBasePath + .trim() + .takeIf { it.isNotBlank() } + ?.let { runCatching { File(it).canonicalFile }.getOrNull() } + ?: chapterFile + ?.parentFile + ?.let { runCatching { it.canonicalFile }.getOrNull() } + ?: return null + + val resolvedChapterFile = chapterFile ?: File(extractionRoot, chapterAbsPath) + val chapterRelative = resolvedChapterFile.parentFile?.let { File(it, decoded) } + fileInsideRootOrNull(extractionRoot, chapterRelative)?.let { return it.absolutePath } + + fileInsideRootOrNull(extractionRoot, File(extractionRoot, decoded))?.let { return it.absolutePath } + + return null + } + + override fun readText(path: String): String? { + dataUriBytes(path)?.let { bytes -> return bytes.toString(Charsets.UTF_8) } + return path.toFileOrNull()?.takeIf { it.isFile }?.readText() + } + + override fun imageDimensions(path: String): Pair? { + val image = runCatching { + dataUriBytes(path)?.let { bytes -> + ImageIO.read(ByteArrayInputStream(bytes)) + } ?: path.toFileOrNull()?.takeIf { it.isFile }?.let { file -> ImageIO.read(file) } + }.getOrNull() ?: return null + return image.width.toFloat() to image.height.toFloat() + } + + private fun dataUriBytes(value: String): ByteArray? { + if (!value.startsWith("data:", ignoreCase = true)) return null + val commaIndex = value.indexOf(',') + if (commaIndex < 0) return null + val metadata = value.substring(0, commaIndex) + val payload = value.substring(commaIndex + 1) + return if (";base64" in metadata.lowercase()) { + runCatching { Base64.getDecoder().decode(payload) }.getOrNull() + } else { + runCatching { URLDecoder.decode(payload, Charsets.UTF_8.name()).toByteArray(Charsets.UTF_8) }.getOrNull() + } + } + + private fun String.toFileOrNull(): File? { + return when { + startsWith("file:", ignoreCase = true) -> runCatching { File(URI(this)) }.getOrNull() + else -> File(this) + } + } + + private fun fileInsideRootOrNull(root: File, candidate: File?): File? { + val file = candidate ?: return null + val canonical = runCatching { file.canonicalFile }.getOrNull() ?: return null + val rootPath = root.path + val targetPath = canonical.path + val insideRoot = targetPath == rootPath || targetPath.startsWith(rootPath + File.separator) + return canonical.takeIf { insideRoot && it.isFile } + } + } + + private fun List.semanticFallbackText(): String { + return flatMap { it.semanticTextParts() } + .filter { it.isNotBlank() } + .joinToString("\n\n") + } + + private fun SemanticBlock.semanticTextParts(): List { + return when (this) { + is SemanticTextBlock -> listOf(text) + is SemanticList -> items.flatMap { it.semanticTextParts() } + is SemanticTable -> rows.flatMap { row -> row.flatMap { cell -> cell.content.flatMap { it.semanticTextParts() } } } + is SemanticFlexContainer -> children.flatMap { it.semanticTextParts() } + is SemanticWrappingBlock -> floatedImage.semanticTextParts() + paragraphsToWrap.flatMap { it.semanticTextParts() } + is SemanticImage -> listOf(altText.orEmpty()) + is SemanticMath -> listOf(altText.orEmpty()) + is SemanticSpacer -> emptyList() + } + } + + private fun ByteArray.u16(offset: Int): Int { + if (offset + 2 > size) return 0 + return ((this[offset].toInt() and 0xFF) shl 8) or (this[offset + 1].toInt() and 0xFF) + } + + private fun ByteArray.u32(offset: Int): Long { + if (offset + 4 > size) return 0 + return ((this[offset].toLong() and 0xFF) shl 24) or + ((this[offset + 1].toLong() and 0xFF) shl 16) or + ((this[offset + 2].toLong() and 0xFF) shl 8) or + (this[offset + 3].toLong() and 0xFF) + } + + private fun ByteArray.asciiAt(offset: Int, length: Int): String { + if (offset < 0 || offset + length > size) return "" + return copyOfRange(offset, offset + length).toString(Charsets.US_ASCII) + } + + private fun ByteArray.safeString(offset: Int, length: Int, charset: Charset): String? { + if (offset < 0 || length <= 0 || offset + length > size) return null + return copyOfRange(offset, offset + length).toString(charset) + .trim('\u0000', ' ', '\n', '\r', '\t') + .takeUnlessBlank() + } + + private fun decodeMobiText(bytes: ByteArray, preferred: Charset): String { + val primary = bytes.toString(preferred) + if ('\uFFFD' !in primary) return primary.trim('\u0000') + return bytes.toString(Charset.forName("windows-1252")).trim('\u0000') + } + + private fun List.flattenBytes(): ByteArray { + val total = sumOf { it.size } + val result = ByteArray(total) + var offset = 0 + forEach { bytes -> + bytes.copyInto(result, offset) + offset += bytes.size + } + return result + } + + private data class ParsedChapter( + val title: String, + val html: String, + val plainText: String + ) + + private data class ParsedDocument( + val title: String?, + val author: String? = null, + val chapters: List = emptyList(), + val plainText: String = chapters.joinToString("\n\n") { it.plainText } + ) + + private data class ParsedMetadata( + val title: String? = null, + val author: String? = null + ) + + private data class ParsedMobi( + val title: String?, + val author: String?, + val html: String, + val text: String, + val chapters: List = emptyList() + ) + + private data class MobiHeaderInfo( + val encoding: Int? = null, + val imageIndex: Int? = null, + val huffRecordIndex: Int? = null, + val huffRecordCount: Int? = null, + val extraFlags: Int = 0 + ) + + private data class MobiHuffCdic( + val indexCount: Int, + val codeLength: Int, + val table1: IntArray, + val mincodeTable: LongArray, + val maxcodeTable: LongArray, + val symbolOffsets: IntArray, + val symbols: List + ) + + private data class MobiBackwardVarlen( + val size: Int, + val byteCount: Int, + val nextPosition: Int + ) + + private const val MOBI_COMPRESSION_NONE = 1 + private const val MOBI_COMPRESSION_PALMDOC = 2 + private const val MOBI_COMPRESSION_HUFFCDIC = 17480 + private const val MOBI_NOT_SET = -1 + private const val HUFF_HEADER_LENGTH = 24 + private const val HUFF_RECORD_MIN_SIZE = 2584 + private const val HUFF_CODETABLE_SIZE = 33 + private const val HUFF_CODELEN_MAX = 16 + private const val CDIC_HEADER_LENGTH = 16 + private const val MOBI_HUFFMAN_MAX_DEPTH = 20 + private const val UINT32_MAX = 0xFFFF_FFFFL +} diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmLruMemoryCache.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedJvmUserDirectories.kt diff --git a/app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt b/shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt similarity index 100% rename from app/src/main/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt rename to shared/src/readerJvmMain/kotlin/com/dueattendant149/bookreader/reader/shared/reader/SharedMeasuredEpubPaginator.kt