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:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -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())
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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("&")
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
'"' -> append(""")
|
||||
'\'' -> append("'")
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() } }
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue