Update v1.0.49 (#330)

* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

* Added image gallery to reader sidebar and refactored desktop PDF UI components

* Implemented EPUB image gallery

* Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic

* Standardized UI styling and refactored app navigation layout

* Implemented auto-hiding reader chrome and activity tracking in desktop

* Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support.

* Added PPTX support for desktop and refactored parsing into a shared module

* Implement paid AI features and account management for the desktop application.

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

* Implement support for password-protected PDFs and file actions in the desktop reader.

* Implement cloud synchronization for desktop using Firestore and Google Drive

* Implement PDF reflow and "Text View" for the desktop reader

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

* Improve PDF spread navigation with fling support and configurable page gaps

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

* Implement deep-linking and playback controls for TTS media sessions

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

* Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml

* Implement localized banner messages and UI strings using resource-backed SharedText

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

* Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

* Flatten highlight comments into a single thread for PDF export and allow author editing

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

* Implement dynamic, theme-aware coloring for the reader slider

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

* Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
This commit is contained in:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

@ -0,0 +1,70 @@
package com.aryan.reader.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.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
internal data class DesktopAccountProfile(
val isProUser: Boolean = false,
val credits: Int = 0
)
internal class DesktopAccountProfileRepository(
private val config: DesktopCloudConfig
) {
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()
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()
DesktopAccountProfile(
isProUser = fields?.booleanField("isPro") == true,
credits = fields?.numberField("credits")?.toInt() ?: 0
)
} finally {
connection.disconnect()
}
}
}
private val DesktopAccountJson = Json { ignoreUnknownKeys = true }
private fun JsonObject?.booleanField(key: String): Boolean? {
return this?.get(key)
?.jsonObjectOrNull()
?.get("booleanValue")
?.jsonPrimitive
?.contentOrNull
?.toBooleanStrictOrNull()
}
private fun JsonObject?.numberField(key: String): Double? {
val field = this?.get(key)?.jsonObjectOrNull() ?: return null
return field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
?: field["doubleValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
}
private fun JsonElement?.jsonObjectOrNull(): JsonObject? = this as? JsonObject
private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())

View file

@ -0,0 +1,513 @@
package com.aryan.reader.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.material.icons.filled.Settings
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
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 com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderCloudTtsState
import com.aryan.reader.shared.ReaderCloudTtsVoices
import com.aryan.reader.shared.ReaderTtsCacheSummary
import com.aryan.reader.shared.RecapResult
import com.aryan.reader.shared.SummarizationResult
import com.aryan.reader.shared.readerCloudTtsVoiceById
import com.aryan.reader.shared.ui.SharedMarkdownText
import com.aryan.reader.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)
}
}
}
@Composable
internal fun DesktopCloudTtsChromeControls(
settings: ReaderAiByokSettings,
cloudTts: ReaderCloudTtsState,
credits: Int,
showCredits: Boolean,
onRead: () -> Unit,
onPauseResume: () -> Unit,
onStop: () -> Unit,
onOpenSettings: () -> Unit
) {
val sanitized = settings.sanitized()
val voice = readerCloudTtsVoiceById(sanitized.ttsSpeakerId)
val ttsBusy = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(8.dp),
tonalElevation = 1.dp
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Column(modifier = Modifier.weight(1f)) {
Text(
when {
cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio")
cloudTts.isPaused -> readerString("desktop_paused", "Paused")
cloudTts.isPlaying -> readerString("label_reading", "Reading")
sanitized.isCloudTtsAvailable -> readerString("desktop_cloud_tts_ready", "Cloud TTS ready")
else -> readerString("desktop_cloud_tts_unavailable", "Cloud TTS unavailable")
},
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Text(
cloudTts.errorMessage
?: cloudTts.progress.currentPositionLabel
?: cloudTts.statusMessage
?: voice?.let { "${it.name}: ${it.description}" }
?: "",
style = MaterialTheme.typography.labelSmall,
color = if (cloudTts.errorMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (showCredits) {
AssistChip(onClick = {}, label = { Text(readerString("credits_count", "%1\$d credits", credits)) })
}
if (cloudTts.isPlaying || cloudTts.isPaused) {
TextButton(onClick = onPauseResume) {
Text(if (cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause"))
}
}
TextButton(
enabled = sanitized.isCloudTtsAvailable || ttsBusy,
onClick = { if (ttsBusy) onStop() else onRead() }
) {
Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read"))
}
IconButton(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = readerString("desktop_cloud_tts_settings", "Cloud TTS settings"))
}
}
}
}
@Composable
internal fun DesktopCloudTtsSettingsOverlay(
settings: ReaderAiByokSettings,
isTtsActive: Boolean,
showCredits: Boolean,
credits: Int,
cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary(),
onClearCache: (() -> Unit)? = null,
onSettingsChange: (ReaderAiByokSettings) -> Unit
) {
val sanitized = settings.sanitized()
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(8.dp),
tonalElevation = 4.dp,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
if (isTtsActive) {
readerString("desktop_stop_reading_change_voices", "Stop reading to change voices.")
} else {
readerString("desktop_choose_cloud_tts_voice", "Choose the Gemini voice used for cloud read aloud.")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (showCredits) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(10.dp)
) {
Text(
readerString("credits_count", "%1\$d credits", credits),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
ReaderCloudTtsVoices.forEach { voice ->
FilterChip(
selected = sanitized.ttsSpeakerId == voice.id,
enabled = !isTtsActive,
onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = 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
)
}
}
)
}
}
if (cacheSummary.hasCachedAudio) {
HorizontalDivider()
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(readerString("desktop_voice_cache", "Voice cache"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
cacheSummary.currentVoiceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (cacheSummary.hasCurrentVoiceCachedAudio && onClearCache != null) {
TextButton(enabled = !isTtsActive, onClick = onClearCache) {
Text(readerString("desktop_clear_voice_cache", "Clear voice cache"))
}
}
}
}
}
}
}

View file

@ -40,6 +40,7 @@ import com.aryan.reader.shared.AppContrastOption
import com.aryan.reader.shared.AppThemeMode
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.ui.SharedAppTheme
import com.aryan.reader.shared.ui.readerString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@ -172,7 +173,7 @@ private fun EpistemeDesktopStartupScreen(window: Component?) {
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "Opening your library",
text = readerString("desktop_opening_your_library", "Opening your library"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -499,10 +500,10 @@ internal fun DesktopWebViewRuntimeIndicator(
modifier: Modifier = Modifier
) {
val message = when {
state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}"
state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup."
state.downloadProgress >= 0f -> "Preparing bundled embedded webview ${state.downloadProgress.toInt()}%"
else -> "Preparing embedded webview..."
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(

View file

@ -0,0 +1,126 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.SharedLibraryProjectionInput
import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.SharedLibraryStateProjector
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter
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 {
return if (appSeedColor == null) {
copy(appSeedColor = DesktopDefaultAppSeedColor)
} else {
this
}
}
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,
readerDefaultSettings = readerDefaultSettings,
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
readerToolbarPreferences = readerToolbarPreferences,
readerHighlightPalette = readerHighlightPalette,
pdfHighlighterPalette = pdfHighlighterPalette,
readerTtsReplacementPreferences = readerTtsReplacementPreferences
)
}
internal fun SharedLibraryStateProjector.projectDesktopLibraryState(
state: SharedReaderScreenState,
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>
): SharedReaderScreenState {
val allBooks = state.rawLibraryBooks
val visibleBooks = allBooks.filterNot { isDesktopPdfReflowBookId(it.id) }
val projected = project(
SharedLibraryProjectionInput(
state = state,
booksFromStore = visibleBooks,
shelfRecords = shelfRecords,
shelfRefs = shelfRefs,
tags = state.allTags.ifEmpty { visibleBooks.collectTags() }
)
)
val booksById = allBooks.associateBy { it.id }
val openTabs = state.openTabIds.mapNotNull { booksById[it] }
val openTabIds = openTabs.map { it.id }
return projected.copy(
rawLibraryBooks = allBooks,
openTabs = openTabs,
openTabIds = openTabIds,
activeTabBookId = state.activeTabBookId?.takeIf { it in openTabIds }
)
}
internal fun SharedReaderScreenState.toDesktopLibrarySnapshot(
shelfRecords: List<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
customFonts: List<CustomFontItem>
): SharedLibrarySnapshot {
return SharedLibrarySnapshot(
books = rawLibraryBooks,
shelfRecords = shelfRecords,
shelfRefs = shelfRefs,
tags = allTags,
customFonts = customFonts,
syncedFolders = syncedFolders,
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
useStrictFileFilter = useStrictFileFilter,
appThemeMode = appThemeMode,
appContrastOption = appContrastOption,
appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor,
appFontPreference = appFontPreference,
customAppThemes = customAppThemes,
readerDefaultSettings = readerDefaultSettings,
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
readerToolbarPreferences = readerToolbarPreferences,
readerHighlightPalette = readerHighlightPalette,
pdfHighlighterPalette = pdfHighlighterPalette,
readerTtsReplacementPreferences = readerTtsReplacementPreferences
)
}

View file

@ -16,6 +16,11 @@ internal data class DesktopPreparedImport(
internal class DesktopBookImporter(
private val booksDirectory: File = File(desktopUserDataRoot(), "books")
) {
fun createBookFile(fileName: String): File {
booksDirectory.mkdirs()
return File(booksDirectory, fileName)
}
fun prepareImports(files: List<ImportedBookFile>): DesktopPreparedImport {
val preparedFiles = mutableListOf<ImportedBookFile>()
var failedCount = 0

View file

@ -19,6 +19,7 @@ internal data class DesktopBuildProfile(
val featurePolicy: SharedFeaturePolicy
) {
val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline
val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess
}
internal fun currentDesktopBuildProfile(): DesktopBuildProfile {
@ -57,7 +58,7 @@ private fun normalizedDesktopFlavor(rawFlavor: String?): String {
internal fun ReaderAiByokSettings.withDesktopFeaturePolicy(
featurePolicy: SharedFeaturePolicy
): ReaderAiByokSettings {
return if (featurePolicy.aiAndCloud) {
return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) {
sanitized()
} else {
ReaderAiByokSettings(hideReaderAiFeatures = true)

View file

@ -38,11 +38,29 @@ class DesktopByokAiAdapter(
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)
@ -51,7 +69,8 @@ class DesktopByokAiAdapter(
suspend fun callTextAi(
feature: ReaderAiFeature,
text: String,
context: String? = null
context: String? = null,
onUpdate: (String) -> Unit = {}
): Result<String> = withContext(Dispatchers.IO) {
if (!networkAccess()) return@withContext Result.failure(IllegalStateException("AI features are unavailable in this desktop build."))
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
@ -64,12 +83,12 @@ class DesktopByokAiAdapter(
Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models."))
}
is ReaderByokTextRequestResult.Ready -> runCatching {
requestResult.request.execute()
requestResult.request.execute(onUpdate)
}
}
}
private fun ReaderByokTextRequest.execute(): String {
private fun ReaderByokTextRequest.execute(onUpdate: (String) -> Unit): String {
var connection: HttpURLConnection? = null
try {
val url = if (model.provider == "groq") {
@ -101,9 +120,9 @@ class DesktopByokAiAdapter(
throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
}
val text = if (model.provider == "groq") {
streamGroqResponse(connection)
streamGroqResponse(connection, onUpdate)
} else {
streamGeminiResponse(connection)
streamGeminiResponse(connection, onUpdate)
}.trim()
if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.")
return text
@ -175,7 +194,7 @@ class DesktopByokAiAdapter(
}.toString()
}
private fun streamGeminiResponse(connection: HttpURLConnection): String {
private fun streamGeminiResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String {
val output = StringBuilder()
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var buffer = ""
@ -206,7 +225,11 @@ class DesktopByokAiAdapter(
val jsonObject = buffer.substring(start, end + 1)
buffer = buffer.substring(end + 1)
val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull()
output.append(parsed.geminiTextChunk())
val chunk = parsed.geminiTextChunk()
if (chunk.isNotEmpty()) {
output.append(chunk)
onUpdate(chunk)
}
if (parsed.geminiFinishReason() == "SAFETY") {
throw IllegalStateException("Blocked for safety reasons.")
}
@ -216,7 +239,7 @@ class DesktopByokAiAdapter(
return output.toString()
}
private fun streamGroqResponse(connection: HttpURLConnection): String {
private fun streamGroqResponse(connection: HttpURLConnection, onUpdate: (String) -> Unit): String {
val output = StringBuilder()
var inThink = false
var thinkBuffer = ""
@ -268,10 +291,17 @@ class DesktopByokAiAdapter(
?.jsonPrimitive
?.contentOrNull
}.getOrNull().orEmpty()
output.append(cleanChunk(chunk))
val cleaned = cleanChunk(chunk)
if (cleaned.isNotEmpty()) {
output.append(cleaned)
onUpdate(cleaned)
}
}
}
if (!inThink && thinkBuffer.isNotBlank()) output.append(thinkBuffer)
if (!inThink && thinkBuffer.isNotBlank()) {
output.append(thinkBuffer)
onUpdate(thinkBuffer)
}
return output.toString()
}
}

View file

@ -0,0 +1,69 @@
package com.aryan.reader.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 {
val resourceProperties = 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) }
}
val localProperties = Properties().apply {
File("local.properties")
.takeIf { it.isFile }
?.inputStream()
?.use { input -> load(input) }
}
fun value(vararg keys: String): String {
return keys.firstNotNullOfOrNull { key ->
System.getProperty("episteme.desktop.$key")
?: System.getenv("EPISTEME_DESKTOP_${key.uppercase()}")
?: System.getenv(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").ifBlank { aiWorkerUrl }
return DesktopCloudConfig(
aiWorkerUrl = aiWorkerUrl,
ttsWorkerUrl = ttsWorkerUrl,
firebaseWebApiKey = value("FIREBASE_WEB_API_KEY", "GOOGLE_API_KEY"),
firebaseProjectId = value("FIREBASE_PROJECT_ID").ifBlank { "reader-9fc469d7" },
googleOAuthClientId = value("GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_WEB_CLIENT_ID", "DEFAULT_WEB_CLIENT_ID"),
googleOAuthClientSecret = value("GOOGLE_OAUTH_CLIENT_SECRET", "GOOGLE_WEB_CLIENT_SECRET", "DEFAULT_WEB_CLIENT_SECRET")
)
}

View file

@ -0,0 +1,659 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.FileType
import com.aryan.reader.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.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 bookmarksJson: String? = null,
val hasAnnotations: Boolean = false,
val fileContentModifiedTimestamp: Long = 0L,
val customName: String? = null,
val highlightsJson: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
val originalTitle: String? = null,
val originalAuthor: String? = null,
val originalSeriesName: String? = null,
val originalSeriesIndex: Double? = null,
val originalDescription: String? = null
)
internal data class DesktopCloudShelfMetadata(
val name: String = "",
val bookIds: List<String> = emptyList(),
val lastModifiedTimestamp: Long = 0L,
val isDeleted: Boolean = false
)
internal data class DesktopCloudFontMetadata(
val id: String = "",
val displayName: String = "",
val fileName: String = "",
val fileExtension: String = "",
val timestamp: Long = 0L,
val isDeleted: Boolean = false
)
internal data class DesktopDriveFile(
val id: String,
val name: String
)
internal class DesktopFirestoreRepository(
private val config: DesktopCloudConfig,
private val client: HttpClient = defaultDesktopCloudHttpClient()
) {
suspend fun getAllBooks(userId: String, idToken: String): List<DesktopCloudBookMetadata> =
firestoreCollection(userId, "books", idToken).mapNotNull { document ->
document.fields?.toBookMetadata(document.id)
}
suspend fun getBookMetadata(userId: String, bookId: String, idToken: String): DesktopCloudBookMetadata? =
firestoreDocument(userId, "books", bookId, idToken)?.let { document ->
document.fields?.toBookMetadata(document.id)
}
suspend fun syncBookMetadata(
userId: String,
book: DesktopCloudBookMetadata,
originDeviceId: String,
idToken: String
) {
val fields = book.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId))
writeFirestoreDocument(userId, "books", book.bookId, fields, idToken)
}
suspend fun getAllShelves(userId: String, idToken: String): List<DesktopCloudShelfMetadata> =
firestoreCollection(userId, "shelves", idToken).mapNotNull { document ->
document.fields?.toShelfMetadata(document.id)
}
suspend fun syncShelf(
userId: String,
shelf: DesktopCloudShelfMetadata,
originDeviceId: String,
idToken: String
) {
val fields = shelf.toFirestoreFields() + ("originDeviceId" to firestoreString(originDeviceId))
writeFirestoreDocument(userId, "shelves", shelf.name, fields, idToken)
}
suspend fun getAllFonts(userId: String, idToken: String): List<DesktopCloudFontMetadata> =
firestoreCollection(userId, "fonts", idToken).mapNotNull { document ->
document.fields?.toFontMetadata(document.id)
}
suspend fun syncFontMetadata(userId: String, font: DesktopCloudFontMetadata, idToken: String) {
writeFirestoreDocument(userId, "fonts", font.id, font.toFirestoreFields(), idToken)
}
suspend fun deleteFontMetadata(userId: String, fontId: String, idToken: String) {
deleteFirestoreDocument(userId, "fonts", fontId, idToken)
}
suspend fun deleteAllUserFirestoreData(userId: String, idToken: String) {
listOf("books", "shelves", "fonts").forEach { collection ->
firestoreCollection(userId, collection, idToken).forEach { document ->
deleteFirestoreDocument(userId, collection, document.id, idToken)
}
}
}
private suspend fun firestoreCollection(
userId: String,
collection: String,
idToken: String
): List<DesktopFirestoreDocument> = withContext(Dispatchers.IO) {
val response = sendFirestore(
request = HttpRequest.newBuilder(firestoreCollectionUri(userId, collection))
.GET()
.build(),
idToken = idToken,
allowNotFound = true
) ?: return@withContext emptyList()
val root = DesktopCloudJson.parseToJsonElement(response).jsonObject
root["documents"]?.jsonArrayOrNull().orEmpty().mapNotNull { element ->
val document = element.jsonObjectOrNull() ?: return@mapNotNull null
DesktopFirestoreDocument(
id = document.string("name")?.substringAfterLast('/').orEmpty(),
fields = document["fields"]?.jsonObjectOrNull()
)
}
}
private suspend fun firestoreDocument(
userId: String,
collection: String,
documentId: String,
idToken: String
): DesktopFirestoreDocument? = withContext(Dispatchers.IO) {
val response = sendFirestore(
request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
.GET()
.build(),
idToken = idToken,
allowNotFound = true
) ?: return@withContext null
val document = DesktopCloudJson.parseToJsonElement(response).jsonObject
DesktopFirestoreDocument(
id = document.string("name")?.substringAfterLast('/').orEmpty(),
fields = document["fields"]?.jsonObjectOrNull()
)
}
private suspend fun writeFirestoreDocument(
userId: String,
collection: String,
documentId: String,
fields: Map<String, JsonElement>,
idToken: String
) = withContext(Dispatchers.IO) {
val body = JsonObject(mapOf("fields" to JsonObject(fields)))
sendFirestore(
request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
.header("Content-Type", "application/json; charset=UTF-8")
.method("PATCH", HttpRequest.BodyPublishers.ofString(DesktopCloudJson.encodeToString(JsonElement.serializer(), body)))
.build(),
idToken = idToken
)
Unit
}
private suspend fun deleteFirestoreDocument(
userId: String,
collection: String,
documentId: String,
idToken: String
) = withContext(Dispatchers.IO) {
sendFirestore(
request = HttpRequest.newBuilder(firestoreDocumentUri(userId, collection, documentId))
.DELETE()
.build(),
idToken = idToken,
allowNotFound = true
)
Unit
}
private fun firestoreCollectionUri(userId: String, collection: String): URI {
return URI.create("${firestoreBaseUrl()}/users/${pathEncode(userId)}/$collection")
}
private fun firestoreDocumentUri(userId: String, collection: String, documentId: String): URI {
return URI.create("${firestoreCollectionUri(userId, collection)}/${pathEncode(documentId)}")
}
private fun firestoreBaseUrl(): String {
return "https://firestore.googleapis.com/v1/projects/${pathEncode(config.firebaseProjectId)}/databases/(default)/documents"
}
private fun sendFirestore(
request: HttpRequest,
idToken: String,
allowNotFound: Boolean = false
): String? {
val authed = HttpRequest.newBuilder(request.uri())
.timeout(Duration.ofSeconds(30))
.copyMethodAndBodyFrom(request)
.header("Authorization", "Bearer $idToken")
.header("Accept", "application/json")
.apply {
request.headers().map().forEach { (name, values) ->
values.forEach { value -> header(name, value) }
}
}
.build()
val response = client.send(authed, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
if (allowNotFound && response.statusCode() == 404) return null
if (response.statusCode() !in 200..299) {
throw IllegalStateException("Firestore HTTP ${response.statusCode()}: ${response.body().take(240)}")
}
return response.body()
}
}
internal class DesktopGoogleDriveRepository(
private val client: HttpClient = defaultDesktopCloudHttpClient()
) {
suspend fun getFiles(accessToken: String): List<DesktopDriveFile> = withContext(Dispatchers.IO) {
listFiles(accessToken = accessToken, query = null)
}
suspend fun 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 = "annotation_$bookId.json",
file = file,
contentType = "application/json"
)
}
suspend fun downloadAnnotationFile(accessToken: String, bookId: String, destination: File): Boolean {
val fileId = listFiles(accessToken, "name = '${driveQueryStringValue("annotation_$bookId.json")}' and trashed = false")
.firstOrNull()
?.id
?: return false
return downloadFile(accessToken, fileId, destination)
}
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")}")
} else {
URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name")}")
}
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()
)
}
private fun listFiles(accessToken: String, query: String?): List<DesktopDriveFile> {
val params = buildList {
add("spaces" to "appDataFolder")
add("fields" to "files(id,name)")
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)
}
}
}
private data class DesktopFirestoreDocument(
val id: String,
val fields: JsonObject?
)
private val DesktopCloudJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
prettyPrint = true
}
private fun defaultDesktopCloudHttpClient(): HttpClient {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()
}
private fun DesktopCloudBookMetadata.toFirestoreFields(): Map<String, JsonElement> = mapOf(
"bookId" to firestoreString(bookId),
"title" to firestoreNullableString(title),
"author" to firestoreNullableString(author),
"displayName" to firestoreString(displayName),
"type" to firestoreString(type),
"lastPositionCfi" to firestoreNullableString(lastPositionCfi),
"lastChapterIndex" to firestoreNullableInt(lastChapterIndex),
"locatorBlockIndex" to firestoreNullableInt(locatorBlockIndex),
"locatorCharOffset" to firestoreNullableInt(locatorCharOffset),
"lastPage" to firestoreNullableInt(lastPage),
"progressPercentage" to firestoreNullableFloat(progressPercentage),
"isRecent" to firestoreBoolean(isRecent),
"isDeleted" to firestoreBoolean(isDeleted),
"lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
"bookmarksJson" to firestoreNullableString(bookmarksJson),
"hasAnnotations" to firestoreBoolean(hasAnnotations),
"fileContentModifiedTimestamp" to firestoreLong(fileContentModifiedTimestamp),
"customName" to firestoreNullableString(customName),
"highlightsJson" to firestoreNullableString(highlightsJson),
"seriesName" to firestoreNullableString(seriesName),
"seriesIndex" to firestoreNullableDouble(seriesIndex),
"description" to firestoreNullableString(description),
"originalTitle" to firestoreNullableString(originalTitle),
"originalAuthor" to firestoreNullableString(originalAuthor),
"originalSeriesName" to firestoreNullableString(originalSeriesName),
"originalSeriesIndex" to firestoreNullableDouble(originalSeriesIndex),
"originalDescription" to firestoreNullableString(originalDescription)
)
private fun DesktopCloudShelfMetadata.toFirestoreFields(): Map<String, JsonElement> = mapOf(
"name" to firestoreString(name),
"bookIds" to firestoreStringArray(bookIds),
"lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
"isDeleted" to firestoreBoolean(isDeleted)
)
private fun DesktopCloudFontMetadata.toFirestoreFields(): Map<String, JsonElement> = mapOf(
"id" to firestoreString(id),
"displayName" to firestoreString(displayName),
"fileName" to firestoreString(fileName),
"fileExtension" to firestoreString(fileExtension),
"timestamp" to firestoreLong(timestamp),
"isDeleted" to firestoreBoolean(isDeleted)
)
private fun JsonObject.toBookMetadata(documentId: String): DesktopCloudBookMetadata? {
val bookId = stringField("bookId") ?: documentId.takeIf { it.isNotBlank() } ?: return null
return DesktopCloudBookMetadata(
bookId = bookId,
title = stringField("title"),
author = stringField("author"),
displayName = stringField("displayName").orEmpty(),
type = stringField("type").orEmpty(),
lastPositionCfi = stringField("lastPositionCfi"),
lastChapterIndex = intField("lastChapterIndex"),
locatorBlockIndex = intField("locatorBlockIndex"),
locatorCharOffset = intField("locatorCharOffset"),
lastPage = intField("lastPage"),
progressPercentage = doubleField("progressPercentage")?.toFloat(),
isRecent = booleanField("isRecent") ?: true,
isDeleted = booleanField("isDeleted") ?: false,
lastModifiedTimestamp = longField("lastModifiedTimestamp"),
bookmarksJson = stringField("bookmarksJson"),
hasAnnotations = booleanField("hasAnnotations") ?: false,
fileContentModifiedTimestamp = longField("fileContentModifiedTimestamp"),
customName = stringField("customName"),
highlightsJson = stringField("highlightsJson"),
seriesName = stringField("seriesName"),
seriesIndex = doubleField("seriesIndex"),
description = stringField("description"),
originalTitle = stringField("originalTitle"),
originalAuthor = stringField("originalAuthor"),
originalSeriesName = stringField("originalSeriesName"),
originalSeriesIndex = doubleField("originalSeriesIndex"),
originalDescription = stringField("originalDescription")
)
}
private fun JsonObject.toShelfMetadata(documentId: String): DesktopCloudShelfMetadata? {
val name = stringField("name") ?: documentId.takeIf { it.isNotBlank() } ?: return null
return DesktopCloudShelfMetadata(
name = name,
bookIds = stringArrayField("bookIds"),
lastModifiedTimestamp = longField("lastModifiedTimestamp"),
isDeleted = booleanField("isDeleted") ?: false
)
}
private fun JsonObject.toFontMetadata(documentId: String): DesktopCloudFontMetadata? {
val id = stringField("id") ?: documentId.takeIf { it.isNotBlank() } ?: return null
return DesktopCloudFontMetadata(
id = id,
displayName = stringField("displayName").orEmpty(),
fileName = stringField("fileName").orEmpty(),
fileExtension = stringField("fileExtension").orEmpty(),
timestamp = longField("timestamp"),
isDeleted = booleanField("isDeleted") ?: false
)
}
private fun firestoreString(value: String): JsonElement = JsonObject(mapOf("stringValue" to JsonPrimitive(value)))
private fun firestoreNullableString(value: String?): JsonElement {
return value?.let(::firestoreString) ?: firestoreNull()
}
private fun firestoreNullableInt(value: Int?): JsonElement {
return value?.let { JsonObject(mapOf("integerValue" to JsonPrimitive(it.toString()))) } ?: firestoreNull()
}
private fun firestoreLong(value: Long): JsonElement = JsonObject(mapOf("integerValue" to JsonPrimitive(value.toString())))
private fun firestoreNullableFloat(value: Float?): JsonElement {
return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it.toDouble()))) } ?: firestoreNull()
}
private fun firestoreNullableDouble(value: Double?): JsonElement {
return value?.let { JsonObject(mapOf("doubleValue" to JsonPrimitive(it))) } ?: firestoreNull()
}
private fun firestoreBoolean(value: Boolean): JsonElement = JsonObject(mapOf("booleanValue" to JsonPrimitive(value)))
private fun firestoreStringArray(values: List<String>): JsonElement {
return JsonObject(
mapOf(
"arrayValue" to JsonObject(
mapOf("values" to JsonArray(values.map(::firestoreString)))
)
)
)
}
private fun firestoreNull(): JsonElement = JsonObject(mapOf("nullValue" to JsonPrimitive("NULL_VALUE")))
private fun JsonObject.stringField(key: String): String? {
return field(key)?.get("stringValue")?.jsonPrimitive?.contentOrNull
}
private fun JsonObject.booleanField(key: String): Boolean? {
return field(key)?.get("booleanValue")?.jsonPrimitive?.booleanOrNull
}
private fun JsonObject.longField(key: String): Long {
val field = field(key) ?: return 0L
return field["integerValue"]?.jsonPrimitive?.longOrNull
?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toLong()
?: 0L
}
private fun JsonObject.intField(key: String): Int? {
val field = field(key) ?: return null
return field["integerValue"]?.jsonPrimitive?.intOrNull
?: field["doubleValue"]?.jsonPrimitive?.doubleOrNull?.toInt()
}
private fun JsonObject.doubleField(key: String): Double? {
val field = field(key) ?: return null
return field["doubleValue"]?.jsonPrimitive?.doubleOrNull
?: field["integerValue"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
}
private fun JsonObject.stringArrayField(key: String): List<String> {
val values = field(key)
?.get("arrayValue")
?.jsonObjectOrNull()
?.get("values")
?.jsonArrayOrNull()
.orEmpty()
return values.mapNotNull { it.jsonObjectOrNull()?.get("stringValue")?.jsonPrimitive?.contentOrNull }
}
private fun JsonObject.field(key: String): JsonObject? = this[key]?.jsonObjectOrNull()
private fun JsonElement?.jsonObjectOrNull(): JsonObject? {
if (this == null || this is JsonNull) return null
return runCatching { jsonObject }.getOrNull()
}
private fun JsonElement?.jsonArrayOrNull(): JsonArray? {
if (this == null || this is JsonNull) return null
return runCatching { jsonArray }.getOrNull()
}
private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
private fun query(vararg pairs: Pair<String, String>): String = query(pairs.asIterable())
private fun query(pairs: Iterable<Pair<String, String>>): String {
return pairs.joinToString("&") { (key, value) -> "${formEncode(key)}=${formEncode(value)}" }
}
private fun formEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
private fun pathEncode(value: String): String = formEncode(value).replace("+", "%20")
private fun driveQueryStringValue(value: String): String {
return value.replace("\\", "\\\\").replace("'", "\\'")
}
private fun sequenceInputStream(vararg streams: InputStream): SequenceInputStream {
return SequenceInputStream(Collections.enumeration(streams.toList()))
}
private fun HttpRequest.Builder.copyMethodAndBodyFrom(source: HttpRequest): HttpRequest.Builder {
val publisher = source.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody())
return method(source.method(), publisher)
}

View file

@ -0,0 +1,138 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import com.aryan.reader.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.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
internal object DesktopCloudSidecarSync {
fun hasLocalAnnotationData(book: BookItem): Boolean {
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
if (book.type != FileType.PDF) return false
return desktopPdfAnnotationFile(path).isFile ||
desktopPdfBookmarkFile(path).isFile ||
desktopPdfRichTextFile(path).isFile
}
fun localAnnotationTimestamp(book: BookItem): Long {
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
if (book.type != FileType.PDF) return 0L
return maxOf(
desktopPdfAnnotationFile(path).lastModifiedIfFile(),
desktopPdfBookmarkFile(path).lastModifiedIfFile(),
desktopPdfRichTextFile(path).lastModifiedIfFile()
)
}
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 bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
val data = buildMap {
if (annotationFile.isFile) {
val annotations = SharedPdfAnnotationSerializer.decode(annotationFile.readText())
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
)
}
if (bookmarkFile.isFile) {
cloudSidecarJson.parseElementOrNull(bookmarkFile.readText())?.let { put("bookmarks", it) }
}
if (richTextFile.isFile) {
cloudSidecarJson.parseElementOrNull(richTextFile.readText())?.let { element ->
put("text", SharedPdfRichTextSerializer.encodeElement(SharedPdfRichTextSerializer.decodeElement(element)))
}
}
}
if (data.isEmpty()) 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)
return tempFile
}
fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean {
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
if (book.type != FileType.PDF) return false
val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: return false
val data = root["data"]?.jsonObjectOrNull() ?: root
val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data)
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
if (canonicalData.hasPdfAnnotationPayload()) {
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData)
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(timestamp)
} else if (annotationFile.isFile) {
annotationFile.delete()
}
canonicalData["bookmarks"]?.let { bookmarks ->
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks))
bookmarkFile.setLastModified(timestamp)
} ?: run {
if (bookmarkFile.isFile) bookmarkFile.delete()
}
canonicalData["text"]?.let { richText ->
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(timestamp)
} ?: run {
if (richTextFile.isFile) richTextFile.delete()
}
return true
}
}
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
}

View file

@ -0,0 +1,668 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.EpubBookmark
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.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<ShelfRecord>,
val shelfRefs: List<BookShelfRef>,
val customFonts: List<CustomFontItem>,
val includeFolderBooks: Boolean
)
internal data class DesktopCloudSyncResult(
val state: SharedReaderScreenState,
val shelfRecords: List<ShelfRecord>,
val shelfRefs: List<BookShelfRef>,
val customFonts: List<CustomFontItem>,
val uploadedBooks: Int = 0,
val downloadedBooks: 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
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 }
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 -> {
uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced ->
state = state.upsertCloudBook(synced)
uploadedBooks += 1
}
}
local == null && remote != null -> {
if (remote.isDeleted) return@forEach
val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles)
val remoteBook = downloaded ?: remote.toDesktopBookItem()
state = state.upsertCloudBook(remoteBook)
if (downloaded != null) downloadedBooks += 1
if (remote.hasAnnotations) {
downloadAnnotations(input.driveAccessToken, remoteBook, remote.lastModifiedTimestamp)
}
}
local != null && remote != null -> {
if (remote.isDeleted) {
state = state.removeCloudBook(bookId)
return@forEach
}
val remoteBook = remote.toDesktopBookItem(existing = local)
val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remote)
val downloaded = if (shouldDownloadContent) {
downloadRemoteBook(input.driveAccessToken, remote, local, driveFiles)
} else {
null
}
val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
val localMetadataTimestamp = maxOf(local.timestamp, localSidecarTimestampBeforeMerge)
if (localMetadataTimestamp > remote.lastModifiedTimestamp) {
uploadBookAndMetadata(input, local, uploadContent = shouldUploadLocalBookContent(local, remote))?.let { synced ->
state = state.upsertCloudBook(synced)
uploadedBooks += 1
}
} else if (remote.lastModifiedTimestamp > local.timestamp || downloaded != null) {
state = state.upsertCloudBook(downloaded ?: remoteBook)
}
val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationTimestamp(downloaded ?: local)
val needsAnnotationDownload = remote.hasAnnotations &&
(remote.lastModifiedTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L)
if (needsAnnotationDownload) {
val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local
downloadAnnotations(input.driveAccessToken, targetBook, remote.lastModifiedTimestamp)
}
}
}
}
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 -> {
if (driveRepository.uploadFile(input.driveAccessToken, book.id, localFile, book.type) != null) {
uploadedBooks += 1
}
}
(localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> {
val remote = remoteBooksMap[book.id] ?: return@forEach
downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)?.let { downloaded ->
state = state.upsertCloudBook(downloaded)
downloadedBooks += 1
}
}
}
}
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
)
return DesktopCloudSyncResult(
state = state,
shelfRecords = shelfRecords,
shelfRefs = shelfRefs,
customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() },
uploadedBooks = uploadedBooks,
downloadedBooks = downloadedBooks
)
}
suspend fun uploadBookAndMetadata(
input: DesktopCloudSyncInput,
book: BookItem,
uploadContent: Boolean
): BookItem? {
if (isDesktopPdfReflowBookId(book.id)) return null
if (book.sourceFolder != null) return null
if (book.path?.startsWith("opds-pse") == true) return null
if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) return null
if (uploadContent) {
val source = book.path?.let(::File)?.takeIf { it.isFile }
if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) {
return null
}
}
val bundle = DesktopCloudSidecarSync.exportAnnotationBundle(book)
try {
if (bundle != null && driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) == null) {
return null
}
} finally {
bundle?.delete()
}
val now = System.currentTimeMillis()
val syncedBook = book.copy(timestamp = now)
firestoreRepository.syncBookMetadata(
userId = input.userId,
book = syncedBook.toDesktopCloudBookMetadata(
hasAnnotations = bundle != null,
timestamp = now
),
originDeviceId = input.deviceId,
idToken = input.idToken
)
return syncedBook
}
suspend fun deleteBooksFromCloud(
userId: String,
idToken: String,
accessToken: String,
deviceId: String,
books: List<BookItem>
) {
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["annotation_${book.id}.json"]?.id
?.let { driveRepository.deleteDriveFile(accessToken, it) }
}
}
suspend fun syncShelfChange(
userId: String,
idToken: String,
deviceId: String,
record: ShelfRecord,
refs: List<BookShelfRef>,
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 {
if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) return false
DesktopCloudSidecarSync.importAnnotationBundle(book, temp.readText(), timestamp)
} finally {
temp.delete()
}
}
private suspend fun downloadRemoteBook(
accessToken: String,
remote: DesktopCloudBookMetadata,
existing: BookItem?,
driveFiles: Map<String, DesktopDriveFile>
): 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")
if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) {
destination.delete()
return null
}
val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified()
if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp)
return remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy(
fileSize = destination.length(),
fileContentModifiedTimestamp = contentTimestamp
)
}
private suspend fun syncFonts(
userId: String,
idToken: String,
accessToken: String,
localFonts: List<CustomFontItem>,
remoteFonts: List<DesktopCloudFontMetadata>
): List<CustomFontItem> {
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<ShelfRecord>,
shelfRefs: List<BookShelfRef>,
syncableBookIds: Set<String>,
remoteShelves: List<DesktopCloudShelfMetadata>
): 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
): DesktopCloudBookMetadata {
val position = readerPosition
val bookmarksJson = readerBookmarks
.mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() }
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::bookmarksToJson)
val highlightsJson = readerHighlights
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::highlightsToJson)
val localFile = path?.let(::File)
val contentTimestamp = 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 = null,
locatorCharOffset = null,
lastPage = position?.pageIndex ?: lastPageIndex,
progressPercentage = progressPercentage,
isRecent = isRecent,
isDeleted = false,
lastModifiedTimestamp = timestamp,
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 = ReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = pageIndex
)
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 = progressPercentage ?: 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 = pageIndex ?: existing?.lastPageIndex,
readerPosition = locator.takeIf {
it.chapterIndex != null || it.pageIndex != null || it.cfi != null || it.startOffset != null
} ?: existing?.readerPosition,
readerSettings = existing?.readerSettings,
readerBookmarks = if (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 = existing?.pdfReaderViewport
)
}
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? {
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
return "$bookId.$extension"
}
private data class DesktopCloudShelfRecord(
val record: ShelfRecord,
val metadata: DesktopCloudShelfMetadata
)
private data class ShelfSyncResult(
val records: List<ShelfRecord>,
val refs: List<BookShelfRef>
)
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.isDeleted &&
remote.fileType() == local.type &&
remote.fileContentModifiedTimestamp > 0L &&
(localFile == null || !localFile.isFile || remote.fileContentModifiedTimestamp > localTimestamp)
}
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 &&
localTimestamp > 0L &&
localTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L)
}
private fun desktopShelfTimestamp(record: ShelfRecord, refs: List<BookShelfRef>): 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? {
cfi?.let { return it }
val chapter = chapterIndex
val start = startOffset
val end = endOffset ?: start
return if (chapter != null && start != null && end != null) {
"desktop:$chapter:$start:$end"
} else if (chapter != null && pageIndex != null) {
"desktop:$chapter:$pageIndex"
} else {
null
}
}
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
)
)
}

View file

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

View file

@ -53,6 +53,23 @@ class DesktopCustomFontStore(
return !target.exists() || target.delete()
}
fun getFontFile(fileName: String): File {
fontsDir.mkdirs()
return File(fontsDir, fileName)
}
internal fun syncedFontItem(metadata: DesktopCloudFontMetadata): CustomFontItem {
return CustomFontItem(
id = metadata.id,
displayName = metadata.displayName,
fileName = metadata.fileName,
fileExtension = metadata.fileExtension,
path = getFontFile(metadata.fileName).absolutePath,
timestamp = metadata.timestamp,
isDeleted = metadata.isDeleted
)
}
fun loadGoogleFontsList(): List<String> {
googleFontsCache?.let { return it }
val loaded = runCatching {

View file

@ -0,0 +1,378 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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 com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.UserHighlight
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.ui.ReaderContentNavigationTarget
import com.multiplatform.webview.jsbridge.IJsMessageHandler
import com.multiplatform.webview.jsbridge.JsMessage
import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge
import com.multiplatform.webview.request.RequestInterceptor
import com.multiplatform.webview.request.WebRequest
import com.multiplatform.webview.request.WebRequestInterceptResult
import com.multiplatform.webview.web.LoadingState
import com.multiplatform.webview.web.WebContent
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.WebViewNavigator
import com.multiplatform.webview.web.WebViewState
import com.multiplatform.webview.web.rememberWebViewNavigator
import kotlinx.coroutines.launch
import java.awt.AWTEvent
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.MouseEvent
@Composable
internal fun DesktopEpubWebView(
html: String,
appearanceScript: String,
navigationTarget: ReaderContentNavigationTarget,
highlights: List<UserHighlight>,
onHighlightCreated: (UserHighlight) -> Unit,
onHighlightSelected: (String) -> Unit,
isFullscreen: Boolean,
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit,
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
onPointerActivity: () -> Unit = {},
networkAccessEnabled: Boolean,
modifier: Modifier = Modifier
) {
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()
val linkRequestInterceptor = remember(scope, networkAccessEnabled) {
object : RequestInterceptor {
override fun onInterceptUrlRequest(
request: WebRequest,
navigator: WebViewNavigator
): WebRequestInterceptResult {
if (!networkAccessEnabled && request.url.isRemoteNetworkUrl()) {
logEpubLink("request_blocked_offline url=\"${request.url.logPreview()}\"")
return WebRequestInterceptResult.Reject
}
if (!request.isForMainFrame) return WebRequestInterceptResult.Allow
val link = request.url.readerLinkClickFromIntercept() ?: return WebRequestInterceptResult.Allow
logEpubLink(
"request_intercept method=${request.method} redirect=${request.isRedirect} " +
"url=\"${request.url.logPreview()}\" href=\"${link.href.logPreview()}\""
)
scope.launch {
latestOnLinkClicked(link.copy(source = "request"))
}
return WebRequestInterceptResult.Reject
}
}
}
val navigator = rememberWebViewNavigator(requestInterceptor = linkRequestInterceptor)
val bridge = rememberWebViewJsBridge()
DisposableEffect(bridge) {
val handlers = listOf(
desktopEpubBridgeHandler("readerHighlightCreated") { message ->
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(message.params)
if (highlight == null) {
logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}")
} else {
scope.launch { latestOnHighlightCreated(highlight) }
}
},
desktopEpubBridgeHandler("readerHighlightClicked") { message ->
message.params.readerHighlightClickOrNull()?.let { highlightClick ->
scope.launch { latestOnHighlightSelected(highlightClick.highlightId) }
}
},
desktopEpubBridgeHandler("readerPositionChanged") { message ->
message.params.readerPositionOrNull()?.let { position ->
scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) }
}
},
desktopEpubBridgeHandler("readerSelectionAction") { message ->
val selectionAction = message.params.readerSelectionActionOrNull()
if (selectionAction != null) {
scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) }
}
},
desktopEpubBridgeHandler("readerKeyNavigation") { message ->
message.params.readerKeyNavigationOrNull()?.let { action ->
scope.launch { latestOnKeyboardNavigation(action) }
}
},
desktopEpubBridgeHandler("readerPointerActivity") { _ ->
scope.launch { latestOnPointerActivity() }
},
desktopEpubBridgeHandler("readerTtsHighlightLog") { message ->
logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}")
},
desktopEpubBridgeHandler("readerSelectionDebugLog") { message ->
logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900))
},
desktopEpubBridgeHandler("readerPaginationLayoutLog") { message ->
logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
},
desktopEpubBridgeHandler("readerGapLayoutLog") { message ->
logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
},
desktopEpubBridgeHandler("readerLinkClicked") { message ->
logEpubLink("bridge_message params=\"${message.params.logPreview()}\"")
val link = message.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) }
}
}
)
handlers.forEach { bridge.register(it) }
onDispose {
handlers.forEach { bridge.unregister(it) }
}
}
val state = remember {
WebViewState(
WebContent.Data(
data = html,
baseUrl = null,
encoding = "utf-8",
mimeType = "text/html",
historyUrl = null
)
)
}
LaunchedEffect(html) {
navigator.loadHtml(
html = html,
baseUrl = null,
mimeType = "text/html",
encoding = "utf-8",
historyUrl = null
)
}
DisposableEffect(Unit) {
var lastActivityAt = 0L
var lastMouseX: Int? = null
var lastMouseY: Int? = null
val listener = AWTEventListener { event ->
val mouseEvent = event as? MouseEvent ?: return@AWTEventListener
if (
mouseEvent.id != MouseEvent.MOUSE_MOVED &&
mouseEvent.id != MouseEvent.MOUSE_DRAGGED &&
mouseEvent.id != MouseEvent.MOUSE_PRESSED &&
mouseEvent.id != MouseEvent.MOUSE_WHEEL
) {
return@AWTEventListener
}
if (mouseEvent.id == MouseEvent.MOUSE_MOVED || mouseEvent.id == MouseEvent.MOUSE_DRAGGED) {
val screenX = mouseEvent.xOnScreen
val screenY = mouseEvent.yOnScreen
if (lastMouseX == screenX && lastMouseY == screenY) return@AWTEventListener
lastMouseX = screenX
lastMouseY = screenY
} else {
lastMouseX = mouseEvent.xOnScreen
lastMouseY = mouseEvent.yOnScreen
}
val now = mouseEvent.`when`.takeIf { it > 0L } ?: System.currentTimeMillis()
if (now - lastActivityAt < 120L) return@AWTEventListener
lastActivityAt = now
scope.launch { latestOnPointerActivity() }
}
val eventMask = AWTEvent.MOUSE_MOTION_EVENT_MASK or
AWTEvent.MOUSE_EVENT_MASK or
AWTEvent.MOUSE_WHEEL_EVENT_MASK
Toolkit.getDefaultToolkit().addAWTEventListener(listener, eventMask)
onDispose {
Toolkit.getDefaultToolkit().removeAWTEventListener(listener)
}
}
Box(modifier = modifier) {
WebView(
state = state,
modifier = Modifier.fillMaxSize(),
captureBackPresses = false,
navigator = navigator,
webViewJsBridge = bridge
)
LaunchedEffect(state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript(DesktopEpubKeyNavigationScript)
}
LaunchedEffect(isFullscreen, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};")
}
LaunchedEffect(html, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');")
}
LaunchedEffect(appearanceScript, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript(appearanceScript)
}
LaunchedEffect(
navigationTarget.autoScroll,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (!state.loadingState.isFinished()) return@LaunchedEffect
val autoScroll = navigationTarget.autoScroll.sanitized()
val command = if (autoScroll.enabled) {
"window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});"
} else {
"window.readerAutoScroll && window.readerAutoScroll.stop();"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(
navigationTarget.requestId,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (!state.loadingState.isFinished()) return@LaunchedEffect
val locator = navigationTarget.locator ?: return@LaunchedEffect
navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});")
}
LaunchedEffect(
navigationTarget.ttsRequestId,
navigationTarget.ttsLocator,
navigationTarget.readingMode,
state.loadingState
) {
if (!state.loadingState.isFinished()) 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);"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(highlights, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights)
navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);")
}
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = { loadingState.progress },
modifier = Modifier.fillMaxWidth()
)
}
}
}
private fun desktopEpubBridgeHandler(
methodName: String,
onMessage: (JsMessage) -> Unit
): IJsMessageHandler {
return object : IJsMessageHandler {
override fun methodName(): String = methodName
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
onMessage(message)
}
}
}
private fun LoadingState.isFinished(): Boolean = this is LoadingState.Finished
private val DesktopEpubKeyNavigationScript = """
(function () {
if (!window.readerDesktopPointerActivityInstalled) {
window.readerDesktopPointerActivityInstalled = true;
var lastPointerActivityAt = 0;
var lastPointerX = null;
var lastPointerY = null;
function notifyPointerActivity(event, requireMovement) {
if (requireMovement && event) {
var x = Math.round(event.screenX || event.clientX || 0);
var y = Math.round(event.screenY || event.clientY || 0);
if (lastPointerX === x && lastPointerY === y) return;
lastPointerX = x;
lastPointerY = y;
}
var now = Date.now();
if (now - lastPointerActivityAt < 120) return;
lastPointerActivityAt = now;
if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
window.kmpJsBridge.callNative('readerPointerActivity', '{}');
}
document.addEventListener('mousemove', function (event) { notifyPointerActivity(event, true); }, true);
document.addEventListener('pointermove', function (event) { notifyPointerActivity(event, true); }, true);
document.addEventListener('pointerdown', function (event) { notifyPointerActivity(event, false); }, true);
document.addEventListener('wheel', function (event) { notifyPointerActivity(event, false); }, true);
}
if (window.readerDesktopKeyNavigationInstalled) return;
window.readerDesktopKeyNavigationInstalled = true;
document.addEventListener('keydown', function (event) {
var target = event.target;
var tag = target && target.tagName ? target.tagName.toLowerCase() : '';
if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return;
var action = null;
if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search';
else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch';
else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next';
else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous';
else if (event.key === 'Home') action = 'first';
else if (event.key === 'End') action = 'last';
else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen';
if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
event.preventDefault();
event.stopPropagation();
window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action }));
}, true);
})();
""".trimIndent()

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.ui.readerString
import java.awt.Desktop
import java.net.URI
import java.net.URLEncoder
@ -58,11 +59,11 @@ internal fun DesktopExternalLinkDialog(
onDismiss()
}
DesktopReaderBottomSheet(
title = "External link",
title = readerString("dialog_external_link_title", "External link"),
onDismiss = ::dismiss
) {
Text(
"You clicked an external link.",
readerString("desktop_external_link_desc", "You clicked an external link."),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
@ -85,7 +86,7 @@ internal fun DesktopExternalLinkDialog(
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = ::dismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
TextButton(
onClick = {
@ -94,7 +95,7 @@ internal fun DesktopExternalLinkDialog(
onDismiss()
}
) {
Text("Copy")
Text(readerString("action_copy", "Copy"))
}
TextButton(
onClick = {
@ -103,7 +104,7 @@ internal fun DesktopExternalLinkDialog(
onDismiss()
}
) {
Text("Open")
Text(readerString("action_open", "Open"))
}
}
}

View file

@ -20,7 +20,7 @@ private val DesktopBookFileDialogPattern = SharedFileCapabilities.all
internal fun desktopBookFileTypesForDialog(): Set<FileType> = DesktopBookFileTypes
internal fun chooseFiles(): List<ImportedBookFile> {
val dialog = FileDialog(null as Frame?, "Import books", FileDialog.LOAD).apply {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_import_books", "Import books"), FileDialog.LOAD).apply {
isMultipleMode = true
isVisible = true
}
@ -28,7 +28,7 @@ internal fun chooseFiles(): List<ImportedBookFile> {
}
internal fun chooseBookFile(): File? {
val dialog = FileDialog(null as Frame?, "Open Book", FileDialog.LOAD).apply {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_book", "Open Book"), FileDialog.LOAD).apply {
file = DesktopBookFileDialogPattern
isVisible = true
}
@ -38,7 +38,7 @@ internal fun chooseBookFile(): File? {
}
internal fun choosePdfFile(): File? {
val dialog = FileDialog(null as Frame?, "Open PDF", FileDialog.LOAD).apply {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_open_pdf", "Open PDF"), FileDialog.LOAD).apply {
file = "*.pdf"
isVisible = true
}
@ -48,7 +48,7 @@ internal fun choosePdfFile(): File? {
}
internal fun chooseFontFile(): File? {
val dialog = FileDialog(null as Frame?, "Choose font", FileDialog.LOAD).apply {
val dialog = FileDialog(null as Frame?, desktopDialogString("desktop_choose_font", "Choose font"), FileDialog.LOAD).apply {
file = "*.ttf;*.otf;*.woff2"
isVisible = true
}
@ -58,7 +58,7 @@ internal fun chooseFontFile(): File? {
}
internal fun chooseReaderTextureFile(): File? {
val dialog = FileDialog(null as Frame?, "Choose reader texture", FileDialog.LOAD).apply {
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
}
@ -67,9 +67,19 @@ internal fun chooseReaderTextureFile(): File? {
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 chooseFolder(): File? {
val chooser = JFileChooser().apply {
dialogTitle = "Import folder"
dialogTitle = desktopDialogString("desktop_import_folder", "Import folder")
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
isAcceptAllFileFilterUsed = false
}
@ -80,6 +90,10 @@ internal fun chooseFolder(): File? {
}
}
private fun desktopDialogString(name: String, fallback: String): String {
return loadDesktopStringResolver().string(name, fallback)
}
internal fun ImportedBookFile.desktopFileType(): FileType {
return SharedFileCapabilities.fileTypeForName(name)
}

View file

@ -23,6 +23,8 @@ import androidx.compose.ui.zIndex
import com.aryan.reader.shared.ImportedBookFile
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.ui.readerQuantityString
import com.aryan.reader.shared.ui.readerString
import java.awt.Component
import java.awt.Container
import java.awt.EventQueue
@ -171,16 +173,28 @@ internal fun DesktopDropImportOverlay(state: DesktopDropImportState) {
val hasSupportedFiles = state.supportedCount > 0
val title = when {
hasSupportedFiles -> "Drop to import ${state.supportedCount} file${if (state.supportedCount == 1) "" else "s"}"
state.hasFilePayload -> "Drop supported files to import"
else -> "Drop files to import"
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) {
"$skipped unsupported file${if (skipped == 1) "" else "s"} will be skipped."
readerQuantityString(
"desktop_unsupported_import_file_count",
skipped,
"%1\$d unsupported file will be skipped.",
"%1\$d unsupported files will be skipped.",
skipped
)
} else {
"Release to add to your library."
readerString("desktop_release_add_library", "Release to add to your library.")
}
} else {
SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)

View file

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

View file

@ -54,6 +54,10 @@ private data class DesktopTtsSequenceChunk(
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 = { false },
private val onWorkerUsageCompleted: suspend () -> Unit = {},
httpClient: HttpClient? = null,
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
) : TtsAdapter {
@ -72,7 +76,14 @@ class DesktopGeminiCloudTtsAdapter(
private var activePlayer: DesktopStreamingPcmPlayer? = null
override val isAvailable: Boolean
get() = networkAccess() && settingsProvider().sanitized().isCloudTtsAvailable
get() {
val settings = settingsProvider().sanitized()
return networkAccess() && if (useWorkerProvider()) {
settings.serverBackedCloudTts && workerUrlProvider().isNotBlank()
} else {
settings.isByokCloudTtsAvailable
}
}
override suspend fun speak(text: String) {
val trimmed = text.trim()
@ -171,11 +182,13 @@ class DesktopGeminiCloudTtsAdapter(
onChunkStart: suspend (Int) -> Unit
) = withContext(Dispatchers.IO) {
val settings = settingsProvider().sanitized()
val useWorker = useWorkerProvider()
val authToken = if (useWorker) authTokenProvider() else null
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}"
"available=${settings.isCloudTtsAvailable} worker=$useWorker"
)
if (!networkAccess()) {
logDesktopTts("stream_blocked reason=network_disabled")
@ -183,7 +196,17 @@ class DesktopGeminiCloudTtsAdapter(
}
if (!settings.isCloudTtsAvailable) {
logDesktopTts("stream_blocked reason=not_available")
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
throw IllegalStateException(
if (useWorker) {
"Cloud TTS needs a signed-in account with credits."
} else {
"Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected."
}
)
}
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)
@ -287,9 +310,19 @@ class DesktopGeminiCloudTtsAdapter(
suspend fun ensureWebSocket(): WebSocket {
webSocket?.let { return it }
val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name())
val uri = URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey")
logDesktopTts("ws_connect_start endpoint=GeminiLive keyChars=${settings.geminiKey.length}")
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)
@ -421,6 +454,7 @@ class DesktopGeminiCloudTtsAdapter(
activeWebSocket = null
activePlayer = null
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
if (useWorker) onWorkerUsageCompleted()
} catch (error: Throwable) {
currentTurnComplete.set(null)
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }

View file

@ -0,0 +1,162 @@
package com.aryan.reader.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 com.aryan.reader.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")
)
internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption {
val normalized = normalizeDesktopLanguageTag(languageTag)
return DesktopLanguageOptions.firstOrNull { it.normalizedTag == normalized }
?: DesktopLanguageOptions.first()
}
internal class DesktopLanguageSettingsStore(
private val settingsFile: File = File(desktopUserConfigRoot(), "language.properties")
) {
fun load(): DesktopLanguageSettings {
if (!settingsFile.isFile) return DesktopLanguageSettings()
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
DesktopLanguageSettings(
languageTag = normalizeDesktopLanguageTag(properties.getProperty(LanguageTag))
)
}.getOrDefault(DesktopLanguageSettings())
}
fun save(settings: DesktopLanguageSettings) {
settingsFile.parentFile?.mkdirs()
val properties = Properties().apply {
normalizeDesktopLanguageTag(settings.languageTag)?.let { languageTag ->
setProperty(LanguageTag, languageTag)
}
}
settingsFile.outputStream().use { output ->
properties.store(output, "Episteme desktop language")
}
}
private companion object {
const val LanguageTag = "languageTag"
}
}
@Composable
internal fun DesktopLanguageDialog(
selectedLanguageTag: String?,
onLanguageSelected: (String?) -> Unit,
onDismiss: () -> Unit
) {
val selectedOption = selectedDesktopLanguageOption(selectedLanguageTag)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(readerString("options_language", "Language"), fontWeight = FontWeight.Bold) },
text = {
LazyColumn(
modifier = Modifier.heightIn(max = 520.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(DesktopLanguageOptions, key = { it.languageTag ?: "system" }) { option ->
val selected = option.normalizedTag == selectedOption.normalizedTag
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = if (selected) {
BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
onClick = {
onLanguageSelected(option.normalizedTag)
onDismiss()
}
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
RadioButton(
selected = selected,
onClick = {
onLanguageSelected(option.normalizedTag)
onDismiss()
}
)
Column(Modifier.weight(1f)) {
Text(readerString(option.labelKey, option.fallbackLabel))
}
}
}
if (option != DesktopLanguageOptions.last()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f))
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(readerString("action_cancel", "Cancel"))
}
}
)
}

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.AppAction
import com.aryan.reader.shared.BannerMessage
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFolderPathResolver
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf
@ -42,10 +43,10 @@ import com.aryan.reader.shared.Tag
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reduce
import com.aryan.reader.shared.ui.NonReaderLibraryTab
import com.aryan.reader.shared.ui.SharedHomeScreen
import com.aryan.reader.shared.ui.SharedLibraryScreen
import com.aryan.reader.shared.ui.SharedShelvesScreen
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.shared.ui.readerString
import java.io.File
internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean {
@ -116,8 +117,7 @@ internal fun resolvedDesktopReaderSettings(
@Composable
internal fun DesktopReaderOpeningScreen(
opening: DesktopReaderOpening,
onReturnToLibrary: () -> Unit
opening: DesktopReaderOpening
) {
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
@ -129,7 +129,7 @@ internal fun DesktopReaderOpeningScreen(
) {
CircularProgressIndicator()
Text(
text = "Opening ${opening.title}",
text = readerString("desktop_opening_title", "Opening %1\$s", opening.title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
@ -140,9 +140,6 @@ internal fun DesktopReaderOpeningScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
TextButton(onClick = onReturnToLibrary) {
Text("Return to library")
}
}
}
}
@ -150,6 +147,9 @@ internal fun DesktopReaderOpeningScreen(
@Composable
internal fun HomeScreen(
state: SharedReaderScreenState,
selectedLibraryTab: NonReaderLibraryTab,
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
onStateChange: (SharedReaderScreenState) -> Unit,
onImportBooks: () -> Unit,
onImportFolder: () -> Unit,
onRead: (BookItem) -> Unit,
@ -158,34 +158,40 @@ internal fun HomeScreen(
onRemoveSelected: () -> Unit,
onShowBookInfo: (BookItem) -> Unit,
onEditBook: (BookItem) -> Unit,
onCreateShelf: () -> Unit,
onCreateSmartShelf: () -> Unit,
onRenameShelf: (Shelf) -> Unit,
onDeleteShelf: (Shelf) -> Unit,
onRemoveFolder: (Shelf) -> Unit,
onTagSelectedBooks: () -> Unit,
onAddSelectedBooksToShelf: () -> Unit,
onOpenTab: (BookItem) -> Unit,
onCloseTab: (BookItem) -> Unit,
onCloseAllTabs: () -> Unit,
onRecentLimitChange: (Int) -> Unit,
onTogglePinned: (BookItem) -> Unit,
onOpenSettings: () -> Unit
onSyncFolderMetadata: () -> Unit,
onScanFolders: () -> Unit,
onTogglePinned: (BookItem) -> Unit
) {
SharedHomeScreen(
LibraryScreen(
state = state,
selectedLibraryTab = selectedLibraryTab,
onLibraryTabChange = onLibraryTabChange,
onStateChange = onStateChange,
onImportBooks = onImportBooks,
onImportFolder = onImportFolder,
onOpenBook = onRead,
onToggleSelection = onSelect,
onRead = onRead,
onSelect = onSelect,
onClearSelection = onClearSelection,
onRemoveSelected = onRemoveSelected,
onShowBookInfo = onShowBookInfo,
onEditBook = onEditBook,
onCreateShelf = onCreateShelf,
onCreateSmartShelf = onCreateSmartShelf,
onRenameShelf = onRenameShelf,
onDeleteShelf = onDeleteShelf,
onRemoveFolder = onRemoveFolder,
onTagSelectedBooks = onTagSelectedBooks,
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
onOpenTab = onOpenTab,
onCloseTab = onCloseTab,
onCloseAllTabs = onCloseAllTabs,
onRecentLimitChange = onRecentLimitChange,
onTogglePinned = onTogglePinned,
onOpenSettings = onOpenSettings,
showActiveTabs = false
onSyncFolderMetadata = onSyncFolderMetadata,
onScanFolders = onScanFolders,
onTogglePinned = onTogglePinned
)
}
@ -237,6 +243,7 @@ internal fun LibraryScreen(
onSyncFolderMetadata = onSyncFolderMetadata,
onScanFolders = onScanFolders,
onTogglePinned = onTogglePinned,
platform = ReaderPlatform.DESKTOP,
useImportEmptyStateWhenLibraryEmpty = true
)
}
@ -298,7 +305,7 @@ internal fun SmartShelfDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create smart shelf") },
title = { Text(readerString("desktop_create_smart_shelf", "Create smart shelf")) },
text = {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
@ -307,7 +314,7 @@ internal fun SmartShelfDialog(
SharedStableOutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Shelf name") },
label = { Text(readerString("shelf_name_hint", "Shelf name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@ -315,29 +322,29 @@ internal fun SmartShelfDialog(
FilterChip(
selected = matchAll,
onClick = { matchAll = true },
label = { Text("All") }
label = { Text(readerString("filter_all", "All")) }
)
FilterChip(
selected = !matchAll,
onClick = { matchAll = false },
label = { Text("Any") }
label = { Text(readerString("desktop_match_any", "Any")) }
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = { rules = rules + DesktopSmartRuleDraft() },
enabled = rules.size < 4
) {
Text("Add rule")
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 = "Field",
label = readerString("desktop_field", "Field"),
selected = draft.field,
options = SmartField.entries.toList(),
optionLabel = { it.desktopLabel() },
optionLabel = { it.localizedLabel() },
onSelected = { field ->
rules = rules.updateAt(index) {
val operator = smartOperatorsFor(field).first()
@ -346,24 +353,24 @@ internal fun SmartShelfDialog(
}
)
SmartRuleDropdown(
label = "Operator",
label = readerString("desktop_operator", "Operator"),
selected = draft.operator,
options = smartOperatorsFor(draft.field),
optionLabel = { it.desktopLabel() },
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("Remove")
Text(readerString("action_remove", "Remove"))
}
}
}
SharedStableOutlinedTextField(
value = draft.value,
onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } },
label = { Text(draft.field.valueLabel()) },
label = { Text(draft.field.localizedValueLabel()) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
selectionKey = index
@ -379,12 +386,12 @@ internal fun SmartShelfDialog(
},
enabled = name.isNotBlank() && validRules.isNotEmpty()
) {
Text("Create")
Text(readerString("action_create", "Create"))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
@ -395,13 +402,14 @@ private fun <T> SmartRuleDropdown(
label: String,
selected: T,
options: List<T>,
optionLabel: (T) -> String,
optionLabel: @Composable (T) -> String,
onSelected: (T) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { expanded = true }) {
Text("$label: ${optionLabel(selected)}")
val selectedLabel = optionLabel(selected)
Text(readerString("filter_facet", "%1\$s: %2\$s", label, selectedLabel))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
@ -424,34 +432,37 @@ private fun smartOperatorsFor(field: SmartField): List<SmartOperator> {
}
}
private fun SmartField.desktopLabel(): String {
@Composable
private fun SmartField.localizedLabel(): String {
return when (this) {
SmartField.TITLE -> "Title"
SmartField.AUTHOR -> "Author"
SmartField.PROGRESS -> "Progress"
SmartField.FILE_TYPE -> "File type"
SmartField.FOLDER -> "Folder"
SmartField.TAG -> "Tag"
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")
}
}
private fun SmartField.valueLabel(): String {
@Composable
private fun SmartField.localizedValueLabel(): String {
return when (this) {
SmartField.PROGRESS -> "Percent"
SmartField.FILE_TYPE -> "Type, e.g. PDF"
SmartField.FOLDER -> "Folder path"
SmartField.TAG -> "Tag name"
SmartField.TITLE -> "Title text"
SmartField.AUTHOR -> "Author text"
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")
}
}
private fun SmartOperator.desktopLabel(): String {
@Composable
private fun SmartOperator.localizedLabel(): String {
return when (this) {
SmartOperator.EQUALS -> "Equals"
SmartOperator.CONTAINS -> "Contains"
SmartOperator.GREATER_THAN -> "Greater than"
SmartOperator.LESS_THAN -> "Less than"
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")
}
}

View file

@ -0,0 +1,297 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.AiAdapter
import com.aryan.reader.shared.AiDefinitionResult
import com.aryan.reader.shared.RecapResult
import com.aryan.reader.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
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 onUsageCompleted: suspend () -> 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<String>, 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<DesktopPaidAiResponse> = 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, onChunk, onUsageReceived)
if (parsed.text.isBlank()) throw IllegalStateException("The AI service returned an empty response.")
onUsageCompleted()
return@runCatching parsed
}
val responseText = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (connection.responseCode == 402 || responseText.contains("INSUFFICIENT_CREDITS")) {
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()
}
}
}
}
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
): DesktopPaidAiResponse {
val output = StringBuilder()
var cost: Double? = null
var freeRemaining: Int? = null
stream?.bufferedReader(Charsets.UTF_8)?.useLines { lines ->
lines.forEach { line ->
val parsed = parseWorkerStreamLine(line) ?: return@forEach
parsed.cost?.let { cost = it }
parsed.freeRemaining?.let { freeRemaining = it }
if (parsed.cost != null || parsed.freeRemaining != null) {
onUsageReceived(parsed.cost, 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 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() }
}

View file

@ -57,6 +57,17 @@ import com.aryan.reader.shared.pdf.withSharedPdfTextStyle
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
import com.aryan.reader.shared.ui.SharedPdfTextStyleControls
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.shared.ui.readerString
internal val DesktopPdfAnnotationTools = listOf(
PdfInkTool.PEN,
PdfInkTool.FOUNTAIN_PEN,
PdfInkTool.PENCIL,
PdfInkTool.HIGHLIGHTER,
PdfInkTool.HIGHLIGHTER_ROUND,
PdfInkTool.TEXT,
PdfInkTool.ERASER
)
@Composable
internal fun DesktopPdfAnnotationEditor(
@ -86,17 +97,17 @@ internal fun DesktopPdfAnnotationEditor(
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Selected ${annotation.desktopLabel()}",
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("Close")
Text(readerString("action_close", "Close"))
}
}
Text(
"Page ${annotation.pageIndex + 1}",
readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
@ -131,13 +142,13 @@ internal fun DesktopPdfAnnotationEditor(
) {
DesktopBottomSheetToolButton(
icon = Icons.Default.ContentCopy,
label = "Copy",
label = readerString("action_copy", "Copy"),
onClick = onCopy
)
if (showSearch) {
DesktopBottomSheetToolButton(
icon = Icons.Default.Search,
label = "Search",
label = readerString("action_search", "Search"),
onClick = onSearch
)
}
@ -147,7 +158,7 @@ internal fun DesktopPdfAnnotationEditor(
SharedStableOutlinedTextField(
value = annotation.text,
onValueChange = { onUpdate(annotation.copy(text = it)) },
label = { Text("Text note") },
label = { Text(readerString("desktop_text_note", "Text note")) },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
selectionKey = annotation.id
@ -163,7 +174,7 @@ internal fun DesktopPdfAnnotationEditor(
} else {
SharedPdfAnnotationDefaults.penPalette
}
Text("Color", style = MaterialTheme.typography.labelLarge)
Text(readerString("desktop_color", "Color"), style = MaterialTheme.typography.labelLarge)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -217,7 +228,7 @@ internal fun DesktopPdfAnnotationEditor(
SharedStableOutlinedTextField(
value = annotation.note.orEmpty(),
onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) },
label = { Text("Note") },
label = { Text(readerString("label_note", "Note")) },
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth(),
@ -228,7 +239,14 @@ internal fun DesktopPdfAnnotationEditor(
if (annotation.kind == PdfAnnotationKind.INK) {
val strokeRange = annotation.tool.sharedPdfStrokeWidthRange()
val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
Text("Thickness ${strokeValue.sharedPdfStrokePercent(strokeRange)}", style = MaterialTheme.typography.labelLarge)
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))) },
@ -237,7 +255,7 @@ internal fun DesktopPdfAnnotationEditor(
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onDelete) {
Text("Delete")
Text(readerString("action_delete", "Delete"))
}
}
}
@ -247,7 +265,7 @@ internal fun DesktopPdfAnnotationEditor(
val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f)
SharedHsvColorPickerDialog(
initialColor = initialColor,
title = "Highlight color ${slot + 1}",
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()
@ -338,28 +356,35 @@ internal fun DesktopPdfEmbeddedAnnotationPanel(
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Embedded PDF comment",
readerString("desktop_embedded_pdf_comment", "Embedded PDF comment"),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onClose) {
Text("Close")
Text(readerString("action_close", "Close"))
}
}
Text(
"Page ${annotation.pageIndex + 1}${annotation.author.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()}",
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.ifBlank { "No comment" },
contents = annotation.contents,
depth = 0
)
DesktopPdfEmbeddedReplies(annotation.replies, depth = 1)
TextButton(onClick = onCopy) {
Text("Copy thread")
Text(readerString("action_copy_thread", "Copy thread"))
}
}
}
@ -394,31 +419,47 @@ private fun DesktopPdfEmbeddedComment(
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
author.ifBlank { "Unknown" },
author.ifBlank { readerString("unknown", "Unknown") },
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold
)
Text(
contents.ifBlank { "No comment" },
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 -> "highlight"
PdfAnnotationKind.INK -> tool.name.lowercase().replace('_', ' ')
PdfAnnotationKind.TEXT -> "text note"
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 -> "Highlight"
PdfAnnotationKind.INK -> "Annotation"
PdfAnnotationKind.TEXT -> "Text note"
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")
}
}

View file

@ -12,6 +12,7 @@ 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
@ -56,12 +57,16 @@ import com.aryan.reader.shared.SearchHighlightMode
import com.aryan.reader.shared.pdf.SharedPdfSearchResult
import com.aryan.reader.shared.ui.ReaderMinimalSlider
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.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?,
@ -71,9 +76,10 @@ internal fun DesktopPdfFullscreenBottomChrome(
onPageScrubFinished: () -> Unit,
onJumpBack: () -> Unit,
onJumpForward: () -> Unit,
onClearJumpHistory: () -> Unit
onClearJumpHistory: () -> Unit,
extraContent: @Composable ColumnScope.() -> Unit = {}
) {
val chromeBackground = MaterialTheme.colorScheme.surface
val chromeBackground = MaterialTheme.colorScheme.surfaceVariant
val chromeContent = MaterialTheme.colorScheme.onSurface
val sliderActive = MaterialTheme.colorScheme.primary
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
@ -89,6 +95,7 @@ internal fun DesktopPdfFullscreenBottomChrome(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.fillMaxWidth()) {
extraContent()
val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null
DesktopPdfJumpHistoryControls(
visible = showJumpHistory,
@ -106,15 +113,18 @@ internal fun DesktopPdfFullscreenBottomChrome(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val canGoPrevious = pageIndex > 0
val canGoNext = pageIndex < pageCount - 1
IconButton(onClick = onPrevious, enabled = canGoPrevious) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = "Previous page",
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,
@ -129,7 +139,101 @@ internal fun DesktopPdfFullscreenBottomChrome(
IconButton(onClick = onNext, enabled = canGoNext) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next page",
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 = MaterialTheme.colorScheme.surfaceVariant
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(6.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
shadowElevation = 1.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.fillMaxWidth()) {
extraContent()
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
) {
IconButton(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)
)
IconButton(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)
)
}
@ -166,7 +270,7 @@ internal fun DesktopPdfZoomPercentageIndicator(
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.ZoomOut,
contentDescription = "Reset zoom",
contentDescription = readerString("content_desc_reset_zoom", "Reset zoom"),
tint = Color.White,
modifier = Modifier
.size(20.dp)
@ -204,18 +308,18 @@ internal fun DesktopPdfSearchTopBar(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Close, contentDescription = "Close search")
Icon(Icons.Default.Close, contentDescription = readerString("content_desc_close_search", "Close search"))
}
SharedStableOutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search in PDF") },
placeholder = { Text(readerString("desktop_search_in_pdf", "Search in PDF")) },
singleLine = true,
modifier = Modifier.weight(1f).focusRequester(focusRequester),
trailingIcon = if (query.isNotEmpty()) {
{
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
Icon(Icons.Default.Close, contentDescription = readerString("tooltip_clear_search", "Clear search"))
}
}
} else {
@ -226,7 +330,11 @@ internal fun DesktopPdfSearchTopBar(
IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) {
Icon(
if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (showResultsPanel) "Hide search results" else "Show search results"
contentDescription = if (showResultsPanel) {
readerString("desktop_hide_search_results", "Hide search results")
} else {
readerString("desktop_show_search_results", "Show search results")
}
)
}
}
@ -270,7 +378,12 @@ internal fun BoxScope.DesktopPdfSearchOverlay(
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) {
Text(
"Indexing ${indexedPageCount.coerceAtMost(pageCount)}/$pageCount pages",
readerString(
"desktop_indexing_pages_format",
"Indexing %1\$d/%2\$d pages",
indexedPageCount.coerceAtMost(pageCount),
pageCount
),
style = MaterialTheme.typography.bodySmall
)
LinearProgressIndicator(
@ -284,14 +397,18 @@ internal fun BoxScope.DesktopPdfSearchOverlay(
when {
query.isBlank() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Type to search this PDF", color = MaterialTheme.colorScheme.onSurfaceVariant)
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) "No matches in indexed pages yet" else "No matches",
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
)
}
@ -300,8 +417,8 @@ internal fun BoxScope.DesktopPdfSearchOverlay(
else -> {
Text(
when {
isIndexing -> "${results.size} matches so far"
else -> "${results.size} matches"
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)
@ -325,7 +442,7 @@ internal fun BoxScope.DesktopPdfSearchOverlay(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
"Page ${result.pageIndex + 1}",
readerString("pdf_page_short", "Page %1\$d", result.pageIndex + 1),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@ -393,7 +510,7 @@ private fun DesktopPdfSearchNavigationPill(
IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) {
Icon(
if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = "Toggle search highlights",
contentDescription = readerString("content_desc_toggle_search_highlights", "Toggle search highlights"),
tint = if (highlightMode == SearchHighlightMode.ALL) {
MaterialTheme.colorScheme.primary
} else {
@ -402,20 +519,20 @@ private fun DesktopPdfSearchNavigationPill(
)
}
IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Previous search result")
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 {
"$resultCount matches"
readerString("desktop_matches_format", "%1\$d matches", resultCount)
},
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp)
)
IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next search result")
Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_search_result", "Next search result"))
}
}
}

View file

@ -0,0 +1,500 @@
package com.aryan.reader.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 com.aryan.reader.shared.SaveMode
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.SHARED_PDF_PAGE_BREAK_CHAR
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper
import com.aryan.reader.shared.pdf.SharedPdfRichPageLayout
import com.aryan.reader.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<SharedPdfAnnotation>,
richTextPageLayouts: List<SharedPdfRichPageLayout>
): 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<SharedPdfAnnotation>,
richTextPageLayouts: List<SharedPdfRichPageLayout>
): 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<SharedPdfAnnotation> = emptyList(),
richTextPageLayouts: List<SharedPdfRichPageLayout> = 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<SharedPdfAnnotation>,
richTextPageLayouts: List<SharedPdfRichPageLayout>,
pageSizes: List<DesktopPdfPageSize>
): List<DesktopPdfRasterOverlay> {
val overlays = mutableListOf<DesktopPdfRasterOverlay>()
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)

View file

@ -0,0 +1,550 @@
package com.aryan.reader.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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ZoomIn
import androidx.compose.material.icons.filled.ZoomOut
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.ScrollableTabRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.PdfDisplayMode
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAutoScrollState
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.ReaderExternalLookupAction
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.PdfSpreadLayout
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig
import com.aryan.reader.shared.pdf.currentSharedPdfTextStyleConfig
import com.aryan.reader.shared.pdf.updateCurrentSharedPdfTextStyle
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.ui.ReaderMinimalSlider
import com.aryan.reader.shared.ui.SharedPdfAnnotationToolDock
import com.aryan.reader.shared.ui.SharedPdfHighlighterPaletteEditor
import com.aryan.reader.shared.ui.SharedPdfTextAnnotationDock
import com.aryan.reader.shared.ui.SharedReaderThemeControls
import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar
import com.aryan.reader.shared.ui.readerString
import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
@Composable
internal fun DesktopPdfInspectorPanel(
document: DesktopPdfDocument,
pageIndex: Int,
displayMode: PdfDisplayMode,
pdfReaderSettings: ReaderSettings,
customTextureIds: List<String>,
onImportTexture: ((ReaderSettings) -> ReaderSettings?)?,
onReaderSettingsChange: (ReaderSettings) -> Unit,
zoomControlScale: Float,
zoomSpec: PdfZoomSpec,
isTextSelectionMode: Boolean,
selectedTool: PdfInkTool,
isRichTextMode: Boolean,
selectedColor: Int,
strokeWidth: Float,
pdfHighlighterColors: List<Int>,
pdfHighlighterPalette: SharedPdfHighlighterPalette,
isHighlighterSnapEnabled: Boolean,
effectiveTextStyleConfig: SharedPdfTextStyleConfig,
richTextController: SharedPdfRichTextController,
pdfExtrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsFeatureAvailable: Boolean,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
pageText: () -> String,
onDisplayModeSelected: (PdfDisplayMode) -> Unit,
onPageScrub: (Float) -> Unit,
onPageScrubFinished: () -> Unit,
onZoomOut: () -> Unit,
onZoomIn: () -> Unit,
onZoomChange: (Float) -> Unit,
onSelectPanMode: () -> Unit,
onTextSelectionModeToggle: () -> Unit,
onRichTextModeToggle: () -> Unit,
onToolSelected: (PdfInkTool) -> Unit,
onColorSelected: (Int) -> Unit,
onStrokeWidthChange: (Float) -> Unit,
onUndoPage: () -> Unit,
onClearPage: () -> Unit,
onHighlighterSnapChange: (Boolean) -> Unit,
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onOpenAiHub: (() -> Unit)? = null,
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
) {
var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VIEW) }
val viewInspectorListState = rememberLazyListState()
val markupInspectorListState = rememberLazyListState()
val assistInspectorListState = rememberLazyListState()
val pdfInspectorListState = when (selectedPdfInspectorTab) {
DesktopPdfInspectorTab.VIEW -> viewInspectorListState
DesktopPdfInspectorTab.MARKUP -> markupInspectorListState
DesktopPdfInspectorTab.ASSIST -> assistInspectorListState
}
Surface(
modifier = Modifier
.width(340.dp)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
) {
Column(modifier = Modifier.fillMaxSize()) {
DesktopPdfInspectorHeader(
selectedTab = selectedPdfInspectorTab,
onTabSelected = { selectedPdfInspectorTab = it }
)
HorizontalDivider()
DesktopPdfInspectorContent(
document = document,
pageIndex = pageIndex,
displayMode = displayMode,
pdfReaderSettings = pdfReaderSettings,
customTextureIds = customTextureIds,
onImportTexture = onImportTexture,
onReaderSettingsChange = onReaderSettingsChange,
zoomControlScale = zoomControlScale,
zoomSpec = zoomSpec,
isTextSelectionMode = isTextSelectionMode,
selectedTool = selectedTool,
isRichTextMode = isRichTextMode,
selectedColor = selectedColor,
strokeWidth = strokeWidth,
pdfHighlighterColors = pdfHighlighterColors,
pdfHighlighterPalette = pdfHighlighterPalette,
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
effectiveTextStyleConfig = effectiveTextStyleConfig,
richTextController = richTextController,
pdfExtrasState = pdfExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = externalLookupAvailable,
cloudTtsFeatureAvailable = cloudTtsFeatureAvailable,
ttsReplacementPreferences = ttsReplacementPreferences,
pageText = pageText,
selectedTab = selectedPdfInspectorTab,
listState = pdfInspectorListState,
onDisplayModeSelected = onDisplayModeSelected,
onPageScrub = onPageScrub,
onPageScrubFinished = onPageScrubFinished,
onZoomOut = onZoomOut,
onZoomIn = onZoomIn,
onZoomChange = onZoomChange,
onSelectPanMode = onSelectPanMode,
onTextSelectionModeToggle = onTextSelectionModeToggle,
onRichTextModeToggle = onRichTextModeToggle,
onToolSelected = onToolSelected,
onColorSelected = onColorSelected,
onStrokeWidthChange = onStrokeWidthChange,
onUndoPage = onUndoPage,
onClearPage = onClearPage,
onHighlighterSnapChange = onHighlighterSnapChange,
onHighlighterPaletteChange = onHighlighterPaletteChange,
onTextStyleChange = onTextStyleChange,
onExternalLookup = onExternalLookup,
onOpenAiHub = onOpenAiHub,
onCloudTtsStart = onCloudTtsStart,
onCloudTtsPauseResume = onCloudTtsPauseResume,
onCloudTtsStop = onCloudTtsStop,
onCloudTtsClearCache = onCloudTtsClearCache,
onAutoScrollChange = onAutoScrollChange,
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange
)
}
}
}
@Composable
private fun DesktopPdfInspectorHeader(
selectedTab: DesktopPdfInspectorTab,
onTabSelected: (DesktopPdfInspectorTab) -> Unit
) {
Column(
modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(readerString("desktop_pdf_tools", "PDF tools"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
ScrollableTabRow(
selectedTabIndex = selectedTab.ordinal,
edgePadding = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
DesktopPdfInspectorTab.values().forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { onTabSelected(tab) },
text = {
Text(
tab.localizedTitle(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
@Composable
private fun ColumnScope.DesktopPdfInspectorContent(
document: DesktopPdfDocument,
pageIndex: Int,
displayMode: PdfDisplayMode,
pdfReaderSettings: ReaderSettings,
customTextureIds: List<String>,
onImportTexture: ((ReaderSettings) -> ReaderSettings?)?,
onReaderSettingsChange: (ReaderSettings) -> Unit,
zoomControlScale: Float,
zoomSpec: PdfZoomSpec,
isTextSelectionMode: Boolean,
selectedTool: PdfInkTool,
isRichTextMode: Boolean,
selectedColor: Int,
strokeWidth: Float,
pdfHighlighterColors: List<Int>,
pdfHighlighterPalette: SharedPdfHighlighterPalette,
isHighlighterSnapEnabled: Boolean,
effectiveTextStyleConfig: SharedPdfTextStyleConfig,
richTextController: SharedPdfRichTextController,
pdfExtrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsFeatureAvailable: Boolean,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
pageText: () -> String,
selectedTab: DesktopPdfInspectorTab,
listState: LazyListState,
onDisplayModeSelected: (PdfDisplayMode) -> Unit,
onPageScrub: (Float) -> Unit,
onPageScrubFinished: () -> Unit,
onZoomOut: () -> Unit,
onZoomIn: () -> Unit,
onZoomChange: (Float) -> Unit,
onSelectPanMode: () -> Unit,
onTextSelectionModeToggle: () -> Unit,
onRichTextModeToggle: () -> Unit,
onToolSelected: (PdfInkTool) -> Unit,
onColorSelected: (Int) -> Unit,
onStrokeWidthChange: (Float) -> Unit,
onUndoPage: () -> Unit,
onClearPage: () -> Unit,
onHighlighterSnapChange: (Boolean) -> Unit,
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
onTextStyleChange: (SharedPdfTextStyleConfig) -> Unit,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onOpenAiHub: (() -> Unit)?,
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onAutoScrollChange: (ReaderAutoScrollState) -> 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.VIEW -> {
item {
DesktopPdfInspectorSection(readerString("label_reading", "Reading")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = displayMode == PdfDisplayMode.PAGINATION,
onClick = { onDisplayModeSelected(PdfDisplayMode.PAGINATION) },
label = { Text(readerString("desktop_page", "Page")) }
)
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)
)
}
)
}
}
}
}
item {
DesktopPdfInspectorSection(readerString("visual_options_progress_bar_position", "Position")) {
val pageRange = if (displayMode == PdfDisplayMode.PAGINATION) {
PdfSpreadLayout.pageRangeLabel(pageIndex, document.pageCount, pdfReaderSettings)
} else {
"${pageIndex + 1}"
}
Text(
if ('-' in pageRange) {
readerString("desktop_pdf_pages_of_count", "Pages %1\$s of %2\$d", pageRange, document.pageCount)
} else {
readerString("desktop_pdf_page_of_count", "Page %1\$s of %2\$d", pageRange, document.pageCount)
},
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (document.pageCount > 1) {
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = onPageScrub,
onValueChangeFinished = onPageScrubFinished,
valueRange = 0f..(document.pageCount - 1).toFloat()
)
}
}
}
item {
DesktopPdfInspectorSection(readerString("app_theme_appearance", "Appearance")) {
SharedReaderThemeControls(
settings = pdfReaderSettings,
builtInThemes = BuiltInPdfReaderThemes,
customTextureIds = customTextureIds,
onImportTexture = onImportTexture,
onSettingsChange = onReaderSettingsChange
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
readerString("visual_options_title", "Visual options"),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
DesktopPdfVisualOptionSwitch(
title = readerString("visual_options_remove_page_gap", "Remove gap between pages"),
description = readerString(
"desktop_remove_gap_between_pages_desc",
"Applies to vertical reading mode."
),
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)
)
}
)
}
}
item {
DesktopPdfInspectorSection(readerString("desktop_zoom", "Zoom")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onZoomOut) {
Icon(Icons.Default.ZoomOut, contentDescription = readerString("desktop_zoom_out", "Zoom out"))
}
Text(
"${(zoomControlScale * 100).toInt()}%",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
IconButton(onClick = onZoomIn) {
Icon(Icons.Default.ZoomIn, contentDescription = readerString("desktop_zoom_in", "Zoom in"))
}
}
Slider(
value = zoomControlScale,
onValueChange = onZoomChange,
valueRange = zoomSpec.min..zoomSpec.max
)
}
}
}
DesktopPdfInspectorTab.MARKUP -> {
item {
DesktopPdfInspectorSection(readerString("desktop_interaction", "Interaction")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = !isTextSelectionMode && selectedTool == PdfInkTool.NONE && !isRichTextMode,
onClick = onSelectPanMode,
label = { Text(readerString("desktop_pan", "Pan")) }
)
FilterChip(
selected = isTextSelectionMode,
onClick = onTextSelectionModeToggle,
label = { Text(readerString("desktop_select_text", "Select text")) }
)
FilterChip(
selected = isRichTextMode,
onClick = onRichTextModeToggle,
label = { Text(readerString("desktop_document_text", "Document text")) }
)
}
}
}
item {
DesktopPdfInspectorSection(readerString("desktop_annotation_tools", "Annotation tools")) {
SharedPdfAnnotationToolDock(
selectedTool = selectedTool,
selectedColor = selectedColor,
strokeWidth = strokeWidth,
tools = DesktopPdfAnnotationTools,
highlighterPalette = pdfHighlighterColors,
onToolSelected = onToolSelected,
onColorSelected = onColorSelected,
onStrokeWidthChange = onStrokeWidthChange,
onUndo = onUndoPage,
onClearPage = onClearPage,
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
onHighlighterSnapChange = onHighlighterSnapChange
)
}
}
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.ASSIST -> {
item {
DesktopPdfExtrasPanel(
pageText = pageText(),
extrasState = pdfExtrasState,
aiByokSettings = aiByokSettings,
externalLookupAvailable = externalLookupAvailable,
cloudTtsFeatureAvailable = cloudTtsFeatureAvailable,
onExternalLookup = onExternalLookup,
onOpenAiHub = onOpenAiHub,
onCloudTtsStart = onCloudTtsStart,
onCloudTtsPauseResume = onCloudTtsPauseResume,
onCloudTtsStop = onCloudTtsStop,
onCloudTtsClearCache = onCloudTtsClearCache,
onAutoScrollChange = onAutoScrollChange,
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.VIEW -> readerString("desktop_view", "View")
DesktopPdfInspectorTab.MARKUP -> readerString("desktop_markup", "Markup")
DesktopPdfInspectorTab.ASSIST -> readerString("desktop_assist", "Assist")
}
}

View file

@ -0,0 +1,79 @@
package com.aryan.reader.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
): 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 -> DesktopPdfKeyCommand.PREVIOUS_PAGE
key == Key.DirectionRight -> 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
): 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 -> DesktopPdfKeyCommand.PREVIOUS_PAGE
AwtKeyEvent.VK_RIGHT -> 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
}
}

View file

@ -11,6 +11,8 @@ 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
@ -21,6 +23,9 @@ 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.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -28,16 +33,27 @@ 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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
@ -47,7 +63,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.PdfTocEntry
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfBookmark
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
import com.aryan.reader.shared.ui.SharedReaderVerticalScrollbar
import com.aryan.reader.shared.ui.readerString
import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
@ -82,12 +107,12 @@ internal fun DesktopPdfJumpHistoryControls(
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Jump back",
contentDescription = readerString("content_desc_jump_back", "Jump back"),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text(
backPage?.let { "P. ${it + 1}" } ?: "",
backPage?.let { readerString("desktop_pdf_compact_page_number", "p. %1\$d", it + 1) } ?: "",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -99,11 +124,11 @@ internal fun DesktopPdfJumpHistoryControls(
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear jump history",
contentDescription = readerString("desktop_clear_jump_history", "Clear jump history"),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("Clear", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(readerString("action_clear", "Clear"), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
TextButton(
@ -112,14 +137,14 @@ internal fun DesktopPdfJumpHistoryControls(
modifier = Modifier.weight(1f)
) {
Text(
forwardPage?.let { "P. ${it + 1}" } ?: "",
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 = "Jump forward",
contentDescription = readerString("content_desc_jump_forward", "Jump forward"),
modifier = Modifier.size(18.dp)
)
}
@ -174,6 +199,437 @@ internal fun desktopVisiblePdfTocEntries(
return result
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DesktopPdfNavigationSidebar(
document: DesktopPdfDocument,
pageIndex: Int,
sortedAnnotations: List<SharedPdfAnnotation>,
sortedEmbeddedAnnotations: List<SharedPdfEmbeddedAnnotation>,
bookmarks: List<SharedPdfBookmark>,
selectedAnnotationId: String?,
selectedEmbeddedAnnotationId: String?,
onPageSelected: (Int) -> Unit,
onAnnotationOpened: (SharedPdfAnnotation) -> Unit,
onAnnotationSelected: (SharedPdfAnnotation) -> Unit,
onAnnotationDeleted: (SharedPdfAnnotation) -> Unit,
onEmbeddedAnnotationOpened: (SharedPdfEmbeddedAnnotation) -> Unit,
onEmbeddedAnnotationSelected: (SharedPdfEmbeddedAnnotation) -> Unit
) {
val documentHandleId = document.handleId
val tabs = listOf(
readerString("desktop_toc", "TOC"),
readerString("tab_annotations", "Annotations"),
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 (sortedAnnotations.isEmpty() && sortedEmbeddedAnnotations.isEmpty()) {
DesktopPdfNavigationEmpty(readerString("desktop_no_annotations_yet", "No annotations yet"))
} else {
val annotationsListState = rememberLazyListState()
var annotationMenuExpandedFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
var embeddedAnnotationMenuExpandedFor by remember { mutableStateOf<SharedPdfEmbeddedAnnotation?>(null) }
var deleteAnnotationConfirmFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = annotationsListState,
modifier = Modifier
.fillMaxSize()
.sharedAcceleratedLazyWheelScroll(annotationsListState)
.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(sortedAnnotations, key = { "nav_annotation_${it.id}" }) { annotation ->
Surface(
color = if (annotation.id == selectedAnnotationId) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.weight(1f)
.clickable { onAnnotationOpened(annotation) }
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
annotation.desktopLabel(),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
annotation.note?.takeIf { it.isNotBlank() }?.let { note ->
Text(
note,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Box {
IconButton(onClick = { annotationMenuExpandedFor = annotation }) {
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_annotation_options", "Annotation options"))
}
DropdownMenu(
expanded = annotationMenuExpandedFor == annotation,
onDismissRequest = { annotationMenuExpandedFor = null }
) {
DropdownMenuItem(
text = {
Text(
if (annotation.note.isNullOrBlank() &&
annotation.kind != PdfAnnotationKind.TEXT
) {
readerString("menu_add_note", "Add note")
} else {
readerString("action_edit", "Edit")
}
)
},
onClick = {
annotationMenuExpandedFor = null
onAnnotationSelected(annotation)
}
)
DropdownMenuItem(
text = { Text(readerString("action_delete", "Delete")) },
onClick = {
annotationMenuExpandedFor = null
deleteAnnotationConfirmFor = annotation
}
)
}
}
}
}
}
items(sortedEmbeddedAnnotations, key = { "nav_embedded_${it.id}" }) { annotation ->
Surface(
color = if (annotation.id == selectedEmbeddedAnnotationId) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.weight(1f)
.clickable { onEmbeddedAnnotationOpened(annotation) }
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
annotation.author.ifBlank { readerString("desktop_pdf_comment", "PDF comment") },
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
readerString("pdf_page_short", "Page %1\$d", annotation.pageIndex + 1),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
annotation.contents.takeIf { it.isNotBlank() }?.let { contents ->
Text(
contents,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Box {
IconButton(onClick = { embeddedAnnotationMenuExpandedFor = annotation }) {
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_comment_options", "Comment options"))
}
DropdownMenu(
expanded = embeddedAnnotationMenuExpandedFor == annotation,
onDismissRequest = { embeddedAnnotationMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text(readerString("desktop_open_comment", "Open comment")) },
onClick = {
embeddedAnnotationMenuExpandedFor = null
onEmbeddedAnnotationSelected(annotation)
}
)
}
}
}
}
}
}
SharedReaderVerticalScrollbar(
listState = annotationsListState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
deleteAnnotationConfirmFor?.let { annotation ->
AlertDialog(
onDismissRequest = { deleteAnnotationConfirmFor = null },
title = { Text(readerString("desktop_delete_annotation_title", "Delete annotation?")) },
text = { Text(readerString("desktop_delete_annotation_desc", "This removes the annotation from this PDF.")) },
confirmButton = {
TextButton(
onClick = {
deleteAnnotationConfirmFor = null
onAnnotationDeleted(annotation)
}
) {
Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteAnnotationConfirmFor = 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,
@ -205,7 +661,11 @@ internal fun DesktopPdfTocTreeItem(
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
contentDescription = if (isExpanded) {
readerString("content_desc_collapse", "Collapse")
} else {
readerString("content_desc_expand", "Expand")
},
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@ -219,7 +679,7 @@ internal fun DesktopPdfTocTreeItem(
modifier = Modifier.weight(1f)
)
Text(
"p. ${entry.pageIndex + 1}",
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)
@ -286,7 +746,7 @@ internal fun DesktopPdfThumbnailTile(
if (render != null) {
Image(
bitmap = render.image,
contentDescription = "Page ${pageIndex + 1}",
contentDescription = readerString("pdf_page_short", "Page %1\$d", pageIndex + 1),
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().padding(3.dp)
)
@ -314,7 +774,8 @@ internal fun DesktopPdfThumbnailTile(
@Composable
internal fun DesktopPdfPageScrubOverlay(
pageIndex: Int?,
pageCount: Int
pageCount: Int,
pageLabel: String? = pageIndex?.let { "Page ${it + 1} of $pageCount" }
) {
if (pageIndex == null || pageCount <= 0) return
Box(
@ -328,7 +789,12 @@ internal fun DesktopPdfPageScrubOverlay(
shadowElevation = 8.dp
) {
Text(
text = "Page ${pageIndex + 1} of $pageCount",
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)

View file

@ -0,0 +1,854 @@
package com.aryan.reader.desktop
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 com.aryan.reader.shared.ReaderTtsChunk
import com.aryan.reader.shared.SearchHighlightMode
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.PdfPageBounds
import com.aryan.reader.shared.pdf.PdfPagePoint
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
import com.aryan.reader.shared.pdf.SharedPdfSearchEngine
import com.aryan.reader.shared.pdf.SharedPdfSearchResult
import com.aryan.reader.shared.pdf.SharedPdfTextDraft
import com.aryan.reader.shared.pdf.sharedPdfTextStyle
import com.aryan.reader.shared.ui.SharedPdfAnnotationOverlay
import com.aryan.reader.shared.ui.SharedPdfEmbeddedAnnotationOverlay
import com.aryan.reader.shared.ui.SharedPdfInlineTextEditorOverlay
import com.aryan.reader.shared.ui.SharedPdfPageNumberOverlay
import com.aryan.reader.shared.ui.SharedPdfRichTextLayer
import com.aryan.reader.shared.ui.SharedPdfTextBoxEditorOverlay
import com.aryan.reader.shared.ui.readerString
import com.aryan.reader.shared.ui.sharedPdfEmbeddedHitTest
import com.aryan.reader.shared.ui.sharedPdfHitTest
import com.aryan.reader.shared.ui.toSharedPdfPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@Composable
internal fun DesktopVerticalPdfPage(
document: DesktopPdfDocument,
pageIndex: Int,
scale: Float,
zoomSpec: PdfZoomSpec,
annotations: List<SharedPdfAnnotation>,
searchResults: List<SharedPdfSearchResult>,
activeSearchIndex: Int,
searchHighlightMode: SearchHighlightMode,
activeTtsChunk: ReaderTtsChunk?,
searchQuery: String,
isTextSelectionMode: Boolean,
selectedAnnotationId: String?,
selectedEmbeddedAnnotationId: String?,
selectedTool: PdfInkTool,
selectedColor: Int,
highlighterPalette: List<Int>,
strokeWidth: Float,
isHighlighterSnapEnabled: Boolean,
activeTextDraft: SharedPdfTextDraft?,
richTextController: SharedPdfRichTextController,
isRichTextMode: Boolean,
readerAiFeaturesAvailable: Boolean,
cloudTtsAvailable: Boolean,
externalLookupAvailable: Boolean,
themeStyle: DesktopPdfThemeStyle,
shouldRender: Boolean,
zoomPreview: DesktopPdfZoomPreview?,
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<SharedPdfAnnotation>) -> Unit,
onTextAnnotationSelected: (SharedPdfAnnotation) -> Unit,
onTextDraftStarted: (Int, Offset, IntSize) -> Unit,
onTextDraftChanged: (String, IntSize) -> Unit,
onTextDraftBoundsChanged: (PdfPageBounds) -> Unit,
onPan: (Offset) -> Unit,
onPagePositioned: (Int, Offset) -> Unit
) {
val documentHandleId = document.handleId
val density = LocalDensity.current
var renderedPage by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfPageRender?>(null) }
var renderError by remember(documentHandleId, pageIndex) { mutableStateOf<String?>(null) }
var isRendering by remember(documentHandleId, pageIndex) { 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<Int?>(null) }
var selectionEndIndex by remember(documentHandleId, pageIndex) { mutableStateOf<Int?>(null) }
var selectionStartHit by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
var selectionEndHit by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfCharHit?>(null) }
var textSelection by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfTextSelection?>(null) }
var selectionMenuOffset by remember(documentHandleId, pageIndex) { mutableStateOf<Offset?>(null) }
var activeSelectionHandle by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfSelectionHandle?>(null) }
var activeStroke by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf<List<PdfPagePoint>>(emptyList()) }
var eraserPosition by remember(documentHandleId, pageIndex, selectedTool) { mutableStateOf<Offset?>(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) {
renderedPage = null
renderError = null
isRendering = false
clearInteractionState()
return@LaunchedEffect
}
val hasPageRender = renderedPage != null
if (!hasPageRender) {
isRendering = true
}
renderError = null
val pageSize = document.pageSizes.getOrNull(pageIndex)
if (pageSize == null) {
renderedPage = null
renderError = failedRenderMessage
isRendering = false
return@LaunchedEffect
}
delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L)
isRendering = true
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
val result = withContext(Dispatchers.IO) {
runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) }
}
result.getOrNull()?.let { renderedPage = it }
renderError = result.exceptionOrNull()?.message
?: if (renderedPage == null) failedRenderMessage else null
isRendering = false
}
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 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()
pageRootOffset = rootOffset
onPagePositioned(pageIndex, rootOffset)
}
.onSizeChanged { pageCanvasSize = it }
.desktopPdfDocumentZoomPreviewLayer(
preview = zoomPreview,
currentZoom = scale,
viewportRootOffset = zoomViewportRootOffset,
pageRootOffset = pageRootOffset
)
.background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp))
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) {
if (isRichTextMode) return@pointerInput
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val point = event.changes.firstOrNull()?.position ?: continue
if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) {
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) {
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) {
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) {
onSelectPage(pageIndex)
onEmbeddedAnnotationSelected(embeddedHit)
clearInteractionState()
event.changes.forEach { it.consume() }
} else if (
currentTextSelection != null &&
selectionMenuOffset == null
) {
clearSelection()
}
} 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, pageCanvasSize, isTextSelectionMode, isRichTextMode) {
if (isRichTextMode || !isTextSelectionMode) return@pointerInput
detectTapGestures(
onLongPress = { point ->
val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize)
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, selectedTool, isTextSelectionMode, isRichTextMode) {
if (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
) {
if (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
)
}
renderedPage != null -> {
val pageRender = renderedPage!!
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<PdfPageBounds> = 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<PdfPageBounds> = 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 {
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
)
}
}
}
}

View file

@ -0,0 +1,82 @@
package com.aryan.reader.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 com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.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"))
}
}
)
}

View file

@ -0,0 +1,95 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.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<page-break></page-break>\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<String> {
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
)
}

View file

@ -59,6 +59,7 @@ import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
import com.aryan.reader.shared.ui.readerString
import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
import kotlin.math.roundToInt
@ -203,7 +204,7 @@ internal fun PdfTextSelectionHandles(
start?.let { position ->
Icon(
imageVector = DesktopPdfSelectionMenuIcons.Teardrop,
contentDescription = "Selection start handle",
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)
@ -217,7 +218,7 @@ internal fun PdfTextSelectionHandles(
end?.let { position ->
Icon(
imageVector = DesktopPdfSelectionMenuIcons.Teardrop,
contentDescription = "Selection end handle",
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)
@ -297,11 +298,11 @@ internal fun PdfSelectionMenu(
mutableStateOf<Int?>(null)
}
val actions = buildList {
add(PdfSelectionMenuAction("Copy", DesktopPdfSelectionMenuIcons.Copy, onCopy))
if (showDefine) add(PdfSelectionMenuAction("Define", DesktopPdfSelectionMenuIcons.Dictionary, onDefine))
if (showSpeak) add(PdfSelectionMenuAction("Speak", Icons.AutoMirrored.Filled.VolumeUp, onSpeak))
if (showSearch) add(PdfSelectionMenuAction("Search", DesktopPdfSelectionMenuIcons.Search, onSearch))
add(PdfSelectionMenuAction("Clear", Icons.Default.Close, onClear, isDestructive = true))
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))
}
val estimatedHeight = PdfSelectionMenuPaletteHeightPx +
(((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx)
@ -439,7 +440,7 @@ internal fun PdfSelectionMenu(
val initialColor = Color(paletteColors[slot]).copy(alpha = 1f)
SharedHsvColorPickerDialog(
initialColor = initialColor,
title = "Highlight color ${slot + 1}",
title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1),
onDismiss = { editingHighlighterSlot = null },
onSave = { color ->
onHighlighterPaletteChange(

View file

@ -0,0 +1,179 @@
package com.aryan.reader.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import com.aryan.reader.shared.pdf.SharedPdfBookmark
import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer
import com.aryan.reader.shared.pdf.SharedPdfRichDocument
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
import com.aryan.reader.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<SharedPdfAnnotation>,
annotationsLoaded: Boolean,
onAnnotationsLoadedChange: (Boolean) -> Unit,
onAnnotationsLoaded: (List<SharedPdfAnnotation>) -> Unit,
onLocalSidecarsChanged: () -> Unit
) {
LaunchedEffect(documentHandleId) {
onAnnotationsLoadedChange(false)
val loadedAnnotations = if (annotationFile.exists()) {
withContext(Dispatchers.IO) {
SharedPdfAnnotationSerializer.decode(annotationFile.readText())
}
} else {
emptyList()
}
onAnnotationsLoaded(loadedAnnotations)
onAnnotationsLoadedChange(true)
}
LaunchedEffect(documentHandleId, annotations, annotationsLoaded) {
if (!annotationsLoaded) return@LaunchedEffect
withContext(Dispatchers.IO) {
runCatching {
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
}
}
onLocalSidecarsChanged()
}
}
@Composable
internal fun DesktopPdfBookmarkSidecarEffect(
documentHandleId: Long,
bookmarkFile: File,
bookmarks: List<SharedPdfBookmark>,
bookmarksLoaded: Boolean,
onBookmarksLoadedChange: (Boolean) -> Unit,
onBookmarksLoaded: (List<SharedPdfBookmark>) -> 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
withContext(Dispatchers.IO) {
runCatching {
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks))
}
}
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<SharedPdfSearchResult>) -> 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)
}
}

View file

@ -0,0 +1,648 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.pptx.SharedPptxCharBox as DesktopPptxCharBox
import com.aryan.reader.shared.pptx.SharedPptxDeck as DesktopPptxDeck
import com.aryan.reader.shared.pptx.SharedPptxDeckCache
import com.aryan.reader.shared.pptx.SharedPptxImageCrop as DesktopPptxImageCrop
import com.aryan.reader.shared.pptx.SharedPptxImageElement as DesktopPptxImageElement
import com.aryan.reader.shared.pptx.SharedPptxParagraph as DesktopPptxParagraph
import com.aryan.reader.shared.pptx.SharedPptxRect as DesktopPptxRect
import com.aryan.reader.shared.pptx.SharedPptxShapeElement as DesktopPptxShapeElement
import com.aryan.reader.shared.pptx.SharedPptxSlide as DesktopPptxSlide
import com.aryan.reader.shared.pptx.SharedPptxTableCell as DesktopPptxTableCell
import com.aryan.reader.shared.pptx.SharedPptxTableElement as DesktopPptxTableElement
import com.aryan.reader.shared.pptx.SharedPptxTextAlign as DesktopPptxTextAlign
import com.aryan.reader.shared.pptx.SharedPptxTextInsets as DesktopPptxTextInsets
import com.aryan.reader.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<DesktopPdfPageSize> = 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<DesktopPptxShapeElement>()
.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<DesktopPdfTextRect> {
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<DesktopPptxCharBox, DesktopPptxRect?>(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<DesktopPptxCharBox>
) {
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<DesktopPptxLaidOutLine> {
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<DesktopPptxLaidOutLine>()
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<String> {
if (rawLine.isBlank()) return listOf(rawLine)
if (measure(rawLine) <= maxWidth) return listOf(rawLine)
val lines = mutableListOf<String>()
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<String>,
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<LaidOutDesktopPptxTableCell> {
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<LaidOutDesktopPptxTableCell>()
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<DesktopPptxCharBox> {
val boxes = mutableListOf<DesktopPptxCharBox>()
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 <T> 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)

View file

@ -0,0 +1,150 @@
package com.aryan.reader.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.Cloud
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 com.aryan.reader.shared.UserData
import com.aryan.reader.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_pro_and_credits", "Pro and 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", "Account"), 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 {
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)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
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"))
}
}
}
statusMessage?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerLow,
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.Cloud, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(readerString("desktop_access", "Desktop access"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
}
Text(
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.")
},
style = MaterialTheme.typography.titleMedium
)
Text(readerString("desktop_credits_available_format", "%1\$d credits available", credits), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary)
HorizontalDivider()
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
)
}
}
Spacer(Modifier.height(12.dp))
}
}

View file

@ -9,7 +9,8 @@ internal data class DesktopReaderOpening(
val bookId: String,
val title: String,
val formatLabel: String,
val returnTab: SharedAppTab
val returnTab: SharedAppTab,
val password: String? = null
)
internal sealed interface DesktopReaderOpenResult {
@ -33,4 +34,10 @@ internal sealed interface DesktopReaderOpenResult {
override val book: BookItem,
val message: String
) : DesktopReaderOpenResult
data class PasswordRequired(
override val opening: DesktopReaderOpening,
override val book: BookItem,
val attemptedPassword: Boolean
) : DesktopReaderOpenResult
}

View file

@ -46,7 +46,6 @@ import androidx.compose.ui.zIndex
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAiFeature
import com.aryan.reader.shared.ReaderAiModelOption
import com.aryan.reader.shared.ReaderAiModelOptions
import com.aryan.reader.shared.ReaderAiResultState
@ -61,6 +60,7 @@ import com.aryan.reader.shared.ui.SharedMarkdownText
import com.aryan.reader.shared.ui.SharedReaderPopupLayer
import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
import com.aryan.reader.shared.ui.readerString
import com.aryan.reader.shared.ui.sharedReaderPopupWidth
@Composable
@ -120,7 +120,7 @@ internal fun DesktopReaderBottomSheet(
overflow = TextOverflow.Ellipsis
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
Icon(Icons.Default.Close, contentDescription = readerString("action_close", "Close"))
}
}
HorizontalDivider()
@ -149,9 +149,14 @@ internal fun DesktopReaderAiResultSheet(
) {
val errorMessage = result.errorMessage
when {
result.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant)
result.isLoading && result.text.isBlank() -> Text(readerString("desktop_working", "Working..."), color = MaterialTheme.colorScheme.onSurfaceVariant)
errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error)
else -> SharedMarkdownText(result.text)
else -> {
if (result.isLoading) {
Text(readerString("desktop_working", "Working..."), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SharedMarkdownText(result.text)
}
}
}
}
@ -169,7 +174,7 @@ internal fun DesktopAiByokSettingsDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("AI keys and models") },
title = { Text(readerString("ai_settings_title", "AI keys and models")) },
text = {
Column(
modifier = Modifier
@ -179,29 +184,35 @@ internal fun DesktopAiByokSettingsDialog(
) {
if (!secureStorageAvailable) {
Text(
"Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.",
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("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(readerString("ai_settings_saved_keys", "Saved keys"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
DesktopSavedAiKeyRow(
label = "Gemini",
label = readerString("provider_gemini", "Gemini"),
keyValue = sanitized.geminiKey,
onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) }
)
DesktopSavedAiKeyRow(
label = "Groq",
label = readerString("provider_groq", "Groq"),
keyValue = sanitized.groqKey,
onClear = { onSettingsChange(sanitized.copy(groqKey = "")) }
)
HorizontalDivider()
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
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 "Gemini", "groq" to "Groq").forEach { (provider, label) ->
listOf(
"gemini" to readerString("provider_gemini", "Gemini"),
"groq" to readerString("provider_groq", "Groq")
).forEach { (provider, label) ->
FilterChip(
selected = selectedProvider == provider,
onClick = { selectedProvider = provider },
@ -212,7 +223,7 @@ internal fun DesktopAiByokSettingsDialog(
SharedStableOutlinedTextField(
value = pendingKey,
onValueChange = { pendingKey = it },
label = { Text("API key") },
label = { Text(readerString("label_api_key", "API key")) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
@ -234,16 +245,19 @@ internal fun DesktopAiByokSettingsDialog(
},
modifier = Modifier.align(Alignment.End)
) {
Text("Save key")
Text(readerString("ai_settings_save_key", "Save key"))
}
HorizontalDivider()
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text("Show AI in reader", style = MaterialTheme.typography.titleMedium)
Text(readerString("options_show_ai_in_reader", "Show AI in reader"), style = MaterialTheme.typography.titleMedium)
Text(
"Matches the Android hide toggle for smart dictionary, summaries, and recaps.",
readerString(
"desktop_show_ai_in_reader_desc",
"Matches the Android hide toggle for smart dictionary, summaries, and recaps."
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -258,9 +272,9 @@ internal fun DesktopAiByokSettingsDialog(
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
Text(readerString("ai_settings_use_one_model", "Use one model for all features"), style = MaterialTheme.typography.titleMedium)
Text(
"Turn this off to choose separate models per reader AI feature.",
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
)
@ -273,40 +287,40 @@ internal fun DesktopAiByokSettingsDialog(
if (sanitized.useOneModel) {
DesktopAiModelSelector(
title = "All AI features",
description = "Smart dictionary, summaries, and recaps all use this model.",
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 = "Smart dictionary",
description = "Used when defining selected words or phrases.",
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 = "Summaries",
description = "Used for EPUB summaries and PDF page summaries.",
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 = "Recaps",
description = "Used for story recap generation.",
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 = "Cloud TTS",
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
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)) }
)
Text("Cloud TTS voice", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
ReaderCloudTtsVoices.chunked(3).forEach { rowVoices ->
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
@ -335,7 +349,7 @@ internal fun DesktopAiByokSettingsDialog(
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Done")
Text(readerString("action_done", "Done"))
}
}
)
@ -351,13 +365,13 @@ private fun DesktopSavedAiKeyRow(
Column(modifier = Modifier.weight(1f)) {
Text(label, fontWeight = FontWeight.SemiBold)
Text(
keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: "No key saved",
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("Clear")
Text(readerString("action_clear", "Clear"))
}
}
}
@ -377,7 +391,7 @@ private fun DesktopAiModelSelector(
FilterChip(
selected = selectedId.isBlank(),
onClick = { onSelected("") },
label = { Text("No model") }
label = { Text(readerString("ai_settings_no_model_selected", "No model selected")) }
)
options.forEach { option ->
FilterChip(
@ -393,13 +407,12 @@ private fun DesktopAiModelSelector(
@Composable
internal fun DesktopPdfExtrasPanel(
pageText: String,
recapText: String,
extrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsFeatureAvailable: Boolean,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onAiAction: (ReaderAiFeature, String) -> Unit,
onOpenAiHub: (() -> Unit)? = null,
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
@ -414,7 +427,7 @@ internal fun DesktopPdfExtrasPanel(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(readerString("desktop_extras", "Extras"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (externalLookupAvailable) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
ReaderExternalLookupAction.entries.forEach { action ->
@ -428,7 +441,7 @@ internal fun DesktopPdfExtrasPanel(
}
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Auto scroll", modifier = Modifier.weight(1f))
Text(readerString("menu_auto_scroll", "Auto Scroll"), modifier = Modifier.weight(1f))
Switch(
checked = autoScroll.enabled,
onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) }
@ -445,11 +458,12 @@ internal fun DesktopPdfExtrasPanel(
Column(modifier = Modifier.weight(1f)) {
Text(
when {
extrasState.cloudTts.isLoading -> "Preparing audio"
extrasState.cloudTts.isPaused -> "Paused"
extrasState.cloudTts.isPlaying -> "Reading"
settings.isCloudTtsAvailable -> "Cloud TTS ready"
else -> "Cloud TTS needs Gemini"
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
)
@ -472,13 +486,13 @@ internal fun DesktopPdfExtrasPanel(
}
}
) {
Text(if (ttsBusy) "Stop" else "Read")
Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read"))
}
}
if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
TextButton(onClick = onCloudTtsPauseResume) {
Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause")
Text(if (extrasState.cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause"))
}
}
}
@ -487,25 +501,25 @@ internal fun DesktopPdfExtrasPanel(
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) }
) {
Text("Page")
Text(readerString("desktop_page", "Page"))
}
TextButton(
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) }
) {
Text("From here")
Text(readerString("desktop_from_here", "From here"))
}
}
val cacheSummary = extrasState.cloudTts.cacheSummary
if (cacheSummary.hasCachedAudio) {
Text(
"Cache: ${cacheSummary.currentVoiceLabel}",
readerString("desktop_cache_format", "Cache: %1\$s", cacheSummary.currentVoiceLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (cacheSummary.hasCurrentVoiceCachedAudio) {
TextButton(onClick = onCloudTtsClearCache) {
Text("Clear voice cache")
Text(readerString("desktop_clear_voice_cache", "Clear voice cache"))
}
}
}
@ -515,19 +529,10 @@ internal fun DesktopPdfExtrasPanel(
bookId = ttsReplacementBookId,
onPreferencesChange = onTtsReplacementPreferencesChange
)
if (settings.areReaderAiFeaturesAvailable) {
if (settings.areReaderAiFeaturesAvailable && onOpenAiHub != null) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
TextButton(
enabled = pageText.isNotBlank() && !extrasState.aiResult.isLoading,
onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, pageText) }
) {
Text("Summarize page")
}
TextButton(
enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading,
onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) }
) {
Text("Recap")
TextButton(onClick = onOpenAiHub) {
Text(readerString("desktop_ai_hub", "AI hub"))
}
}
}

View file

@ -0,0 +1,501 @@
package com.aryan.reader.desktop
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.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.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 com.aryan.reader.paginatedreader.CssStyle
import com.aryan.reader.paginatedreader.SemanticImage
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.ReaderAction
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAiFeature
import com.aryan.reader.shared.ReaderAutoScrollState
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.ReaderExternalLookupAction
import com.aryan.reader.shared.ReaderHighlightPalette
import com.aryan.reader.shared.ReaderToolbarPreferences
import com.aryan.reader.shared.ReaderTtsChunk
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.reader.ReaderEngine
import com.aryan.reader.shared.reader.ReaderImageReference
import com.aryan.reader.shared.reader.ReaderLinkTarget
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.ReaderSessionState
import com.aryan.reader.shared.reader.ReaderViewportSpec
import com.aryan.reader.shared.reader.SharedEpubPaginationCache
import com.aryan.reader.shared.reader.SharedMeasuredEpubPaginator
import com.aryan.reader.shared.reader.layoutSignature
import com.aryan.reader.shared.reduce
import com.aryan.reader.shared.ui.DesktopEpubNativeImage
import com.aryan.reader.shared.ui.ReaderContentRenderPlan
import com.aryan.reader.shared.ui.SharedNativePaginatedReader
import com.aryan.reader.shared.ui.SharedNativeReaderSelectionAction
import com.aryan.reader.shared.ui.SharedReaderScreen
import kotlinx.coroutines.delay
import java.awt.event.KeyEvent as AwtKeyEvent
@Composable
internal fun DesktopReaderScreen(
session: ReaderSessionState,
readerEngine: ReaderEngine,
onSessionChange: (ReaderSessionState) -> Unit,
onReturnToLibrary: (() -> Unit)? = null,
onFullscreenChange: (Boolean) -> Unit = {},
toolbarPreferences: ReaderToolbarPreferences,
onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit,
highlightPalette: ReaderHighlightPalette,
onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?,
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
onPickCustomFont: () -> String?,
customFonts: List<CustomFontItem>,
readerExtrasState: ReaderExtrasState,
aiByokSettings: ReaderAiByokSettings,
externalLookupAvailable: Boolean,
cloudTtsControlsAvailable: Boolean,
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
onAiAction: (ReaderAiFeature, String) -> Unit,
onAiResultDismiss: () -> Unit,
onCloudTtsToggle: (String) -> Unit,
onCloudTtsStart: (ReaderTtsReadScope, List<ReaderTtsChunk>) -> Unit,
onCloudTtsPauseResume: () -> Unit,
onCloudTtsStop: () -> Unit,
onCloudTtsClearCache: () -> Unit,
onOpenAiHub: (() -> Unit)? = null,
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
onDownloadReaderImage: (ReaderImageReference) -> Unit,
readerTextureDataUri: (String) -> String?,
readerCustomTextureIds: List<String>,
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
)
}
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<DesktopEpubPaginationRequest?>(null)
}
var runningMeasuredPaginationRequest by remember(session.reader.book.id) {
mutableStateOf<DesktopEpubPaginationRequest?>(null)
}
val paginatedLayoutReady = session.reader.settings.readingMode != ReaderReadingMode.PAGINATED ||
(measuredPaginationRequest != null && completedMeasuredPaginationRequest == measuredPaginationRequest)
val latestSession by rememberUpdatedState(session)
val latestOnSessionChange by rememberUpdatedState(onSessionChange)
var externalLinkDialogUrl by remember { mutableStateOf<String?>(null) }
var lastHandledLink by remember { mutableStateOf<DesktopEpubHandledLink?>(null) }
var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) }
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 handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean {
val action = event.desktopReaderKeyNavigationOrNull(fullscreen = isFullscreen) ?: return false
val currentSession = latestSession
val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine)
if (nextSession == null) {
if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) {
setReaderFullscreen(false)
}
} else {
latestOnSessionChange(nextSession)
}
return true
}
DesktopReaderFullscreenKeyEffect(
enabled = isFullscreen && externalLinkDialogUrl == null,
onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) }
)
LaunchedEffect(session.reader.settings.readingMode) {
if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) {
completedMeasuredPaginationRequest = null
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
}
delay(280L)
val settings = latestSession.reader.settings
if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect
runningMeasuredPaginationRequest = request
try {
val reflowStartSession = latestSession
val reflowStartRequestId = reflowStartSession.navigationRequestId
val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession)
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 = measuredPaginator.paginate(
book = session.reader.book,
settings = settings,
viewport = request.viewport
)
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}"
)
if (layoutChanged) {
latestOnSessionChange(
readerEngine.replacePages(
state = latestSession,
pages = pages,
reflowAnchor = reflowAnchor,
navigationRequestIdAtReflowStart = reflowStartRequestId
)
)
}
if (pages.isNotEmpty()) {
completedMeasuredPaginationRequest = request
}
} finally {
if (runningMeasuredPaginationRequest == request) {
runningMeasuredPaginationRequest = null
}
}
}
val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String) -> Unit = { action, text ->
val settings = aiByokSettings.sanitized()
when (action) {
DesktopReaderSelectionAction.DEFINE -> {
if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text)
}
DesktopReaderSelectionAction.SPEAK -> {
if (settings.isCloudTtsAvailable) onCloudTtsToggle(text)
}
DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text)
}
}
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) -> Unit = { action, text ->
when (action) {
SharedNativeReaderSelectionAction.DEFINE ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text)
SharedNativeReaderSelectionAction.SPEAK ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text)
SharedNativeReaderSelectionAction.SEARCH ->
handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text)
}
}
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,
highlightPalette = highlightPalette,
onHighlightPaletteChange = onHighlightPaletteChange,
ttsReplacementPreferences = ttsReplacementPreferences,
ttsReplacementBookId = ttsReplacementBookId,
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
onPickCustomFont = onPickCustomFont,
customFonts = customFonts,
readerExtrasState = readerExtrasState,
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,
onOpenAiHub = onOpenAiHub,
onAutoScrollChange = onAutoScrollChange,
onDownloadReaderImage = onDownloadReaderImage,
readerImagePreviewContent = { image, previewModifier ->
DesktopEpubNativeImage(
image = image.toDesktopPreviewSemanticImage(),
modifier = previewModifier.clip(RoundedCornerShape(3.dp))
)
},
readerTextureDataUri = readerTextureDataUri,
readerCustomTextureIds = readerCustomTextureIds,
onImportReaderTexture = onImportReaderTexture,
bottomChromeExtraContent = bottomChromeExtraContent,
useDetachedChromeLayer = useDetachedChromeLayer,
useDetachedPanelLayer = useDetachedPanelLayer
) { renderPlan, onVisiblePageChanged, onHighlightSelected, onChromeActivity ->
Surface(
color = renderPlan.background,
shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp))
.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)}"
)
if (next != readerViewport) {
logEpubPagination(
"viewport_changed width=${next.widthPx} height=${next.heightPx} " +
"previous=${readerViewport.widthPx}x${readerViewport.heightPx}"
)
readerViewport = next
}
}
) {
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) {
DesktopEpubPaginationPreparing(
active = runningMeasuredPaginationRequest != null,
modifier = Modifier.fillMaxSize()
)
} else {
when (renderPlan) {
is ReaderContentRenderPlan.WebDocument -> {
if (webViewRuntimeState.initialized) {
DesktopEpubWebView(
html = renderPlan.html,
appearanceScript = renderPlan.appearanceScript,
navigationTarget = renderPlan.navigationTarget,
highlights = renderPlan.highlights,
onHighlightCreated = { highlight ->
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
},
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 = handleDesktopSelectionAction,
onLinkClicked = handleDesktopEpubLinkClicked,
onVisiblePageChanged = onVisiblePageChanged,
onPointerActivity = onChromeActivity,
networkAccessEnabled = webViewNetworkAccessEnabled,
modifier = Modifier.fillMaxSize()
)
} else {
DesktopWebViewRuntimeIndicator(
state = webViewRuntimeState,
modifier = Modifier.fillMaxSize()
)
}
}
is ReaderContentRenderPlan.NativePaginatedPages -> {
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,
onHighlightCreated = { highlight ->
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
},
onHighlightSelected = onHighlightSelected,
onLinkClicked = { link ->
handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick())
},
imageContent = { image, imageModifier ->
DesktopEpubNativeImage(
image = image,
modifier = imageModifier
)
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
}
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
}
}

View file

@ -4,6 +4,8 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import com.aryan.reader.shared.ReaderTexture
import com.aryan.reader.shared.ReaderTextureFilePrefix
import com.aryan.reader.shared.ReaderTextureImportExtensions
import com.aryan.reader.shared.readerTextureMimeTypeForExtension
import java.io.ByteArrayInputStream
import java.io.File
import java.util.Base64
@ -14,11 +16,10 @@ internal object DesktopReaderTextures {
private val bytesCache = mutableMapOf<String, ByteArray?>()
private val dataUriCache = mutableMapOf<String, String?>()
private val imageCache = mutableMapOf<String, ImageBitmap?>()
private val importExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp")
fun importedTextureIds(): List<String> {
return readerTextureDirectory()
.listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in importExtensions }
.listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in ReaderTextureImportExtensions }
?.sortedBy { it.name.lowercase(Locale.ROOT) }
?.map { ReaderTextureFilePrefix + it.absolutePath }
.orEmpty()
@ -27,7 +28,7 @@ internal object DesktopReaderTextures {
fun importTexture(source: File): String? {
if (!source.isFile) return null
val extension = source.extension.lowercase(Locale.ROOT)
.takeIf { it in importExtensions }
.takeIf { it in ReaderTextureImportExtensions }
?: return null
val safeName = source.nameWithoutExtension
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
@ -49,7 +50,7 @@ internal object DesktopReaderTextures {
return dataUriCache.getOrPut(textureId) {
val bytes = bytesFor(textureId) ?: return@getOrPut null
val extension = textureExtension(textureId)
"data:${imageMimeTypeForExtension(extension)};base64," +
"data:${readerTextureMimeTypeForExtension(extension)};base64," +
Base64.getEncoder().encodeToString(bytes)
}
}
@ -95,13 +96,3 @@ internal object DesktopReaderTextures {
return File(desktopUserDataRoot(), "reader_textures")
}
}
private fun imageMimeTypeForExtension(extension: String): String {
return when (extension.lowercase(Locale.ROOT)) {
"jpg", "jpeg" -> "image/jpeg"
"webp" -> "image/webp"
"gif" -> "image/gif"
"bmp" -> "image/bmp"
else -> "image/png"
}
}

View file

@ -2,6 +2,8 @@ package com.aryan.reader.desktop
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.platform.Font as DesktopFont
import com.aryan.reader.shared.AppFontPreference
import com.aryan.reader.shared.AppFontPreferenceKind
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.ReaderSettings
@ -38,5 +40,21 @@ internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean
}
internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? {
return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()
val file = File(path).takeIf { it.isFile } ?: return null
return runCatching { FontFamily(DesktopFont(file)) }.getOrNull()
}
internal fun AppFontPreference.toDesktopAppFontFamily(customFonts: List<CustomFontItem>): 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()
}
}
}

View file

@ -0,0 +1,157 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.ReaderCloudTtsState
import com.aryan.reader.shared.ReaderExtrasState
import com.aryan.reader.shared.RecapResult
import com.aryan.reader.shared.SummarizationResult
import com.aryan.reader.shared.reader.ReaderSessionState
import kotlinx.coroutines.Job
internal data class DesktopReaderWindowState(
val id: String,
val opening: DesktopReaderOpening,
val content: DesktopReaderWindowContent = DesktopReaderWindowContent.Opening,
val focusRequestId: Long = 0L,
val fullscreen: Boolean = false
) {
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 showCloudTtsSettings: Boolean = false,
val ttsJob: Job? = null
) : DesktopReaderWindowContent
}
internal data class DesktopReaderWindowOpenDecision(
val windows: List<DesktopReaderWindowState>,
val shouldStartOpen: Boolean
)
internal fun List<DesktopReaderWindowState>.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<DesktopReaderWindowState>.focusDesktopReaderWindow(bookId: String): List<DesktopReaderWindowState> {
return map { window ->
if (window.bookId == bookId) {
window.copy(focusRequestId = window.focusRequestId + 1)
} else {
window
}
}
}
internal fun List<DesktopReaderWindowState>.withDesktopReaderWindowContent(
requestId: Long,
content: DesktopReaderWindowContent
): List<DesktopReaderWindowState> {
return map { window ->
if (window.opening.requestId == requestId) {
window.copy(content = content)
} else {
window
}
}
}
internal fun List<DesktopReaderWindowState>.withoutDesktopReaderWindow(windowId: String): List<DesktopReaderWindowState> {
return filterNot { it.id == windowId }
}
internal fun List<DesktopReaderWindowState>.withoutDesktopReaderBookIds(
bookIds: Set<String>
): List<DesktopReaderWindowState> {
return filterNot { it.bookId in bookIds }
}
internal fun List<DesktopReaderWindowState>.replaceDesktopTextReaderContent(
windowId: String,
transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text
): List<DesktopReaderWindowState> {
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<DesktopReaderWindowState>.replaceAllDesktopTextReaderContent(
transform: (DesktopReaderWindowContent.Text) -> DesktopReaderWindowContent.Text
): List<DesktopReaderWindowState> {
return map { window ->
val content = window.content
if (content is DesktopReaderWindowContent.Text) {
window.copy(content = transform(content))
} else {
window
}
}
}

View file

@ -0,0 +1,250 @@
package com.aryan.reader.desktop
import com.aryan.reader.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<String, String>,
private val plurals: Map<String, Map<String, String>>,
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<String, String>()) { merged, path ->
merged + loadResourceMap(classLoader, path)
}
val fallbackPlurals = loadPluralMap(classLoader, "$DesktopAndroidStringsRoot/values/plurals.xml")
val localizedPlurals = desktopAndroidPluralResourcePaths(locale)
.asReversed()
.fold(emptyMap<String, Map<String, String>>()) { merged, path ->
merged + loadPluralMap(classLoader, path)
}
return DesktopAndroidStringResources(
strings = fallback + localized,
plurals = fallbackPlurals + localizedPlurals,
locale = locale
)
}
private fun loadResourceMap(classLoader: ClassLoader, path: String): Map<String, String> {
val stream = classLoader.getResourceAsStream(path) ?: return emptyMap()
return stream.use(::parseAndroidStringXml)
}
private fun loadPluralMap(classLoader: ClassLoader, path: String): Map<String, Map<String, String>> {
val stream = classLoader.getResourceAsStream(path) ?: return emptyMap()
return stream.use(::parseAndroidPluralXml)
}
}
}
internal fun desktopAndroidStringResourcePaths(locale: Locale): List<String> {
return desktopAndroidResourcePaths(locale, "strings.xml")
}
internal fun desktopAndroidPluralResourcePaths(locale: Locale): List<String> {
return desktopAndroidResourcePaths(locale, "plurals.xml")
}
private fun desktopAndroidResourcePaths(locale: Locale, fileName: String): List<String> {
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<String, String> {
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<String, String>()
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<String, Map<String, String>> {
val factory = secureAndroidXmlDocumentBuilderFactory()
val document = factory.newDocumentBuilder().parse(stream)
val nodes = document.getElementsByTagName("plurals")
val plurals = linkedMapOf<String, Map<String, String>>()
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<String, String>()
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>): 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"

View file

@ -0,0 +1,88 @@
package com.aryan.reader.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<DesktopCachedSummaryItem> {
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)
}

View file

@ -1,5 +1,6 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.SharedFeaturePolicy
import java.io.File
import java.nio.file.Files
@ -18,6 +19,8 @@ class DesktopBuildProfileTest {
assertEquals("Standard edition", profile.buildLabel)
assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy)
assertTrue(profile.featurePolicy.networkAccess)
assertFalse(profile.featurePolicy.byokAi)
assertFalse(profile.byokAiAvailable)
}
@Test
@ -30,6 +33,8 @@ class DesktopBuildProfileTest {
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
assertFalse(profile.featurePolicy.networkAccess)
assertFalse(profile.featurePolicy.aiAndCloud)
assertTrue(profile.featurePolicy.byokAi)
assertFalse(profile.byokAiAvailable)
assertFalse(profile.featurePolicy.opdsCatalogs)
assertFalse(profile.featurePolicy.googleFontsDownload)
}
@ -43,6 +48,31 @@ class DesktopBuildProfileTest {
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(
networkAccess = true,
aiAndCloud = true,
byokAi = true
)
assertTrue(
DesktopBuildProfile(
flavor = "oss-online",
appName = "Episteme oss",
buildLabel = "OSS edition",
featurePolicy = onlineOssPolicy
).byokAiAvailable
)
assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy))
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures)
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures)
}
@Test
fun `desktop diagnostics are disabled unless explicitly enabled`() {
assertFalse(desktopDiagnosticsFlag(null))

View file

@ -0,0 +1,130 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.HighlightColor
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.UserHighlight
import com.aryan.reader.shared.reader.ReaderBookmark
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
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)
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 `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)
}
}

View file

@ -0,0 +1,138 @@
package com.aryan.reader.desktop
import androidx.compose.ui.text.AnnotatedString
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.PdfPageBounds
import com.aryan.reader.shared.pdf.PdfPagePoint
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.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())
}
}

View file

@ -0,0 +1,91 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.SharedLibraryStateProjector
import com.aryan.reader.shared.SharedReaderScreenState
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(listOf(reflow.id), projected.openTabIds)
assertEquals(reflow.id, projected.activeTabBookId)
}
}

View file

@ -0,0 +1,154 @@
package com.aryan.reader.desktop
import com.aryan.reader.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",
"""
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
</Types>
""".trimIndent()
)
zip.writeEntry(
"ppt/presentation.xml",
"""
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldSz cx="9144000" cy="6858000"/>
<p:sldIdLst>
<p:sldId id="256" r:id="rId1"/>
</p:sldIdLst>
</p:presentation>
""".trimIndent()
)
zip.writeEntry(
"ppt/_rels/presentation.xml.rels",
"""
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
Target="slides/slide1.xml"/>
</Relationships>
""".trimIndent()
)
zip.writeEntry(
"ppt/slides/slide1.xml",
"""
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:cSld>
<p:bg>
<p:bgPr>
<a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill>
</p:bgPr>
</p:bg>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Title"/>
<p:cNvSpPr/>
<p:nvPr><a:hlinkClick r:id="rId2"/></p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm><a:off x="914400" y="914400"/><a:ext cx="3657600" cy="914400"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
<a:solidFill><a:srgbClr val="EAF2FF"/></a:solidFill>
<a:ln><a:solidFill><a:srgbClr val="4472C4"/></a:solidFill></a:ln>
</p:spPr>
<p:txBody>
<a:bodyPr/>
<a:lstStyle/>
<a:p>
<a:pPr algn="l"/>
<a:r>
<a:rPr sz="2400"><a:solidFill><a:srgbClr val="1F1F1F"/></a:solidFill></a:rPr>
<a:t>Hello PPTX</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>
""".trimIndent()
)
zip.writeEntry(
"ppt/slides/_rels/slide1.xml.rels",
"""
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId2"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
Target="https://example.com/slides"
TargetMode="External"/>
</Relationships>
""".trimIndent()
)
}
}
private fun ZipOutputStream.writeEntry(name: String, contents: String) {
putNextEntry(ZipEntry(name))
write(contents.toByteArray(Charsets.UTF_8))
closeEntry()
}
}

View file

@ -0,0 +1,71 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ui.SharedAppTab
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopReaderWindowStateTest {
@Test
fun `opening a new reader creates a window`() {
val opening = readerOpening("book-1", requestId = 1)
val decision = emptyList<DesktopReaderWindowState>().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<DesktopReaderWindowState>()
.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<DesktopReaderWindowState>()
.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)
}
private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening {
return DesktopReaderOpening(
requestId = requestId,
bookId = bookId,
title = "Book $bookId",
formatLabel = FileType.EPUB.name,
returnTab = SharedAppTab.LIBRARY
)
}
}

View file

@ -0,0 +1,136 @@
package com.aryan.reader.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 = """
<resources>
<string name="line">One\nTwo</string>
<string name="quote">Don\'t stop</string>
</resources>
""".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 = """
<resources>
<plurals name="book_count">
<item quantity="one">%1${'$'}d book</item>
<item quantity="other">%1${'$'}d books</item>
</plurals>
<plurals name="quoted_count">
<item quantity="one">Don\'t skip %1${'$'}d file</item>
<item quantity="other">Don\'t skip %1${'$'}d files</item>
</plurals>
</resources>
""".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 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 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()
}
}
}

View file

@ -0,0 +1,48 @@
package com.aryan.reader.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))
}
}