Windows ga (#358)
* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app * Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app * Persist reader window state and improve slider interaction in desktop app * Improve PDF page transitions and refine focus management in desktop app * Refactor scrollbar interaction and adjust desktop modal focus handling * Improve PDF sidecar synchronization and cross-platform metadata compatibility * Refactor PDF annotation comment logic to shared module and implement Desktop UI * Refactor reader screen to use tap-to-toggle and full-width styling in desktop app * Refactor reader workspace layout and chrome-panel interactions * Implement global search keyboard shortcuts and focusable chrome layers * Implement flavor-specific legal links and update the About UI * Refactor reader UI controls on desktop app * Enhance desktop folder sync with background metadata extraction and improved error handling * Refactor Library UI and remove redundant Home tab in desktop app * Add custom tooltips to reader icon buttons in desktop app * Integrate app theme controls into reader interfaces on desktop * Add right-to-left pagination support and improve focus restoration on desktop app * Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app * Update desktop reader defaults and implement settings migration * Implement block-based position tracking in ReaderLocator * Enhance EPUB highlighting reliability in desktop app * Add support for custom reader themes and update highlight palette logic in desktop app * Replace the Tools panel with a "More" dropdown menu and refactor account UI * Implement account profile header in desktop sidebar * Implement cloud sync reliability improvements and sidebar toggle on desktop app * Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app * Integrate WebView2 for EPUB vertical rendering on Windows * Refactor reader layout logic and enhance WebView2 diagnostics * Improve vertical reading layout and WebView2 resizing on Desktop * Refine vertical reading mode layout and margin handling * Enhance reader locator precision and Desktop mode-switching reliability * Implement chapter-level caching and warm-start pagination in desktop app * Replace bundled KCEF with native system webviews via SWT * Refactor EPUB page info bar visibility and layout logic * Improve PDF toolbar persistence and fix tab reactivation logic * Enable multi-selection and bulk operations for custom fonts * Refactor instrumentation tests * Add EPUB UI test fixture and initial instrumentation tests * Expand EpubReader UI tests and improve accessibility * Add instrumentation tests and test tags for library and reader screens * Enhance OPDS parser logic and catalog integration * Add support for toggling local synchronization on a per-folder basis. * Implement tri-state sizing for the TTS overlay * Persist TTS overlay size across sessions * Refactor reader brightness control and add incremental step buttons * Improve CSS support, pagination control, and style-aware semantic caching * Improve link handling, interaction, and diagnostics in the paginated reader * crash fixes * Implement persistent pending removal for external files * Implement book-specific word replacements * Add native vertical reading mode with custom renderer * Implement text selection and navigation improvements for the native vertical reader * Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub * Implement lazy loading and chapter prefetching for native vertical reader * Improve window lifecycle and disposal handling on Desktop * Optimize vertical reading performance in desktop app * Enhance TTS start accuracy and diagnostic logging on desktop * Refactor AI settings visibility on desktop * Improve pagination height measurement and enhance cutoff diagnostics * Implement lifecycle management and improve justified text splitting for pagination * Refine AI usage tracking and force AI feature visibility on Desktop * Add descriptive context comments and usage examples to string and plural resources. * Optimize performance and memory usage in search and state mapping * Replace reader page sliders with minimal slider and navigation controls * Add support for CBT comic archives * Harden file path validation and XML parsing to prevent security vulnerabilities * Implement local account profile caching and optimize desktop performance * Improve desktop persistence reliability and add Linux secure storage support * Improved PDF zoom stability and layout prediction during zoom commits * Improved PDF spread layout prediction, reader focus restoration, and account profile caching * Enhance highlight precision and scoping using block-local offsets and CFIs * Enhance cloud book content synchronization and background downloads * Implement granular timestamp tracking for reading positions and PDF annotations * Restrict diagnostic logging and stack traces to debug builds * Refine PDF page gaps and reader chrome interaction logic * Refactor PDF highlight rendering and overhaul Desktop sidebar UI * Implement a new interaction dock and undo/redo history for PDF annotations in desktop * Enhance PDF color picker and improve navigation scroll restoration * Add highlight palette customization and improve selection menu UI in desktop app epub reader * Enhance desktop shelf management and library organization
This commit is contained in:
parent
5971eaa571
commit
83dcafa4b6
444 changed files with 47279 additions and 8096 deletions
|
|
@ -8,18 +8,49 @@ import kotlinx.serialization.json.JsonObject
|
|||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.util.Properties
|
||||
|
||||
internal data class DesktopAccountProfile(
|
||||
val isProUser: Boolean = false,
|
||||
val credits: Int = 0
|
||||
val credits: Int = 0,
|
||||
val fetchedAtEpochMillis: Long = 0L
|
||||
)
|
||||
|
||||
// Credits and Pro status are server-owned, so startup only trusts a recent snapshot.
|
||||
internal const val DesktopAccountProfileCacheTtlMillis: Long = 30L * 60L * 1000L
|
||||
|
||||
internal fun DesktopAccountProfile.isFresh(
|
||||
nowEpochMillis: Long = System.currentTimeMillis(),
|
||||
ttlMillis: Long = DesktopAccountProfileCacheTtlMillis
|
||||
): Boolean {
|
||||
if (fetchedAtEpochMillis <= 0L || ttlMillis <= 0L) return false
|
||||
val ageMillis = nowEpochMillis - fetchedAtEpochMillis
|
||||
return ageMillis in 0L..ttlMillis
|
||||
}
|
||||
|
||||
internal class DesktopAccountProfileRepository(
|
||||
private val config: DesktopCloudConfig
|
||||
private val config: DesktopCloudConfig,
|
||||
private val store: DesktopAccountProfileStore = DesktopAccountProfileStore()
|
||||
) {
|
||||
fun cachedProfile(
|
||||
uid: String,
|
||||
nowEpochMillis: Long = System.currentTimeMillis()
|
||||
): DesktopAccountProfile? {
|
||||
return store.load(uid)?.takeIf { profile -> profile.isFresh(nowEpochMillis) }
|
||||
}
|
||||
|
||||
fun saveFetchedProfile(uid: String, profile: DesktopAccountProfile) {
|
||||
store.save(uid, profile)
|
||||
}
|
||||
|
||||
fun clearCachedProfiles() {
|
||||
store.clear()
|
||||
}
|
||||
|
||||
suspend fun fetchProfile(uid: String, idToken: String): DesktopAccountProfile = withContext(Dispatchers.IO) {
|
||||
if (uid.isBlank() || idToken.isBlank()) return@withContext DesktopAccountProfile()
|
||||
val url = "https://firestore.googleapis.com/v1/projects/${urlEncode(config.firebaseProjectId)}/databases/(default)/documents/users/${urlEncode(uid)}"
|
||||
|
|
@ -31,23 +62,60 @@ internal class DesktopAccountProfileRepository(
|
|||
readTimeout = 20_000
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) return@withContext DesktopAccountProfile()
|
||||
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
return@withContext DesktopAccountProfile(fetchedAtEpochMillis = System.currentTimeMillis())
|
||||
}
|
||||
val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IllegalStateException("Could not check account status: HTTP ${connection.responseCode}")
|
||||
}
|
||||
val fields = DesktopAccountJson.parseToJsonElement(text).jsonObject["fields"].jsonObjectOrNull()
|
||||
DesktopAccountProfile(
|
||||
val profile = DesktopAccountProfile(
|
||||
isProUser = fields?.booleanField("isPro") == true,
|
||||
credits = fields?.numberField("credits")?.toInt() ?: 0
|
||||
credits = fields?.numberField("credits")?.toInt() ?: 0,
|
||||
fetchedAtEpochMillis = System.currentTimeMillis()
|
||||
)
|
||||
profile
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class DesktopAccountProfileStore(
|
||||
private val settingsFile: File = File(desktopUserConfigRoot(), "account_profile.properties")
|
||||
) {
|
||||
fun load(uid: String): DesktopAccountProfile? {
|
||||
if (uid.isBlank() || !settingsFile.isFile) return null
|
||||
val properties = Properties()
|
||||
return runCatching {
|
||||
settingsFile.inputStream().use(properties::load)
|
||||
if (properties.getProperty("uid", "") != uid) return null
|
||||
DesktopAccountProfile(
|
||||
isProUser = properties.getProperty("isProUser", "false").toBooleanStrictOrNull() ?: false,
|
||||
credits = properties.getProperty("credits", "0").toIntOrNull() ?: 0,
|
||||
fetchedAtEpochMillis = properties.getProperty("fetchedAtEpochMillis", "0").toLongOrNull() ?: 0L
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun save(uid: String, profile: DesktopAccountProfile) {
|
||||
if (uid.isBlank()) return
|
||||
val properties = Properties().apply {
|
||||
setProperty("uid", uid)
|
||||
setProperty("isProUser", profile.isProUser.toString())
|
||||
setProperty("credits", profile.credits.toString())
|
||||
setProperty("fetchedAtEpochMillis", profile.fetchedAtEpochMillis.toString())
|
||||
}
|
||||
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account profile")
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
settingsFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private val DesktopAccountJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun JsonObject?.booleanField(key: String): Boolean? {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.sun.jna.win32.StdCallLibrary
|
|||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.util.Properties
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val WINDOWS_CRED_TYPE_GENERIC = 1
|
||||
private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2
|
||||
|
|
@ -60,7 +61,7 @@ internal class DesktopAiByokStore(
|
|||
loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID)
|
||||
} else {
|
||||
loadedSettings
|
||||
}
|
||||
}.toDesktopPersistableAiSettings()
|
||||
if (secureStorageAvailable &&
|
||||
(legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings)
|
||||
) {
|
||||
|
|
@ -82,7 +83,7 @@ internal class DesktopAiByokStore(
|
|||
}
|
||||
|
||||
fun save(settings: ReaderAiByokSettings) {
|
||||
val sanitized = settings.sanitized()
|
||||
val sanitized = settings.toDesktopPersistableAiSettings()
|
||||
logDesktopTts(
|
||||
"settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
|
||||
"secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " +
|
||||
|
|
@ -101,9 +102,7 @@ internal class DesktopAiByokStore(
|
|||
setProperty("ttsSpeakerId", sanitized.ttsSpeakerId)
|
||||
}
|
||||
settingsFile.parentFile?.mkdirs()
|
||||
settingsFile.outputStream().use { output ->
|
||||
properties.store(output, "Episteme desktop AI keys and models")
|
||||
}
|
||||
settingsFile.storePropertiesAtomically(properties, "Episteme desktop AI keys and models")
|
||||
logDesktopTts(
|
||||
"settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " +
|
||||
"groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}"
|
||||
|
|
@ -173,10 +172,10 @@ internal interface DesktopSecretCodec {
|
|||
companion object {
|
||||
fun platform(): DesktopSecretCodec {
|
||||
val osName = System.getProperty("os.name").orEmpty()
|
||||
val codec = if (osName.startsWith("Windows", ignoreCase = true)) {
|
||||
WindowsSecretCodec
|
||||
} else {
|
||||
UnavailableDesktopSecretCodec
|
||||
val codec = when {
|
||||
osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec
|
||||
osName.contains("Linux", ignoreCase = true) -> LinuxSecretToolCodec()
|
||||
else -> UnavailableDesktopSecretCodec
|
||||
}
|
||||
logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}")
|
||||
return codec
|
||||
|
|
@ -193,6 +192,160 @@ private object UnavailableDesktopSecretCodec : DesktopSecretCodec {
|
|||
override fun unprotect(value: String): String = ""
|
||||
}
|
||||
|
||||
internal data class DesktopSecretCommandResult(
|
||||
val exitCode: Int,
|
||||
val stdout: String,
|
||||
val stderr: String
|
||||
) {
|
||||
val isSuccess: Boolean get() = exitCode == 0
|
||||
val errorSummary: String
|
||||
get() = stderr.ifBlank { stdout }.desktopTtsPreview(240).ifBlank { "exit code $exitCode" }
|
||||
}
|
||||
|
||||
internal interface DesktopSecretCommandRunner {
|
||||
fun isExecutableAvailable(command: String): Boolean
|
||||
fun run(command: List<String>, input: String? = null, timeoutMillis: Long = 5_000L): DesktopSecretCommandResult
|
||||
}
|
||||
|
||||
private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner {
|
||||
override fun isExecutableAvailable(command: String): Boolean {
|
||||
val path = System.getenv("PATH").orEmpty()
|
||||
return path.split(File.pathSeparator)
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.any { directory ->
|
||||
File(directory, command).let { it.isFile && it.canExecute() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun run(command: List<String>, input: String?, timeoutMillis: Long): DesktopSecretCommandResult {
|
||||
require(command.isNotEmpty()) { "Secret command cannot be empty." }
|
||||
val process = ProcessBuilder(command).start()
|
||||
input?.let { value ->
|
||||
process.outputStream.use { output ->
|
||||
output.write(value.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
} ?: process.outputStream.close()
|
||||
|
||||
val completed = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)
|
||||
if (!completed) {
|
||||
process.destroyForcibly()
|
||||
throw IllegalStateException("Timed out waiting for ${command.first()} secure storage command.")
|
||||
}
|
||||
return DesktopSecretCommandResult(
|
||||
exitCode = process.exitValue(),
|
||||
stdout = process.inputStream.readBytes().toString(Charsets.UTF_8),
|
||||
stderr = process.errorStream.readBytes().toString(Charsets.UTF_8)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class LinuxSecretToolCodec(
|
||||
private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner
|
||||
) : DesktopSecretCodec {
|
||||
override val name: String = "linux-secret-tool"
|
||||
|
||||
override val isAvailable: Boolean by lazy {
|
||||
val available = commandRunner.isExecutableAvailable(SecretToolCommand) &&
|
||||
runCatching {
|
||||
commandRunner.run(listOf(SecretToolCommand, "--help"), timeoutMillis = 3_000L).isSuccess
|
||||
}.getOrDefault(false)
|
||||
logDesktopTts("settings_linux_secret_tool_available available=$available")
|
||||
available
|
||||
}
|
||||
|
||||
override fun protect(value: String): String {
|
||||
return protect("secret", value)
|
||||
}
|
||||
|
||||
override fun unprotect(value: String): String {
|
||||
return unprotect("secret", value)
|
||||
}
|
||||
|
||||
override fun protect(keyName: String, value: String): String {
|
||||
if (!isAvailable) {
|
||||
throw IllegalStateException(
|
||||
"Linux Secret Service is unavailable. Install libsecret-tools and make sure a desktop keyring is running."
|
||||
)
|
||||
}
|
||||
val key = linuxSecretKey(keyName)
|
||||
logDesktopTts("settings_linux_secret_tool_write_start key=$keyName valueChars=${value.length}")
|
||||
val result = commandRunner.run(
|
||||
command = listOf(
|
||||
SecretToolCommand,
|
||||
"store",
|
||||
"--label",
|
||||
"Episteme $keyName",
|
||||
SecretToolApplicationAttribute,
|
||||
SecretToolApplicationValue,
|
||||
SecretToolKeyAttribute,
|
||||
key
|
||||
),
|
||||
input = value,
|
||||
timeoutMillis = 15_000L
|
||||
)
|
||||
logDesktopTts("settings_linux_secret_tool_write_result key=$keyName exit=${result.exitCode}")
|
||||
if (!result.isSuccess) {
|
||||
throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}")
|
||||
}
|
||||
return Prefix + key
|
||||
}
|
||||
|
||||
override fun unprotect(keyName: String, value: String): String {
|
||||
if (!isAvailable) return ""
|
||||
val key = value.removePrefix(Prefix).takeIf { value.startsWith(Prefix) } ?: linuxSecretKey(keyName)
|
||||
logDesktopTts("settings_linux_secret_tool_read_start key=$keyName")
|
||||
val result = commandRunner.run(
|
||||
command = listOf(
|
||||
SecretToolCommand,
|
||||
"lookup",
|
||||
SecretToolApplicationAttribute,
|
||||
SecretToolApplicationValue,
|
||||
SecretToolKeyAttribute,
|
||||
key
|
||||
),
|
||||
timeoutMillis = 8_000L
|
||||
)
|
||||
logDesktopTts("settings_linux_secret_tool_read_result key=$keyName exit=${result.exitCode} chars=${result.stdout.length}")
|
||||
if (!result.isSuccess) {
|
||||
throw IllegalStateException("Linux Secret Service read failed: ${result.errorSummary}")
|
||||
}
|
||||
return result.stdout.trimEnd('\r', '\n')
|
||||
}
|
||||
|
||||
override fun delete(keyName: String) {
|
||||
val key = linuxSecretKey(keyName)
|
||||
runCatching {
|
||||
commandRunner.run(
|
||||
command = listOf(
|
||||
SecretToolCommand,
|
||||
"clear",
|
||||
SecretToolApplicationAttribute,
|
||||
SecretToolApplicationValue,
|
||||
SecretToolKeyAttribute,
|
||||
key
|
||||
),
|
||||
timeoutMillis = 8_000L
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logDesktopTts("settings_linux_secret_tool_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"")
|
||||
}
|
||||
}
|
||||
|
||||
private fun linuxSecretKey(keyName: String): String {
|
||||
return "Episteme.Reader.$keyName"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val Prefix = "secret-tool:"
|
||||
const val SecretToolCommand = "secret-tool"
|
||||
const val SecretToolApplicationAttribute = "application"
|
||||
const val SecretToolApplicationValue = "Episteme.Reader"
|
||||
const val SecretToolKeyAttribute = "key"
|
||||
}
|
||||
}
|
||||
|
||||
private object WindowsSecretCodec : DesktopSecretCodec {
|
||||
override val name: String = "windows"
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,7 @@ 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
|
||||
|
|
@ -42,13 +38,8 @@ 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
|
||||
|
||||
|
|
@ -345,169 +336,3 @@ private fun DesktopSummaryCachePanel(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import androidx.compose.ui.window.application
|
|||
import androidx.compose.ui.window.rememberWindowState
|
||||
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
|
||||
|
|
@ -58,6 +57,8 @@ import java.util.concurrent.atomic.AtomicReference
|
|||
|
||||
internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300)
|
||||
|
||||
private val DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis = longArrayOf(160L, 200L)
|
||||
|
||||
internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) {
|
||||
configureComposeSwingInterop()
|
||||
application {
|
||||
|
|
@ -190,35 +191,69 @@ internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
|
|||
internal const val ComposeInteropBlendingEnabled = "true"
|
||||
private const val DesktopWindowStatePersistDebounceMillis = 450L
|
||||
|
||||
internal fun configureComposeSwingInterop() {
|
||||
// Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed
|
||||
// JCEF WebView, and current Compose interop can leave a stale black native rectangle after
|
||||
// that reader surface is removed unless interop blending is enabled.
|
||||
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
|
||||
System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled)
|
||||
internal fun composeInteropBlendingDefault(
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): String? {
|
||||
return if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
|
||||
null
|
||||
} else {
|
||||
ComposeInteropBlendingEnabled
|
||||
}
|
||||
}
|
||||
|
||||
internal fun configureComposeSwingInterop(
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
) {
|
||||
// Must run before Compose creates the desktop window. Vertical EPUB embeds native SWT/AWT
|
||||
// browser surfaces; the blending path can prevent those native children from painting.
|
||||
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
|
||||
composeInteropBlendingDefault(platform)?.let { defaultValue ->
|
||||
System.setProperty(ComposeInteropBlendingProperty, defaultValue)
|
||||
}
|
||||
}
|
||||
logDesktopWebView2(
|
||||
"compose_interop platform=${platform.os} blending=${System.getProperty(ComposeInteropBlendingProperty).orEmpty().ifBlank { "default" }}"
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopWindowStatePersistenceEffect(
|
||||
internal fun DesktopWindowStatePersistenceEffect(
|
||||
windowState: WindowState,
|
||||
store: DesktopWindowStateStore,
|
||||
enabled: Boolean
|
||||
enabled: Boolean,
|
||||
transformSnapshot: (DesktopWindowStateSnapshot) -> DesktopWindowStateSnapshot? = { it },
|
||||
onSnapshotSaved: (DesktopWindowStateSnapshot) -> Unit = {}
|
||||
) {
|
||||
val persistenceEnabled by rememberUpdatedState(enabled)
|
||||
val latestTransformSnapshot by rememberUpdatedState(transformSnapshot)
|
||||
val latestOnSnapshotSaved by rememberUpdatedState(onSnapshotSaved)
|
||||
LaunchedEffect(windowState, store) {
|
||||
snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { snapshot ->
|
||||
if (!persistenceEnabled || snapshot == null) return@collectLatest
|
||||
val persistableSnapshot = latestTransformSnapshot(snapshot) ?: return@collectLatest
|
||||
delay(DesktopWindowStatePersistDebounceMillis)
|
||||
if (persistenceEnabled) {
|
||||
withContext(Dispatchers.IO) {
|
||||
store.save(snapshot)
|
||||
store.save(persistableSnapshot)
|
||||
}
|
||||
latestOnSnapshotSaved(persistableSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(windowState, store) {
|
||||
onDispose {
|
||||
if (persistenceEnabled) {
|
||||
DesktopWindowStateSnapshot.fromWindowState(windowState)
|
||||
?.let(latestTransformSnapshot)
|
||||
?.let { snapshot ->
|
||||
runCatching { store.save(snapshot) }
|
||||
latestOnSnapshotSaved(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -259,6 +294,14 @@ internal fun DesktopReaderFullscreenEffect(
|
|||
}
|
||||
awtWindow.refreshDesktopReaderWindowFocus()
|
||||
}
|
||||
if (!enabled) {
|
||||
for (delayMillis in DesktopReaderWindowFullscreenExitFocusRetryDelaysMillis) {
|
||||
delay(delayMillis)
|
||||
EventQueue.invokeLater {
|
||||
awtWindow.refreshDesktopReaderWindowFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(awtWindow) {
|
||||
|
|
@ -444,16 +487,43 @@ private fun java.awt.Window.refreshDesktopReaderWindowFocus() {
|
|||
internal fun DesktopReaderFullscreenKeyEffect(
|
||||
enabled: Boolean,
|
||||
onKeyPressed: (AwtKeyEvent) -> Boolean
|
||||
) {
|
||||
DesktopReaderKeyDispatcherEffect(
|
||||
enabled = enabled,
|
||||
allowChromeModalWindows = false,
|
||||
onKeyPressed = onKeyPressed
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopReaderKeyDispatcherEffect(
|
||||
enabled: Boolean,
|
||||
allowChromeModalWindows: Boolean = false,
|
||||
allowPanelModalWindows: Boolean = false,
|
||||
dispatchWhenOwnerWindowActive: Boolean = true,
|
||||
onKeyPressed: (AwtKeyEvent) -> Boolean
|
||||
) {
|
||||
val currentOnKeyPressed by rememberUpdatedState(onKeyPressed)
|
||||
DisposableEffect(enabled) {
|
||||
DisposableEffect(
|
||||
enabled,
|
||||
allowChromeModalWindows,
|
||||
allowPanelModalWindows,
|
||||
dispatchWhenOwnerWindowActive
|
||||
) {
|
||||
if (!enabled) {
|
||||
onDispose {}
|
||||
} else {
|
||||
val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
val dispatcher = java.awt.KeyEventDispatcher { event ->
|
||||
val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true
|
||||
!modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
|
||||
val keyWindow = focusManager.focusedWindow ?: focusManager.activeWindow
|
||||
val activeReaderModalKind = keyWindow?.desktopReaderModalWindowKind()
|
||||
val activeWindowAllowed = desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||
activeReaderModalKind = activeReaderModalKind,
|
||||
allowChromeModalWindows = allowChromeModalWindows,
|
||||
allowPanelModalWindows = allowPanelModalWindows,
|
||||
dispatchWhenOwnerWindowActive = dispatchWhenOwnerWindowActive
|
||||
)
|
||||
activeWindowAllowed && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
|
||||
}
|
||||
focusManager.addKeyEventDispatcher(dispatcher)
|
||||
onDispose {
|
||||
|
|
@ -463,18 +533,62 @@ internal fun DesktopReaderFullscreenKeyEffect(
|
|||
}
|
||||
}
|
||||
|
||||
private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean {
|
||||
internal enum class DesktopReaderModalWindowKind {
|
||||
CHROME,
|
||||
PANEL,
|
||||
POPUP
|
||||
}
|
||||
|
||||
internal fun desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||
activeReaderModalKind: DesktopReaderModalWindowKind?,
|
||||
allowChromeModalWindows: Boolean,
|
||||
allowPanelModalWindows: Boolean,
|
||||
dispatchWhenOwnerWindowActive: Boolean
|
||||
): Boolean {
|
||||
return when (activeReaderModalKind) {
|
||||
null -> dispatchWhenOwnerWindowActive
|
||||
DesktopReaderModalWindowKind.CHROME -> allowChromeModalWindows
|
||||
DesktopReaderModalWindowKind.PANEL -> allowPanelModalWindows
|
||||
DesktopReaderModalWindowKind.POPUP -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.awt.Window.desktopReaderModalWindowKind(): DesktopReaderModalWindowKind? {
|
||||
val windowTitle = when (this) {
|
||||
is java.awt.Dialog -> title
|
||||
is Frame -> title
|
||||
else -> ""
|
||||
}
|
||||
return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true ||
|
||||
windowTitle.startsWith("Reader Panel") ||
|
||||
windowTitle.startsWith("Reader Popup")
|
||||
return desktopReaderModalWindowKind(
|
||||
windowName = name.orEmpty(),
|
||||
windowTitle = windowTitle
|
||||
)
|
||||
}
|
||||
|
||||
private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
|
||||
internal fun desktopReaderModalWindowKind(
|
||||
windowName: String,
|
||||
windowTitle: String
|
||||
): DesktopReaderModalWindowKind? {
|
||||
return when {
|
||||
windowName == "${DesktopReaderModalWindowNamePrefix}ChromeTop" ||
|
||||
windowName == "${DesktopReaderModalWindowNamePrefix}ChromeBottom" ||
|
||||
windowTitle.startsWith("Reader Chrome") -> DesktopReaderModalWindowKind.CHROME
|
||||
|
||||
windowName == "${DesktopReaderModalWindowNamePrefix}Panel" ||
|
||||
windowName == "${DesktopReaderModalWindowNamePrefix}PanelLeft" ||
|
||||
windowName == "${DesktopReaderModalWindowNamePrefix}PanelRight" ||
|
||||
windowTitle.startsWith("Reader Panel") ||
|
||||
windowTitle.startsWith("Reader Navigation") ||
|
||||
windowTitle.startsWith("Reader Tools") -> DesktopReaderModalWindowKind.PANEL
|
||||
|
||||
windowName.startsWith(DesktopReaderModalWindowNamePrefix) ||
|
||||
windowTitle.startsWith("Reader Popup") -> DesktopReaderModalWindowKind.POPUP
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
|
||||
|
||||
internal data class DesktopWebViewRuntimeState(
|
||||
val initialized: Boolean = false,
|
||||
|
|
@ -483,15 +597,43 @@ internal data class DesktopWebViewRuntimeState(
|
|||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean {
|
||||
return readerSurface == ReaderFeatureSurface.TEXT_READER
|
||||
internal enum class DesktopEpubWebViewBackend(
|
||||
val logName: String,
|
||||
val displayName: String
|
||||
) {
|
||||
WINDOWS_WEBVIEW2("webview2", "Microsoft Edge WebView2"),
|
||||
WEBKIT("webkit", "WebKit"),
|
||||
UNSUPPORTED("unsupported", "native webview")
|
||||
}
|
||||
|
||||
internal fun shouldStartDesktopWebViewRuntime(
|
||||
requested: Boolean,
|
||||
state: DesktopWebViewRuntimeState
|
||||
internal fun desktopEpubWebViewBackend(
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): DesktopEpubWebViewBackend {
|
||||
return when (platform.os) {
|
||||
DesktopOperatingSystem.WINDOWS -> DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
|
||||
DesktopOperatingSystem.LINUX,
|
||||
DesktopOperatingSystem.MACOS -> DesktopEpubWebViewBackend.WEBKIT
|
||||
DesktopOperatingSystem.OTHER -> DesktopEpubWebViewBackend.UNSUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopEpubWebViewUsesNativeSwtBrowser(
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): Boolean {
|
||||
return requested && !state.initialized && !state.restartRequired && state.errorMessage == null
|
||||
return desktopEpubWebViewBackend(platform) != DesktopEpubWebViewBackend.UNSUPPORTED
|
||||
}
|
||||
|
||||
internal fun desktopEpubWebViewUsesWebView2(
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): Boolean {
|
||||
return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
|
||||
}
|
||||
|
||||
internal fun desktopEpubWebViewCanRender(
|
||||
state: DesktopWebViewRuntimeState,
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): Boolean {
|
||||
return desktopEpubWebViewUsesNativeSwtBrowser(platform)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -499,7 +641,10 @@ internal fun DesktopWebViewRuntimeIndicator(
|
|||
state: DesktopWebViewRuntimeState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val platform = currentDesktopPlatform()
|
||||
val message = when {
|
||||
!desktopEpubWebViewUsesNativeSwtBrowser(platform) ->
|
||||
readerString("desktop_webview_unsupported", "Embedded webview is unavailable on this desktop platform.")
|
||||
state.errorMessage != null -> readerString("desktop_webview_start_error", "Embedded webview could not start: %1\$s", state.errorMessage)
|
||||
state.restartRequired -> readerString("desktop_webview_restart_required", "Embedded webview installed. Restart Episteme to finish setup.")
|
||||
state.downloadProgress >= 0f -> readerString("desktop_webview_preparing_progress", "Preparing bundled embedded webview %1\$d%%", state.downloadProgress.toInt())
|
||||
|
|
@ -515,7 +660,9 @@ internal fun DesktopWebViewRuntimeIndicator(
|
|||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (state.errorMessage == null && !state.restartRequired) {
|
||||
CircularProgressIndicator()
|
||||
if (desktopEpubWebViewUsesNativeSwtBrowser(platform)) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ 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.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
import com.aryan.reader.shared.ui.SharedAppTab
|
||||
|
||||
internal val DesktopInitialAppTab = SharedAppTab.LIBRARY
|
||||
|
||||
internal fun desktopEmptyReaderBook(): SharedEpubBook {
|
||||
val noBookOpen = loadDesktopStringResolver().string("desktop_no_book_open", "No book open")
|
||||
|
|
@ -27,11 +31,37 @@ internal fun desktopEmptyReaderBook(): SharedEpubBook {
|
|||
}
|
||||
|
||||
internal fun SharedLibrarySnapshot.withDesktopDefaults(): SharedLibrarySnapshot {
|
||||
return if (appSeedColor == null) {
|
||||
copy(appSeedColor = DesktopDefaultAppSeedColor)
|
||||
val shouldMigrateReaderDefaults = desktopReaderDefaultsVersion < DesktopReaderDefaultsVersion
|
||||
val migratedTextDefaults = if (shouldMigrateReaderDefaults && readerDefaultSettings == ReaderSettings()) {
|
||||
DesktopDefaultTextReaderSettings
|
||||
} else {
|
||||
this
|
||||
readerDefaultSettings
|
||||
}
|
||||
val migratedPdfDefaults = if (shouldMigrateReaderDefaults && pdfReaderDefaultSettings == ReaderSettings(themeId = "no_theme")) {
|
||||
DesktopDefaultPdfReaderSettings
|
||||
} else {
|
||||
pdfReaderDefaultSettings
|
||||
}
|
||||
val migratedBooks = if (shouldMigrateReaderDefaults) {
|
||||
books.map { book ->
|
||||
when {
|
||||
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.TEXT) &&
|
||||
book.readerSettings == ReaderSettings() -> book.copy(readerSettings = migratedTextDefaults)
|
||||
book.usesDesktopReaderSettingsEngine(DesktopReaderSettingsEngine.PDF) &&
|
||||
book.readerSettings == ReaderSettings(themeId = "no_theme") -> book.copy(readerSettings = migratedPdfDefaults)
|
||||
else -> book
|
||||
}
|
||||
}
|
||||
} else {
|
||||
books
|
||||
}
|
||||
return copy(
|
||||
books = migratedBooks,
|
||||
appSeedColor = appSeedColor ?: DesktopDefaultAppSeedColor,
|
||||
readerDefaultSettings = migratedTextDefaults,
|
||||
pdfReaderDefaultSettings = migratedPdfDefaults,
|
||||
desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion
|
||||
)
|
||||
}
|
||||
|
||||
internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScreenState {
|
||||
|
|
@ -54,6 +84,7 @@ internal fun SharedLibrarySnapshot.toDesktopReaderScreenState(): SharedReaderScr
|
|||
appSeedColor = appSeedColor,
|
||||
appFontPreference = appFontPreference,
|
||||
customAppThemes = customAppThemes,
|
||||
customReaderThemes = customReaderThemes,
|
||||
readerDefaultSettings = readerDefaultSettings,
|
||||
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
|
||||
readerToolbarPreferences = readerToolbarPreferences,
|
||||
|
|
@ -116,8 +147,10 @@ internal fun SharedReaderScreenState.toDesktopLibrarySnapshot(
|
|||
appSeedColor = appSeedColor,
|
||||
appFontPreference = appFontPreference,
|
||||
customAppThemes = customAppThemes,
|
||||
customReaderThemes = customReaderThemes,
|
||||
readerDefaultSettings = readerDefaultSettings,
|
||||
pdfReaderDefaultSettings = pdfReaderDefaultSettings,
|
||||
desktopReaderDefaultsVersion = DesktopReaderDefaultsVersion,
|
||||
readerToolbarPreferences = readerToolbarPreferences,
|
||||
readerHighlightPalette = readerHighlightPalette,
|
||||
pdfHighlighterPalette = pdfHighlighterPalette,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.Properties
|
||||
|
||||
internal fun File.writeTextAtomically(text: String) {
|
||||
parentFile?.mkdirs()
|
||||
val temp = createSiblingTempFile()
|
||||
try {
|
||||
temp.writeText(text)
|
||||
moveReplacing(temp, this)
|
||||
} finally {
|
||||
runCatching { if (temp.exists()) temp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun File.storePropertiesAtomically(properties: Properties, comments: String) {
|
||||
parentFile?.mkdirs()
|
||||
val temp = createSiblingTempFile()
|
||||
try {
|
||||
temp.outputStream().use { output ->
|
||||
properties.store(output, comments)
|
||||
}
|
||||
moveReplacing(temp, this)
|
||||
} finally {
|
||||
runCatching { if (temp.exists()) temp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.createSiblingTempFile(): File {
|
||||
val directory = parentFile ?: File(".")
|
||||
directory.mkdirs()
|
||||
val prefix = ".$name."
|
||||
return Files.createTempFile(directory.toPath(), prefix, ".tmp").toFile()
|
||||
}
|
||||
|
||||
private fun moveReplacing(source: File, target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
try {
|
||||
Files.move(
|
||||
source.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(
|
||||
source.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ package com.aryan.reader.desktop
|
|||
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import java.io.File
|
||||
import com.aryan.reader.shared.SharedLegalLinks
|
||||
import com.aryan.reader.shared.SharedLegalProfile
|
||||
import com.aryan.reader.shared.sharedLegalLinksForProfile
|
||||
|
||||
internal const val DesktopFlavorProperty = "episteme.desktop.flavor"
|
||||
internal const val DesktopVersionProperty = "episteme.desktop.version"
|
||||
|
|
@ -16,10 +18,21 @@ internal data class DesktopBuildProfile(
|
|||
val flavor: String,
|
||||
val appName: String,
|
||||
val buildLabel: String,
|
||||
val featurePolicy: SharedFeaturePolicy
|
||||
val featurePolicy: SharedFeaturePolicy,
|
||||
val legalProfile: SharedLegalProfile = if (featurePolicy.byokAi) {
|
||||
SharedLegalProfile.OSS
|
||||
} else {
|
||||
SharedLegalProfile.STANDARD
|
||||
}
|
||||
) {
|
||||
val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline
|
||||
val aiKeySettingsAvailable: Boolean
|
||||
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && legalProfile != SharedLegalProfile.OSS
|
||||
val byokAiAvailable: Boolean get() = featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess
|
||||
val creditBackedCloudTtsControlsAvailable: Boolean
|
||||
get() = featurePolicy.aiAndCloud && featurePolicy.networkAccess && !byokAiAvailable
|
||||
val legalLinks: SharedLegalLinks
|
||||
get() = sharedLegalLinksForProfile(legalProfile)
|
||||
}
|
||||
|
||||
internal fun currentDesktopBuildProfile(): DesktopBuildProfile {
|
||||
|
|
@ -35,13 +48,15 @@ internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfi
|
|||
flavor = DesktopFlavorOssOffline,
|
||||
appName = EpistemeDesktopOssAppName,
|
||||
buildLabel = "Offline OSS edition",
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline,
|
||||
legalProfile = SharedLegalProfile.OSS
|
||||
)
|
||||
else -> DesktopBuildProfile(
|
||||
flavor = DesktopFlavorStandard,
|
||||
appName = EpistemeDesktopStandardAppName,
|
||||
buildLabel = "Standard edition",
|
||||
featurePolicy = SharedFeaturePolicy.Standard
|
||||
featurePolicy = SharedFeaturePolicy.Standard,
|
||||
legalProfile = SharedLegalProfile.STANDARD
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,48 +74,12 @@ internal fun ReaderAiByokSettings.withDesktopFeaturePolicy(
|
|||
featurePolicy: SharedFeaturePolicy
|
||||
): ReaderAiByokSettings {
|
||||
return if (featurePolicy.byokAi && featurePolicy.aiAndCloud && featurePolicy.networkAccess) {
|
||||
sanitized()
|
||||
toDesktopPersistableAiSettings()
|
||||
} else {
|
||||
ReaderAiByokSettings(hideReaderAiFeatures = true)
|
||||
ReaderAiByokSettings()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bundledDesktopWebViewDir(): File {
|
||||
val platform = currentDesktopPlatform()
|
||||
val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
return listOfNotNull(
|
||||
resourceDir?.resolve("kcef-bundle"),
|
||||
File(System.getProperty("user.dir"), "kcef-bundle"),
|
||||
File(System.getProperty("user.dir"), "desktopApp/${platform.kcefBundleDirectoryName}"),
|
||||
File(System.getProperty("user.dir"), "desktopApp/kcef-bundle"),
|
||||
File("desktopApp/${platform.kcefBundleDirectoryName}"),
|
||||
File("desktopApp/kcef-bundle"),
|
||||
File(platform.kcefBundleDirectoryName),
|
||||
File("kcef-bundle")
|
||||
).firstOrNull(::isBundledDesktopWebViewPresent)
|
||||
?: resourceDir?.resolve("kcef-bundle")
|
||||
?: File(platform.kcefBundleDirectoryName)
|
||||
}
|
||||
|
||||
internal fun isBundledDesktopWebViewPresent(
|
||||
dir: File,
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): Boolean {
|
||||
return dir.isDirectory &&
|
||||
bundledDesktopWebViewRequiredPaths(platform).all { requiredPath ->
|
||||
dir.resolve(requiredPath).exists()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bundledDesktopWebViewRequiredPaths(
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): List<String> {
|
||||
return when (platform.os) {
|
||||
DesktopOperatingSystem.WINDOWS -> listOf("jcef.dll", "libcef.dll")
|
||||
DesktopOperatingSystem.LINUX -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales")
|
||||
DesktopOperatingSystem.MACOS -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework")
|
||||
DesktopOperatingSystem.OTHER -> emptyList()
|
||||
}
|
||||
internal fun ReaderAiByokSettings.toDesktopPersistableAiSettings(): ReaderAiByokSettings {
|
||||
return sanitized().copy(hideReaderAiFeatures = false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,14 @@ internal data class DesktopCloudConfig(
|
|||
}
|
||||
|
||||
internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
|
||||
val resourceProperties = Properties().apply {
|
||||
return desktopCloudConfigFromProperties(
|
||||
resourceProperties = loadDesktopCloudResourceProperties(),
|
||||
localProperties = loadDesktopLocalProperties()
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadDesktopCloudResourceProperties(): Properties {
|
||||
return Properties().apply {
|
||||
val classLoader = DesktopCloudConfig::class.java.classLoader
|
||||
val stream = classLoader.getResourceAsStream("desktop-cloud.properties")
|
||||
?: classLoader.getResourceAsStream("common/desktop-cloud.properties")
|
||||
|
|
@ -35,18 +42,27 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
|
|||
}
|
||||
stream?.use { input -> load(input) }
|
||||
}
|
||||
val localProperties = Properties().apply {
|
||||
File("local.properties")
|
||||
.takeIf { it.isFile }
|
||||
}
|
||||
|
||||
private fun loadDesktopLocalProperties(file: File = File("local.properties")): Properties {
|
||||
return Properties().apply {
|
||||
file.takeIf { it.isFile }
|
||||
?.inputStream()
|
||||
?.use { input -> load(input) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopCloudConfigFromProperties(
|
||||
resourceProperties: Properties,
|
||||
localProperties: Properties = Properties(),
|
||||
systemProperty: (String) -> String? = { key -> System.getProperty("episteme.desktop.$key") },
|
||||
environment: (String) -> String? = { key -> System.getenv(key) }
|
||||
): DesktopCloudConfig {
|
||||
fun value(vararg keys: String): String {
|
||||
return keys.firstNotNullOfOrNull { key ->
|
||||
System.getProperty("episteme.desktop.$key")
|
||||
?: System.getenv("EPISTEME_DESKTOP_${key.uppercase()}")
|
||||
?: System.getenv(key)
|
||||
systemProperty(key)
|
||||
?: environment("EPISTEME_DESKTOP_${key.uppercase()}")
|
||||
?: environment(key)
|
||||
?: localProperties.getProperty("DESKTOP_$key")
|
||||
?: localProperties.getProperty(key)
|
||||
?: resourceProperties.getProperty(key)
|
||||
|
|
@ -56,7 +72,7 @@ internal fun loadDesktopCloudConfig(): DesktopCloudConfig {
|
|||
val aiWorkerUrl = value("AI_WORKER_URL").ifBlank {
|
||||
"https://reader-ai.aryanrajttps.workers.dev"
|
||||
}
|
||||
val ttsWorkerUrl = value("TTS_WORKER_URL").ifBlank { aiWorkerUrl }
|
||||
val ttsWorkerUrl = value("TTS_WORKER_URL")
|
||||
|
||||
return DesktopCloudConfig(
|
||||
aiWorkerUrl = aiWorkerUrl,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import java.net.http.HttpResponse
|
|||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -50,6 +51,8 @@ internal data class DesktopCloudBookMetadata(
|
|||
val isRecent: Boolean = true,
|
||||
val isDeleted: Boolean = false,
|
||||
val lastModifiedTimestamp: Long = 0L,
|
||||
val readingPositionModifiedTimestamp: Long = 0L,
|
||||
val annotationModifiedTimestamp: Long = 0L,
|
||||
val bookmarksJson: String? = null,
|
||||
val hasAnnotations: Boolean = false,
|
||||
val fileContentModifiedTimestamp: Long = 0L,
|
||||
|
|
@ -83,7 +86,8 @@ internal data class DesktopCloudFontMetadata(
|
|||
|
||||
internal data class DesktopDriveFile(
|
||||
val id: String,
|
||||
val name: String
|
||||
val name: String,
|
||||
val modifiedTimeMillis: Long = 0L
|
||||
)
|
||||
|
||||
internal class DesktopFirestoreRepository(
|
||||
|
|
@ -266,6 +270,10 @@ internal class DesktopGoogleDriveRepository(
|
|||
listFiles(accessToken = accessToken, query = null)
|
||||
}
|
||||
|
||||
suspend fun getFileByName(accessToken: String, fileName: String): DesktopDriveFile? = withContext(Dispatchers.IO) {
|
||||
listFiles(accessToken, "name = '${driveQueryStringValue(fileName)}' and trashed = false").firstOrNull()
|
||||
}
|
||||
|
||||
suspend fun uploadFont(accessToken: String, fileName: String, file: File, extension: String): DesktopDriveFile? =
|
||||
uploadNamedFile(
|
||||
accessToken = accessToken,
|
||||
|
|
@ -293,18 +301,20 @@ internal class DesktopGoogleDriveRepository(
|
|||
suspend fun uploadAnnotationFile(accessToken: String, bookId: String, file: File): DesktopDriveFile? {
|
||||
return uploadNamedFile(
|
||||
accessToken = accessToken,
|
||||
fileName = "annotation_$bookId.json",
|
||||
fileName = desktopCloudAnnotationDriveFileName(bookId),
|
||||
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
|
||||
val driveFile = getFileByName(accessToken, desktopCloudAnnotationDriveFileName(bookId))
|
||||
?: return false
|
||||
return downloadFile(accessToken, fileId, destination)
|
||||
return downloadFile(accessToken, driveFile.id, destination).also { downloaded ->
|
||||
if (downloaded && driveFile.modifiedTimeMillis > 0L) {
|
||||
destination.setLastModified(driveFile.modifiedTimeMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadFile(accessToken: String, fileId: String, destination: File): Boolean = withContext(Dispatchers.IO) {
|
||||
|
|
@ -375,9 +385,9 @@ internal class DesktopGoogleDriveRepository(
|
|||
}.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")}")
|
||||
URI.create("https://www.googleapis.com/upload/drive/v3/files?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
|
||||
} else {
|
||||
URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name")}")
|
||||
URI.create("https://www.googleapis.com/upload/drive/v3/files/${pathEncode(existingFileId)}?${query("uploadType" to "multipart", "fields" to "id,name,modifiedTime")}")
|
||||
}
|
||||
val request = HttpRequest.newBuilder(uploadUri)
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
|
|
@ -399,14 +409,15 @@ internal class DesktopGoogleDriveRepository(
|
|||
val root = DesktopCloudJson.parseToJsonElement(response.body()).jsonObject
|
||||
DesktopDriveFile(
|
||||
id = root.string("id").orEmpty(),
|
||||
name = root.string("name").orEmpty()
|
||||
name = root.string("name").orEmpty(),
|
||||
modifiedTimeMillis = parseDriveModifiedTimeMillis(root.string("modifiedTime"))
|
||||
)
|
||||
}
|
||||
|
||||
private fun listFiles(accessToken: String, query: String?): List<DesktopDriveFile> {
|
||||
val params = buildList {
|
||||
add("spaces" to "appDataFolder")
|
||||
add("fields" to "files(id,name)")
|
||||
add("fields" to "files(id,name,modifiedTime)")
|
||||
if (!query.isNullOrBlank()) add("q" to query)
|
||||
}
|
||||
val request = HttpRequest.newBuilder(
|
||||
|
|
@ -426,11 +437,20 @@ internal class DesktopGoogleDriveRepository(
|
|||
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)
|
||||
DesktopDriveFile(
|
||||
id = id,
|
||||
name = name,
|
||||
modifiedTimeMillis = parseDriveModifiedTimeMillis(obj.string("modifiedTime"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDriveModifiedTimeMillis(value: String?): Long {
|
||||
if (value.isNullOrBlank()) return 0L
|
||||
return runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0L)
|
||||
}
|
||||
|
||||
private data class DesktopFirestoreDocument(
|
||||
val id: String,
|
||||
val fields: JsonObject?
|
||||
|
|
@ -464,6 +484,8 @@ private fun DesktopCloudBookMetadata.toFirestoreFields(): Map<String, JsonElemen
|
|||
"isRecent" to firestoreBoolean(isRecent),
|
||||
"isDeleted" to firestoreBoolean(isDeleted),
|
||||
"lastModifiedTimestamp" to firestoreLong(lastModifiedTimestamp),
|
||||
"readingPositionModifiedTimestamp" to firestoreLong(readingPositionModifiedTimestamp),
|
||||
"annotationModifiedTimestamp" to firestoreLong(annotationModifiedTimestamp),
|
||||
"bookmarksJson" to firestoreNullableString(bookmarksJson),
|
||||
"hasAnnotations" to firestoreBoolean(hasAnnotations),
|
||||
"fileContentModifiedTimestamp" to firestoreLong(fileContentModifiedTimestamp),
|
||||
|
|
@ -512,6 +534,8 @@ private fun JsonObject.toBookMetadata(documentId: String): DesktopCloudBookMetad
|
|||
isRecent = booleanField("isRecent") ?: true,
|
||||
isDeleted = booleanField("isDeleted") ?: false,
|
||||
lastModifiedTimestamp = longField("lastModifiedTimestamp"),
|
||||
readingPositionModifiedTimestamp = longField("readingPositionModifiedTimestamp"),
|
||||
annotationModifiedTimestamp = longField("annotationModifiedTimestamp"),
|
||||
bookmarksJson = stringField("bookmarksJson"),
|
||||
hasAnnotations = booleanField("hasAnnotations") ?: false,
|
||||
fileContentModifiedTimestamp = longField("fileContentModifiedTimestamp"),
|
||||
|
|
|
|||
|
|
@ -11,54 +11,118 @@ 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 localAnnotationDebugSummary(book: BookItem): String {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return "path=null type=${book.type}"
|
||||
if (book.type != FileType.PDF) return "path=${path.logPreview(140)} type=${book.type}"
|
||||
val annotationFile = desktopPdfAnnotationFile(path)
|
||||
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
return "path=${path.logPreview(140)} " +
|
||||
"annotations{exists=${annotationFile.isFile} syncable=${annotationFile.hasSyncablePdfAnnotations()} " +
|
||||
"bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFile()}} " +
|
||||
"deletedAnnotations{exists=${deletedAnnotationFile.isFile} count=${deletedAnnotationFile.annotationDeletionCount()} " +
|
||||
"bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModifiedIfFile()}} " +
|
||||
"bookmarks{exists=${bookmarkFile.isFile} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModifiedIfFile()}} " +
|
||||
"text{exists=${richTextFile.isFile} syncable=${richTextFile.hasSyncablePdfRichText()} " +
|
||||
"bytes=${richTextFile.length()} ts=${richTextFile.lastModifiedIfFile()}} " +
|
||||
"payloadTs=${localAnnotationPayloadTimestamp(book)} totalTs=${localAnnotationTimestamp(book)}"
|
||||
}
|
||||
|
||||
fun hasLocalAnnotationData(book: BookItem): Boolean {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (book.type != FileType.PDF) return false
|
||||
return desktopPdfAnnotationFile(path).isFile ||
|
||||
return desktopPdfAnnotationFile(path).hasSyncablePdfAnnotations() ||
|
||||
desktopPdfAnnotationDeletionFile(path).hasSyncablePdfAnnotationDeletions() ||
|
||||
desktopPdfBookmarkFile(path).isFile ||
|
||||
desktopPdfRichTextFile(path).isFile
|
||||
desktopPdfRichTextFile(path).hasSyncablePdfRichText()
|
||||
}
|
||||
|
||||
fun localAnnotationTimestamp(book: BookItem): Long {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
|
||||
if (book.type != FileType.PDF) return 0L
|
||||
return maxOf(
|
||||
desktopPdfAnnotationFile(path).lastModifiedIfFile(),
|
||||
desktopPdfBookmarkFile(path).lastModifiedIfFile(),
|
||||
desktopPdfRichTextFile(path).lastModifiedIfFile()
|
||||
localAnnotationPayloadTimestamp(path),
|
||||
desktopPdfBookmarkFile(path).lastModifiedIfFile()
|
||||
)
|
||||
}
|
||||
|
||||
fun localAnnotationPayloadTimestamp(book: BookItem): Long {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
|
||||
if (book.type != FileType.PDF) return 0L
|
||||
return localAnnotationPayloadTimestamp(path)
|
||||
}
|
||||
|
||||
fun markAnnotationPayloadSynced(book: BookItem, timestamp: Long) {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return
|
||||
if (book.type != FileType.PDF || timestamp <= 0L) return
|
||||
listOf(desktopPdfAnnotationFile(path), desktopPdfAnnotationDeletionFile(path), desktopPdfRichTextFile(path))
|
||||
.filter { it.isFile }
|
||||
.forEach { it.setLastModified(timestamp) }
|
||||
}
|
||||
|
||||
fun recordAnnotationDeletions(book: BookItem, annotationIds: Collection<String>) {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return
|
||||
if (book.type != FileType.PDF) return
|
||||
recordAnnotationDeletions(path, book.id, annotationIds)
|
||||
}
|
||||
|
||||
fun recordAnnotationDeletions(documentPath: String, logBookId: String, annotationIds: Collection<String>) {
|
||||
val ids = annotationIds.mapNotNull { it.takeIf(String::isNotBlank) }.toSet()
|
||||
if (ids.isEmpty()) return
|
||||
val file = desktopPdfAnnotationDeletionFile(documentPath)
|
||||
val existing = if (file.isFile) {
|
||||
SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(file.readText())
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val next = existing.toMutableMap()
|
||||
ids.forEach { id -> next[id] = maxOf(next[id] ?: 0L, now) }
|
||||
val nextJson = SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(next)
|
||||
if (file.isFile && file.readText() == nextJson) return
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(nextJson)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.local.mark_deleted_annotations book=$logBookId ids=${ids.sorted()} " +
|
||||
"bytes=${file.length()} ts=${file.lastModified()}"
|
||||
}
|
||||
}
|
||||
|
||||
fun exportAnnotationBundle(book: BookItem): File? {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return null
|
||||
if (book.type != FileType.PDF) return null
|
||||
val annotationFile = desktopPdfAnnotationFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.export.inspect book=${book.id} ${localAnnotationDebugSummary(book)}"
|
||||
}
|
||||
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)))
|
||||
desktopPdfAnnotationElementForSync(annotationFile.readText())?.let { annotations ->
|
||||
put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
|
||||
}
|
||||
}
|
||||
if (deletedAnnotationFile.hasSyncablePdfAnnotationDeletions()) {
|
||||
val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(deletedAnnotationFile.readText())
|
||||
put(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions)
|
||||
)
|
||||
}
|
||||
if (richTextFile.isFile) {
|
||||
desktopPdfRichTextElementForSync(richTextFile.readText())?.let { put("text", it) }
|
||||
}
|
||||
}
|
||||
if (data.isEmpty()) {
|
||||
logDesktopCloudAnnotations { "desktop.export.skip book=${book.id} reason=no_syncable_payload" }
|
||||
return null
|
||||
}
|
||||
if (data.isEmpty()) return null
|
||||
val payload = JsonObject(mapOf("version" to JsonPrimitive(2)) + data)
|
||||
val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(
|
||||
cloudSidecarJson.encodeToString(JsonElement.serializer(), payload)
|
||||
|
|
@ -69,48 +133,129 @@ internal object DesktopCloudSidecarSync {
|
|||
)
|
||||
tempFile.parentFile?.mkdirs()
|
||||
tempFile.writeText(canonical)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.export.bundle_ready book=${book.id} keys=${data.keys.toList()} " +
|
||||
"canonicalBytes=${canonical.length} fileBytes=${tempFile.length()} temp=${tempFile.name}"
|
||||
}
|
||||
return tempFile
|
||||
}
|
||||
|
||||
fun importAnnotationBundle(book: BookItem, rawJson: String, timestamp: Long): Boolean {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (book.type != FileType.PDF) return false
|
||||
val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: return false
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: run {
|
||||
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=missing_path bytes=${rawJson.length}" }
|
||||
return false
|
||||
}
|
||||
if (book.type != FileType.PDF) {
|
||||
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=not_pdf type=${book.type} bytes=${rawJson.length}" }
|
||||
return false
|
||||
}
|
||||
val root = cloudSidecarJson.parseElementOrNull(rawJson)?.jsonObjectOrNull() ?: run {
|
||||
logDesktopCloudAnnotations { "desktop.import.skip book=${book.id} reason=parse_failed bytes=${rawJson.length}" }
|
||||
return false
|
||||
}
|
||||
val data = root["data"]?.jsonObjectOrNull() ?: root
|
||||
val canonicalData = SharedPdfAnnotationSidecarCodec.withCanonicalAnnotations(data)
|
||||
val annotationFile = desktopPdfAnnotationFile(path)
|
||||
val deletedAnnotationFile = desktopPdfAnnotationDeletionFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.import.inspect book=${book.id} remoteTs=$timestamp rawBytes=${rawJson.length} " +
|
||||
"rawKeys=${data.keys.toList()} canonicalKeys=${canonicalData.keys.toList()} " +
|
||||
localAnnotationDebugSummary(book)
|
||||
}
|
||||
|
||||
if (canonicalData.hasPdfAnnotationPayload()) {
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(canonicalData)
|
||||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
annotationFile.setLastModified(timestamp)
|
||||
if (annotations.isEmpty()) {
|
||||
if (annotationFile.isFile) {
|
||||
val deleted = annotationFile.delete()
|
||||
logDesktopCloudAnnotations { "desktop.import.delete_annotations book=${book.id} deleted=$deleted" }
|
||||
} else {
|
||||
logDesktopCloudAnnotations { "desktop.import.annotations_empty book=${book.id} existing=false" }
|
||||
}
|
||||
} else {
|
||||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
annotationFile.setLastModified(timestamp)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.import.write_annotations book=${book.id} count=${annotations.size} " +
|
||||
"bytes=${annotationFile.length()} ts=${annotationFile.lastModified()}"
|
||||
}
|
||||
}
|
||||
} else if (annotationFile.isFile) {
|
||||
annotationFile.delete()
|
||||
val deleted = annotationFile.delete()
|
||||
logDesktopCloudAnnotations { "desktop.import.delete_annotations_missing_payload book=${book.id} deleted=$deleted" }
|
||||
} else {
|
||||
logDesktopCloudAnnotations { "desktop.import.no_annotation_payload book=${book.id} existing=false" }
|
||||
}
|
||||
|
||||
val deletions = SharedPdfAnnotationSidecarCodec.annotationDeletionsFromData(canonicalData)
|
||||
if (deletions.isEmpty()) {
|
||||
if (deletedAnnotationFile.isFile) {
|
||||
val deleted = deletedAnnotationFile.delete()
|
||||
logDesktopCloudAnnotations { "desktop.import.delete_annotation_tombstones book=${book.id} deleted=$deleted" }
|
||||
}
|
||||
} else {
|
||||
deletedAnnotationFile.parentFile?.mkdirs()
|
||||
deletedAnnotationFile.writeText(SharedPdfAnnotationSidecarCodec.annotationDeletionsJson(deletions))
|
||||
deletedAnnotationFile.setLastModified(timestamp)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.import.write_annotation_tombstones book=${book.id} count=${deletions.size} " +
|
||||
"bytes=${deletedAnnotationFile.length()} ts=${deletedAnnotationFile.lastModified()}"
|
||||
}
|
||||
}
|
||||
|
||||
canonicalData["bookmarks"]?.let { bookmarks ->
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
bookmarkFile.writeText(cloudSidecarJson.encodeToString(JsonElement.serializer(), bookmarks))
|
||||
bookmarkFile.setLastModified(timestamp)
|
||||
} ?: run {
|
||||
if (bookmarkFile.isFile) bookmarkFile.delete()
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.import.write_bookmarks book=${book.id} bytes=${bookmarkFile.length()} ts=${bookmarkFile.lastModified()}"
|
||||
}
|
||||
}
|
||||
|
||||
canonicalData["text"]?.let { richText ->
|
||||
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
|
||||
richTextFile.parentFile?.mkdirs()
|
||||
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||
richTextFile.setLastModified(timestamp)
|
||||
if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
|
||||
if (richTextFile.isFile) {
|
||||
val deleted = richTextFile.delete()
|
||||
logDesktopCloudAnnotations { "desktop.import.delete_text_empty book=${book.id} deleted=$deleted" }
|
||||
} else {
|
||||
logDesktopCloudAnnotations { "desktop.import.text_empty book=${book.id} existing=false" }
|
||||
}
|
||||
} else {
|
||||
richTextFile.parentFile?.mkdirs()
|
||||
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||
richTextFile.setLastModified(timestamp)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.import.write_text book=${book.id} textChars=${richDocument.text.length} " +
|
||||
"spans=${richDocument.spans.size} bytes=${richTextFile.length()} ts=${richTextFile.lastModified()}"
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
if (richTextFile.isFile) richTextFile.delete()
|
||||
if (richTextFile.isFile) {
|
||||
val deleted = richTextFile.delete()
|
||||
logDesktopCloudAnnotations { "desktop.import.delete_text_missing book=${book.id} deleted=$deleted" }
|
||||
} else {
|
||||
logDesktopCloudAnnotations { "desktop.import.no_text_payload book=${book.id} existing=false" }
|
||||
}
|
||||
}
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.import.done book=${book.id} remoteTs=$timestamp ${localAnnotationDebugSummary(book)}"
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun localAnnotationPayloadTimestamp(path: String): Long {
|
||||
return maxOf(
|
||||
desktopPdfAnnotationFile(path).lastModifiedIfSyncableAnnotations(),
|
||||
desktopPdfAnnotationDeletionFile(path).lastModifiedIfSyncableAnnotationDeletions(),
|
||||
desktopPdfRichTextFile(path).lastModifiedIfSyncableRichText()
|
||||
)
|
||||
}
|
||||
|
||||
private val cloudSidecarJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
|
|
@ -136,3 +281,31 @@ private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
|
|||
private fun File.lastModifiedIfFile(): Long {
|
||||
return if (isFile) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun File.hasSyncablePdfAnnotations(): Boolean {
|
||||
return isFile && desktopPdfAnnotationElementForSync(readText()) != null
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfSyncableAnnotations(): Long {
|
||||
return if (hasSyncablePdfAnnotations()) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun File.annotationDeletionCount(): Int {
|
||||
return if (isFile) SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(readText()).size else 0
|
||||
}
|
||||
|
||||
private fun File.hasSyncablePdfAnnotationDeletions(): Boolean {
|
||||
return annotationDeletionCount() > 0
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfSyncableAnnotationDeletions(): Long {
|
||||
return if (hasSyncablePdfAnnotationDeletions()) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun File.hasSyncablePdfRichText(): Boolean {
|
||||
return isFile && desktopPdfRichTextElementForSync(readText()) != null
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfSyncableRichText(): Long {
|
||||
return if (hasSyncablePdfRichText()) lastModified() else 0L
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,17 @@ 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.SharedCloudBookMetadataWinner
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.ShelfRecord
|
||||
import com.aryan.reader.shared.sharedCloudBookMetadataWinner
|
||||
import com.aryan.reader.shared.shouldDownloadRemoteCloudBookContent
|
||||
import com.aryan.reader.shared.shouldUploadLocalCloudBookContent
|
||||
import com.aryan.reader.shared.sharedCloudBookContentFileName
|
||||
import com.aryan.reader.shared.toStablePositionCfi
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import java.io.File
|
||||
|
||||
|
|
@ -31,7 +39,8 @@ internal data class DesktopCloudSyncResult(
|
|||
val shelfRefs: List<BookShelfRef>,
|
||||
val customFonts: List<CustomFontItem>,
|
||||
val uploadedBooks: Int = 0,
|
||||
val downloadedBooks: Int = 0
|
||||
val downloadedBooks: Int = 0,
|
||||
val pendingContentDownloads: Int = 0
|
||||
)
|
||||
|
||||
internal class DesktopCloudSync(
|
||||
|
|
@ -47,13 +56,22 @@ internal class DesktopCloudSync(
|
|||
var customFonts = input.customFonts
|
||||
var uploadedBooks = 0
|
||||
var downloadedBooks = 0
|
||||
var pendingContentDownloads = 0
|
||||
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.full_sync.start user=${input.userId} device=${input.deviceId} " +
|
||||
"localBooks=${input.state.rawLibraryBooks.size} includeFolderBooks=${input.includeFolderBooks}"
|
||||
}
|
||||
val remoteBooks = firestoreRepository.getAllBooks(input.userId, input.idToken)
|
||||
.filterNot { isDesktopPdfReflowBookId(it.bookId) }
|
||||
.filterNot { SharedFileCapabilities.isManualOnlyReaderFileName(it.displayName) }
|
||||
val remoteShelves = firestoreRepository.getAllShelves(input.userId, input.idToken)
|
||||
val remoteFonts = firestoreRepository.getAllFonts(input.userId, input.idToken)
|
||||
var driveFiles = driveRepository.getFiles(input.driveAccessToken).associateBy { it.name }
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.full_sync.loaded user=${input.userId} remoteBooks=${remoteBooks.size} " +
|
||||
"remoteShelves=${remoteShelves.size} remoteFonts=${remoteFonts.size} driveFiles=${driveFiles.size}"
|
||||
}
|
||||
|
||||
val localBooks = state.rawLibraryBooks
|
||||
.filterNot { isDesktopPdfReflowBookId(it.id) }
|
||||
|
|
@ -71,6 +89,7 @@ internal class DesktopCloudSync(
|
|||
|
||||
when {
|
||||
local != null && remote == null -> {
|
||||
logDesktopCloudSync { "desktop.engine.book_decision action=upload_new ${local.desktopCloudSyncSummary()}" }
|
||||
uploadBookAndMetadata(input, local, uploadContent = true)?.let { synced ->
|
||||
state = state.upsertCloudBook(synced)
|
||||
uploadedBooks += 1
|
||||
|
|
@ -78,22 +97,33 @@ internal class DesktopCloudSync(
|
|||
}
|
||||
|
||||
local == null && remote != null -> {
|
||||
if (remote.isDeleted) return@forEach
|
||||
if (remote.isDeleted) {
|
||||
logDesktopCloudSync { "desktop.engine.book_decision action=skip_deleted_remote_only ${remote.desktopCloudSyncSummary()}" }
|
||||
return@forEach
|
||||
}
|
||||
logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_new ${remote.desktopCloudSyncSummary()}" }
|
||||
val downloaded = downloadRemoteBook(input.driveAccessToken, remote, null, driveFiles)
|
||||
val remoteBook = downloaded ?: remote.toDesktopBookItem()
|
||||
if (downloaded == null) {
|
||||
pendingContentDownloads += 1
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.book_decision action=defer_remote_new_pending_content " +
|
||||
remote.desktopCloudSyncSummary()
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
val remoteBook = downloaded
|
||||
state = state.upsertCloudBook(remoteBook)
|
||||
if (downloaded != null) downloadedBooks += 1
|
||||
downloadedBooks += 1
|
||||
importDesktopPdfBookmarksMetadata(remoteBook, remote.bookmarksJson, remote.lastModifiedTimestamp)
|
||||
if (remote.hasAnnotations) {
|
||||
downloadAnnotations(input.driveAccessToken, remoteBook, remote.lastModifiedTimestamp)
|
||||
val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(
|
||||
remoteAnnotationDriveFileTimestamp(remote.bookId, driveFiles)
|
||||
)
|
||||
downloadAnnotations(input.driveAccessToken, remoteBook, remoteAnnotationTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -101,24 +131,165 @@ internal class DesktopCloudSync(
|
|||
} 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
|
||||
if (shouldDownloadContent && downloaded == null) {
|
||||
pendingContentDownloads += 1
|
||||
}
|
||||
val localContentAvailable = local.path?.let(::File)?.isFile == true
|
||||
if (shouldDownloadContent && downloaded == null && !localContentAvailable) {
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.book_decision action=defer_existing_pending_content book=$bookId " +
|
||||
local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary()
|
||||
}
|
||||
} else if (remote.lastModifiedTimestamp > local.timestamp || downloaded != null) {
|
||||
state = state.upsertCloudBook(downloaded ?: remoteBook)
|
||||
state = state.removeCloudBook(bookId)
|
||||
return@forEach
|
||||
}
|
||||
val localSidecarTimestampBeforeMerge = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
|
||||
val metadataWinner = sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = local.timestamp,
|
||||
remoteModifiedTimestamp = remote.lastModifiedTimestamp
|
||||
)
|
||||
val localMetadataWins = metadataWinner == SharedCloudBookMetadataWinner.LOCAL
|
||||
val localReadingTimestamp = local.effectiveCloudReadingPositionModifiedTimestamp()
|
||||
val remoteReadingTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp()
|
||||
val remoteAnnotationDriveTimestamp = remoteAnnotationDriveFileTimestamp(bookId, driveFiles)
|
||||
val remoteAnnotationTimestamp = remote.effectiveCloudAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp)
|
||||
val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp
|
||||
val localAnnotationsShouldUpload = shouldUploadLocalAnnotations(
|
||||
local = local,
|
||||
remote = remote,
|
||||
remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
|
||||
localSidecarTimestamp = localSidecarTimestampBeforeMerge
|
||||
)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " +
|
||||
"remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
|
||||
"remoteDriveAnnTs=$remoteAnnotationDriveTimestamp localTs=${local.timestamp} " +
|
||||
"remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
|
||||
"localShouldUpload=$localAnnotationsShouldUpload " +
|
||||
DesktopCloudSidecarSync.localAnnotationDebugSummary(local)
|
||||
}
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.book_compare book=$bookId winner=$metadataWinner shouldDownloadContent=$shouldDownloadContent " +
|
||||
"downloadedContent=${downloaded != null} sidecarTs=$localSidecarTimestampBeforeMerge " +
|
||||
"uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
|
||||
local.desktopCloudSyncSummary() + " " + remote.desktopCloudSyncSummary()
|
||||
}
|
||||
|
||||
val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationTimestamp(downloaded ?: local)
|
||||
val needsAnnotationDownload = remote.hasAnnotations &&
|
||||
(remote.lastModifiedTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L)
|
||||
if (remote.isDeleted) {
|
||||
if (localMetadataWins) {
|
||||
logDesktopCloudSync { "desktop.engine.book_decision action=resurrect_upload_local book=$bookId" }
|
||||
uploadBookAndMetadata(
|
||||
input = input,
|
||||
book = local,
|
||||
uploadContent = shouldUploadLocalBookContent(local, null),
|
||||
uploadAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(local)
|
||||
)?.let { synced ->
|
||||
state = state.upsertCloudBook(synced)
|
||||
uploadedBooks += 1
|
||||
}
|
||||
} else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE) {
|
||||
logDesktopCloudSync { "desktop.engine.book_decision action=apply_remote_delete book=$bookId" }
|
||||
state = state.removeCloudBook(bookId)
|
||||
} else {
|
||||
logDesktopCloudSync { "desktop.engine.book_decision action=skip_equal_delete book=$bookId" }
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (localMetadataWins) {
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.book_decision action=upload_local book=$bookId " +
|
||||
"uploadContent=${shouldUploadLocalBookContent(local, remote)} " +
|
||||
"uploadAnnotations=$localAnnotationsShouldUpload " +
|
||||
"preserveRemoteReading=${remoteReadingTimestamp > localReadingTimestamp}"
|
||||
}
|
||||
val localForMetadata = if (remoteReadingTimestamp > localReadingTimestamp) {
|
||||
local.withCloudReadingPosition(remote)
|
||||
} else {
|
||||
local
|
||||
}
|
||||
val bookForMetadata = localForMetadata.withDownloadedCloudContent(downloaded, replacePath = false)
|
||||
uploadBookAndMetadata(
|
||||
input = input,
|
||||
book = bookForMetadata,
|
||||
uploadContent = shouldUploadLocalBookContent(local, remote),
|
||||
uploadAnnotations = localAnnotationsShouldUpload,
|
||||
remoteHasAnnotations = remote.hasAnnotations,
|
||||
remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
|
||||
remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp
|
||||
)?.let { synced ->
|
||||
state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded))
|
||||
uploadedBooks += 1
|
||||
}
|
||||
} else if (metadataWinner == SharedCloudBookMetadataWinner.REMOTE || downloaded != null) {
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.book_decision action=apply_remote book=$bookId " +
|
||||
"metadataWinner=$metadataWinner downloadedContent=${downloaded != null}"
|
||||
}
|
||||
val mergedBook = downloaded ?: remoteBook
|
||||
state = state.upsertCloudBook(mergedBook)
|
||||
importDesktopPdfBookmarksMetadata(mergedBook, remote.bookmarksJson, remote.lastModifiedTimestamp)
|
||||
}
|
||||
|
||||
if (!localMetadataWins && (localAnnotationsShouldUpload || localReadingPositionShouldUpload)) {
|
||||
val metadataBook = state.rawLibraryBooks.firstOrNull { it.id == bookId }
|
||||
?: remoteBook
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.sync.upload_local_supplement book=$bookId winner=$metadataWinner " +
|
||||
"remoteHas=${remote.hasAnnotations} remoteTs=${remote.lastModifiedTimestamp} " +
|
||||
"remoteAnnTs=$remoteAnnotationTimestamp " +
|
||||
"localSidecarTs=$localSidecarTimestampBeforeMerge " +
|
||||
"uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
|
||||
"localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp"
|
||||
}
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.book_decision action=upload_local_supplement book=$bookId " +
|
||||
"metadataWinner=$metadataWinner sidecarTs=$localSidecarTimestampBeforeMerge " +
|
||||
"uploadAnnotations=$localAnnotationsShouldUpload uploadReading=$localReadingPositionShouldUpload " +
|
||||
metadataBook.desktopCloudSyncSummary()
|
||||
}
|
||||
uploadBookAndMetadata(
|
||||
input = input,
|
||||
book = metadataBook,
|
||||
uploadContent = false,
|
||||
uploadAnnotations = localAnnotationsShouldUpload,
|
||||
remoteHasAnnotations = remote.hasAnnotations,
|
||||
remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp,
|
||||
remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp
|
||||
)?.let { synced ->
|
||||
state = state.upsertCloudBook(synced.withDownloadedCloudContent(downloaded))
|
||||
uploadedBooks += 1
|
||||
}
|
||||
}
|
||||
|
||||
val localSidecarTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(downloaded ?: local)
|
||||
val needsAnnotationDownload = !localMetadataWins &&
|
||||
!localAnnotationsShouldUpload &&
|
||||
remote.hasAnnotations &&
|
||||
(remoteAnnotationTimestamp > localSidecarTimestamp || localSidecarTimestamp == 0L)
|
||||
if (needsAnnotationDownload) {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.sync.download_remote_annotations book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
|
||||
"remoteAnnTs=$remoteAnnotationTimestamp " +
|
||||
"localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " +
|
||||
"localShouldUpload=$localAnnotationsShouldUpload"
|
||||
}
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.sidecar_download_start book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
|
||||
"remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins"
|
||||
}
|
||||
val targetBook = downloaded ?: state.rawLibraryBooks.firstOrNull { it.id == bookId } ?: local
|
||||
downloadAnnotations(input.driveAccessToken, targetBook, remote.lastModifiedTimestamp)
|
||||
downloadAnnotations(input.driveAccessToken, targetBook, remoteAnnotationTimestamp)
|
||||
} else {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.sync.skip_remote_annotations book=$bookId remoteHas=${remote.hasAnnotations} " +
|
||||
"remoteAnnTs=$remoteAnnotationTimestamp localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins " +
|
||||
"localShouldUpload=$localAnnotationsShouldUpload"
|
||||
}
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.sidecar_download_skip book=$bookId remoteHasAnnotations=${remote.hasAnnotations} " +
|
||||
"localSidecarTs=$localSidecarTimestamp localMetadataWins=$localMetadataWins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -135,18 +306,49 @@ internal class DesktopCloudSync(
|
|||
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
|
||||
val remote = remoteBooksMap[book.id]
|
||||
if (remote == null || shouldUploadLocalBookContent(book, remote)) {
|
||||
logDesktopCloudSync { "desktop.engine.content_upload_missing_remote book=${book.id} driveName=$driveName" }
|
||||
uploadBookAndMetadata(
|
||||
input = input,
|
||||
book = book,
|
||||
uploadContent = true,
|
||||
uploadAnnotations = false,
|
||||
remoteHasAnnotations = remote?.hasAnnotations == true,
|
||||
remoteAnnotationModifiedTimestamp = remote?.effectiveCloudAnnotationModifiedTimestamp(
|
||||
remoteAnnotationDriveFileTimestamp(book.id, driveFiles)
|
||||
) ?: 0L,
|
||||
remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
|
||||
)?.let { synced ->
|
||||
state = state.upsertCloudBook(synced)
|
||||
uploadedBooks += 1
|
||||
}
|
||||
} else {
|
||||
pendingContentDownloads += 1
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName " +
|
||||
"localContentTs=${book.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(localFile == null || !localFile.isFile) && driveFiles[driveName] != null -> {
|
||||
val remote = remoteBooksMap[book.id] ?: return@forEach
|
||||
downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)?.let { downloaded ->
|
||||
logDesktopCloudSync { "desktop.engine.content_download_missing_local book=${book.id} driveName=$driveName" }
|
||||
val downloaded = downloadRemoteBook(input.driveAccessToken, remote, book, driveFiles)
|
||||
if (downloaded != null) {
|
||||
state = state.upsertCloudBook(downloaded)
|
||||
downloadedBooks += 1
|
||||
} else {
|
||||
pendingContentDownloads += 1
|
||||
}
|
||||
}
|
||||
|
||||
(localFile == null || !localFile.isFile) && driveFiles[driveName] == null -> {
|
||||
pendingContentDownloads += 1
|
||||
logDesktopCloudSync { "desktop.engine.content_wait_missing_remote book=${book.id} driveName=$driveName" }
|
||||
state = state.removeCloudBook(book.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,55 +374,205 @@ internal class DesktopCloudSync(
|
|||
remoteFonts = remoteFonts
|
||||
)
|
||||
|
||||
logDesktopCloudSync {
|
||||
"desktop.engine.full_sync.complete user=${input.userId} uploaded=$uploadedBooks downloaded=$downloadedBooks " +
|
||||
"pendingContent=$pendingContentDownloads books=${state.rawLibraryBooks.size}"
|
||||
}
|
||||
return DesktopCloudSyncResult(
|
||||
state = state,
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs,
|
||||
customFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() },
|
||||
uploadedBooks = uploadedBooks,
|
||||
downloadedBooks = downloadedBooks
|
||||
downloadedBooks = downloadedBooks,
|
||||
pendingContentDownloads = pendingContentDownloads
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun uploadBookAndMetadata(
|
||||
input: DesktopCloudSyncInput,
|
||||
book: BookItem,
|
||||
uploadContent: Boolean
|
||||
uploadContent: Boolean,
|
||||
uploadAnnotations: Boolean = true,
|
||||
remoteHasAnnotations: Boolean = false,
|
||||
remoteAnnotationModifiedTimestamp: Long = 0L,
|
||||
remoteContentModifiedTimestamp: Long? = null
|
||||
): 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 (isDesktopPdfReflowBookId(book.id)) {
|
||||
logDesktopCloudSync { "desktop.upload.skip reason=reflow ${book.desktopCloudSyncSummary()}" }
|
||||
return null
|
||||
}
|
||||
if (book.sourceFolder != null) {
|
||||
logDesktopCloudSync { "desktop.upload.skip reason=folder_book ${book.desktopCloudSyncSummary()}" }
|
||||
return null
|
||||
}
|
||||
if (book.path?.startsWith("opds-pse") == true) {
|
||||
logDesktopCloudSync { "desktop.upload.skip reason=opds_stream ${book.desktopCloudSyncSummary()}" }
|
||||
return null
|
||||
}
|
||||
if (SharedFileCapabilities.isManualOnlyReaderFileName(book.displayName)) {
|
||||
logDesktopCloudSync { "desktop.upload.skip reason=manual_only ${book.desktopCloudSyncSummary()}" }
|
||||
return null
|
||||
}
|
||||
logDesktopCloudSync {
|
||||
"desktop.upload.start uploadContent=$uploadContent uploadAnnotations=$uploadAnnotations " +
|
||||
"remoteHasAnnotations=$remoteHasAnnotations ${book.desktopCloudSyncSummary()}"
|
||||
}
|
||||
if (uploadContent) {
|
||||
val source = book.path?.let(::File)?.takeIf { it.isFile }
|
||||
if (source != null && driveRepository.uploadFile(input.driveAccessToken, book.id, source, book.type) == null) {
|
||||
logDesktopCloudSync { "desktop.upload.content_failed book=${book.id} path=${source.absolutePath}" }
|
||||
return null
|
||||
}
|
||||
logDesktopCloudSync { "desktop.upload.content_success book=${book.id} path=${source?.absolutePath ?: "none"}" }
|
||||
}
|
||||
|
||||
val bundle = DesktopCloudSidecarSync.exportAnnotationBundle(book)
|
||||
val hasLocalAnnotations = DesktopCloudSidecarSync.hasLocalAnnotationData(book)
|
||||
val shouldUploadAnnotations = uploadAnnotations || (!remoteHasAnnotations && hasLocalAnnotations)
|
||||
val bundle = if (shouldUploadAnnotations) DesktopCloudSidecarSync.exportAnnotationBundle(book) else null
|
||||
var uploadedAnnotationTimestamp = 0L
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.annotation_decision book=${book.id} uploadAnnotations=$uploadAnnotations " +
|
||||
"remoteHas=$remoteHasAnnotations hasLocal=$hasLocalAnnotations shouldUpload=$shouldUploadAnnotations " +
|
||||
"bundleBytes=${bundle?.length() ?: 0L} " + DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
|
||||
}
|
||||
try {
|
||||
if (bundle != null && driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle) == null) {
|
||||
return null
|
||||
if (bundle != null) {
|
||||
val mergedRemoteIntoUpload = mergeRemoteAnnotationsIntoUploadBundle(
|
||||
accessToken = input.driveAccessToken,
|
||||
book = book,
|
||||
bundle = bundle,
|
||||
remoteHasAnnotations = remoteHasAnnotations
|
||||
)
|
||||
val uploadedAnnotationFile = driveRepository.uploadAnnotationFile(input.driveAccessToken, book.id, bundle)
|
||||
if (uploadedAnnotationFile == null) {
|
||||
logDesktopCloudAnnotations { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" }
|
||||
logDesktopCloudSync { "desktop.upload.sidecar_failed book=${book.id} bytes=${bundle.length()}" }
|
||||
return null
|
||||
}
|
||||
uploadedAnnotationTimestamp = uploadedAnnotationFile.modifiedTimeMillis
|
||||
if (mergedRemoteIntoUpload) {
|
||||
val appliedMergedLocal = DesktopCloudSidecarSync.importAnnotationBundle(
|
||||
book = book,
|
||||
rawJson = bundle.readText(),
|
||||
timestamp = uploadedAnnotationTimestamp
|
||||
)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.local_apply_merged book=${book.id} applied=$appliedMergedLocal " +
|
||||
"driveTs=$uploadedAnnotationTimestamp bytes=${bundle.length()}"
|
||||
}
|
||||
}
|
||||
DesktopCloudSidecarSync.markAnnotationPayloadSynced(book, uploadedAnnotationTimestamp)
|
||||
}
|
||||
if (bundle != null) {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.sidecar_success book=${book.id} bytes=${bundle.length()} driveTs=$uploadedAnnotationTimestamp"
|
||||
}
|
||||
} else {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.sidecar_skipped book=${book.id} shouldUpload=$shouldUploadAnnotations hasLocal=$hasLocalAnnotations"
|
||||
}
|
||||
}
|
||||
logDesktopCloudSync {
|
||||
"desktop.upload.sidecar_decision book=${book.id} hasLocal=$hasLocalAnnotations " +
|
||||
"shouldUpload=$shouldUploadAnnotations uploaded=${bundle != null} bytes=${bundle?.length() ?: 0L}"
|
||||
}
|
||||
} finally {
|
||||
bundle?.delete()
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val syncedBook = book.copy(timestamp = now)
|
||||
val syncedBook = book.copy(
|
||||
timestamp = now,
|
||||
readingPositionModifiedTimestamp = book.effectiveCloudReadingPositionModifiedTimestamp()
|
||||
)
|
||||
val localAnnotationTimestamp = DesktopCloudSidecarSync.localAnnotationPayloadTimestamp(book)
|
||||
val syncedAnnotationTimestamp = if (bundle != null) {
|
||||
uploadedAnnotationTimestamp.takeIf { it > 0L } ?: maxOf(localAnnotationTimestamp, now)
|
||||
} else if (remoteHasAnnotations) {
|
||||
remoteAnnotationModifiedTimestamp
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
val syncedHasAnnotations = if (uploadAnnotations) {
|
||||
syncedAnnotationTimestamp > 0L || (bundle != null && hasLocalAnnotations)
|
||||
} else {
|
||||
remoteHasAnnotations || syncedAnnotationTimestamp > 0L || bundle != null || hasLocalAnnotations
|
||||
}
|
||||
firestoreRepository.syncBookMetadata(
|
||||
userId = input.userId,
|
||||
book = syncedBook.toDesktopCloudBookMetadata(
|
||||
hasAnnotations = bundle != null,
|
||||
timestamp = now
|
||||
hasAnnotations = syncedHasAnnotations,
|
||||
timestamp = now,
|
||||
annotationModifiedTimestamp = syncedAnnotationTimestamp,
|
||||
contentTimestampOverride = if (uploadContent) null else remoteContentModifiedTimestamp
|
||||
),
|
||||
originDeviceId = input.deviceId,
|
||||
idToken = input.idToken
|
||||
)
|
||||
logDesktopCloudSync {
|
||||
"desktop.upload.metadata_success user=${input.userId} device=${input.deviceId} " +
|
||||
"oldTs=${book.timestamp} newTs=$now hasAnnotations=$syncedHasAnnotations " +
|
||||
syncedBook.desktopCloudSyncSummary("synced")
|
||||
}
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.metadata_success book=${book.id} oldTs=${book.timestamp} newTs=$now " +
|
||||
"readTs=${syncedBook.effectiveCloudReadingPositionModifiedTimestamp()} " +
|
||||
"annTs=$syncedAnnotationTimestamp hasAnnotations=$syncedHasAnnotations"
|
||||
}
|
||||
return syncedBook
|
||||
}
|
||||
|
||||
private suspend fun mergeRemoteAnnotationsIntoUploadBundle(
|
||||
accessToken: String,
|
||||
book: BookItem,
|
||||
bundle: File,
|
||||
remoteHasAnnotations: Boolean
|
||||
): Boolean {
|
||||
if (!remoteHasAnnotations || !bundle.isFile) return false
|
||||
val remoteTemp = File(desktopUserCacheRoot(), "remote_annotation_${book.id.toDesktopSafeFileName()}_${System.nanoTime()}.json")
|
||||
try {
|
||||
val didDownload = driveRepository.downloadAnnotationFile(accessToken, book.id, remoteTemp)
|
||||
if (!didDownload || !remoteTemp.isFile) {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.merge_remote_missing book=${book.id} didDownload=$didDownload " +
|
||||
"tempExists=${remoteTemp.exists()} localBytes=${bundle.length()}"
|
||||
}
|
||||
return false
|
||||
}
|
||||
val localRaw = bundle.readText()
|
||||
val remoteRaw = remoteTemp.readText()
|
||||
val mergedRaw = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
|
||||
localDataJson = localRaw,
|
||||
remoteDataJson = remoteRaw,
|
||||
preferRemoteOnConflict = false
|
||||
)
|
||||
val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(localRaw)
|
||||
val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteRaw)
|
||||
val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedRaw)
|
||||
if (mergedRaw != localRaw) {
|
||||
bundle.writeText(mergedRaw)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.merge_remote_applied book=${book.id} localCount=$localCount " +
|
||||
"remoteCount=$remoteCount mergedCount=$mergedCount mergedBytes=${bundle.length()}"
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.merge_remote_noop book=${book.id} localCount=$localCount " +
|
||||
"remoteCount=$remoteCount mergedCount=$mergedCount"
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.upload.merge_remote_failed book=${book.id} error=${error.message.orEmpty().logPreview(240)}"
|
||||
}
|
||||
} finally {
|
||||
remoteTemp.delete()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun deleteBooksFromCloud(
|
||||
userId: String,
|
||||
idToken: String,
|
||||
|
|
@ -247,7 +599,7 @@ internal class DesktopCloudSync(
|
|||
desktopCloudBookDriveFileName(book.id, book.type)
|
||||
?.let { driveFiles[it]?.id }
|
||||
?.let { driveRepository.deleteDriveFile(accessToken, it) }
|
||||
driveFiles["annotation_${book.id}.json"]?.id
|
||||
driveFiles[desktopCloudAnnotationDriveFileName(book.id)]?.id
|
||||
?.let { driveRepository.deleteDriveFile(accessToken, it) }
|
||||
}
|
||||
}
|
||||
|
|
@ -293,8 +645,32 @@ internal class DesktopCloudSync(
|
|||
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)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.download.start book=${book.id} remoteTs=$timestamp temp=${temp.name} " +
|
||||
DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
|
||||
}
|
||||
logDesktopCloudSync { "desktop.sidecar_download.start book=${book.id} remoteTs=$timestamp temp=${temp.name}" }
|
||||
if (!driveRepository.downloadAnnotationFile(accessToken, book.id, temp) || !temp.isFile) {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.download.missing book=${book.id} remoteTs=$timestamp tempExists=${temp.exists()} tempBytes=${temp.length()}"
|
||||
}
|
||||
logDesktopCloudSync { "desktop.sidecar_download.missing book=${book.id} remoteTs=$timestamp" }
|
||||
return false
|
||||
}
|
||||
val raw = temp.readText()
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.download.success book=${book.id} remoteTs=$timestamp bytes=${raw.length}"
|
||||
}
|
||||
val appliedTimestamp = timestamp.takeIf { it > 0L } ?: temp.lastModified().takeIf { it > 0L } ?: 0L
|
||||
val applied = DesktopCloudSidecarSync.importAnnotationBundle(book, raw, appliedTimestamp)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp applied=$applied " +
|
||||
DesktopCloudSidecarSync.localAnnotationDebugSummary(book)
|
||||
}
|
||||
logDesktopCloudSync {
|
||||
"desktop.sidecar_download.applied book=${book.id} remoteTs=$timestamp appliedTs=$appliedTimestamp bytes=${temp.length()} applied=$applied"
|
||||
}
|
||||
applied
|
||||
} finally {
|
||||
temp.delete()
|
||||
}
|
||||
|
|
@ -311,16 +687,23 @@ internal class DesktopCloudSync(
|
|||
val driveFile = driveFiles[driveName] ?: return null
|
||||
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
|
||||
val destination = bookImporter.createBookFile("${remote.bookId.toDesktopSafeFileName()}.$extension")
|
||||
logDesktopCloudSync { "desktop.content_download.start book=${remote.bookId} driveName=$driveName remoteContentTs=${remote.fileContentModifiedTimestamp}" }
|
||||
if (!driveRepository.downloadFile(accessToken, driveFile.id, destination)) {
|
||||
destination.delete()
|
||||
logDesktopCloudSync { "desktop.content_download.failed book=${remote.bookId} driveName=$driveName" }
|
||||
return null
|
||||
}
|
||||
val contentTimestamp = remote.fileContentModifiedTimestamp.takeIf { it > 0L } ?: destination.lastModified()
|
||||
if (contentTimestamp > 0L) destination.setLastModified(contentTimestamp)
|
||||
return remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy(
|
||||
val downloaded = remote.toDesktopBookItem(existing = existing, downloadedPath = destination.absolutePath).copy(
|
||||
fileSize = destination.length(),
|
||||
fileContentModifiedTimestamp = contentTimestamp
|
||||
)
|
||||
logDesktopCloudSync {
|
||||
"desktop.content_download.success book=${remote.bookId} bytes=${destination.length()} contentTs=$contentTimestamp " +
|
||||
downloaded.desktopCloudSyncSummary("downloaded")
|
||||
}
|
||||
return downloaded
|
||||
}
|
||||
|
||||
private suspend fun syncFonts(
|
||||
|
|
@ -447,18 +830,28 @@ internal class DesktopCloudSync(
|
|||
|
||||
internal fun BookItem.toDesktopCloudBookMetadata(
|
||||
hasAnnotations: Boolean,
|
||||
timestamp: Long = this.timestamp
|
||||
timestamp: Long = this.timestamp,
|
||||
annotationModifiedTimestamp: Long = 0L,
|
||||
contentTimestampOverride: Long? = null
|
||||
): 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 position = readerPosition.takeIf { type.usesCloudLocatorMetadata() }
|
||||
val supportsReaderAnnotations = type.usesCloudLocatorMetadata()
|
||||
val bookmarksJson = desktopPdfBookmarksMetadataJson(this)
|
||||
?: if (supportsReaderAnnotations) {
|
||||
readerBookmarks
|
||||
.mapNotNull { it.toDesktopCloudEpubBookmarkOrNull() }
|
||||
.let(EpubAnnotationSerializer::bookmarksToJson)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val highlightsJson = if (supportsReaderAnnotations) {
|
||||
EpubAnnotationSerializer.highlightsToJson(readerHighlights)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val localFile = path?.let(::File)
|
||||
val contentTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L }
|
||||
val contentTimestamp = contentTimestampOverride
|
||||
?: fileContentModifiedTimestamp.takeIf { it > 0L }
|
||||
?: localFile?.takeIf { it.isFile }?.lastModified()
|
||||
?: 0L
|
||||
return DesktopCloudBookMetadata(
|
||||
|
|
@ -469,13 +862,15 @@ internal fun BookItem.toDesktopCloudBookMetadata(
|
|||
type = type.name,
|
||||
lastPositionCfi = position?.cloudPositionCfi(),
|
||||
lastChapterIndex = position?.chapterIndex,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
lastPage = position?.pageIndex ?: lastPageIndex,
|
||||
locatorBlockIndex = position?.blockIndex,
|
||||
locatorCharOffset = position?.charOffset,
|
||||
lastPage = if (type.usesCloudLocatorMetadata()) position?.pageIndex ?: lastPageIndex else lastPageIndex,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
isDeleted = false,
|
||||
lastModifiedTimestamp = timestamp,
|
||||
readingPositionModifiedTimestamp = effectiveCloudReadingPositionModifiedTimestamp(),
|
||||
annotationModifiedTimestamp = annotationModifiedTimestamp,
|
||||
bookmarksJson = bookmarksJson,
|
||||
hasAnnotations = hasAnnotations,
|
||||
fileContentModifiedTimestamp = contentTimestamp,
|
||||
|
|
@ -498,11 +893,39 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
|
|||
): BookItem {
|
||||
val type = fileType()
|
||||
val pageIndex = lastPage
|
||||
val locator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = pageIndex
|
||||
)
|
||||
val locator = if (type.usesCloudLocatorMetadata()) {
|
||||
ReaderLocator.fromLegacy(
|
||||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = pageIndex
|
||||
).withFallbacks(
|
||||
blockIndex = locatorBlockIndex,
|
||||
charOffset = locatorCharOffset
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val remoteReadingTimestamp = effectiveCloudReadingPositionModifiedTimestamp()
|
||||
val localReadingTimestamp = existing?.effectiveCloudReadingPositionModifiedTimestamp() ?: 0L
|
||||
val useRemoteReadingPosition = existing == null ||
|
||||
remoteReadingTimestamp > localReadingTimestamp ||
|
||||
(localReadingTimestamp == 0L && hasCloudReadingPosition())
|
||||
val restoredPageIndex = if (useRemoteReadingPosition) pageIndex ?: existing?.lastPageIndex else existing?.lastPageIndex
|
||||
val restoredReaderPosition = if (type.usesCloudLocatorMetadata()) {
|
||||
if (useRemoteReadingPosition) {
|
||||
locator?.takeIf {
|
||||
it.chapterIndex != null ||
|
||||
it.pageIndex != null ||
|
||||
it.cfi != null ||
|
||||
it.startOffset != null ||
|
||||
it.blockIndex != null
|
||||
} ?: existing?.readerPosition
|
||||
} else {
|
||||
existing?.readerPosition
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return BookItem(
|
||||
id = bookId,
|
||||
path = downloadedPath ?: existing?.path,
|
||||
|
|
@ -518,7 +941,7 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
|
|||
originalSeriesName = originalSeriesName ?: existing?.originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex ?: existing?.originalSeriesIndex,
|
||||
originalDescription = originalDescription ?: existing?.originalDescription,
|
||||
progressPercentage = progressPercentage ?: existing?.progressPercentage,
|
||||
progressPercentage = if (useRemoteReadingPosition) progressPercentage ?: existing?.progressPercentage else existing?.progressPercentage,
|
||||
isRecent = isRecent,
|
||||
fileSize = existing?.fileSize ?: 0L,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp.takeIf { it > 0L }
|
||||
|
|
@ -529,12 +952,10 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
|
|||
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,
|
||||
lastPageIndex = restoredPageIndex,
|
||||
readerPosition = restoredReaderPosition,
|
||||
readerSettings = existing?.readerSettings,
|
||||
readerBookmarks = if (bookmarksJson.isNullOrBlank()) {
|
||||
readerBookmarks = if (type == FileType.PDF || bookmarksJson.isNullOrBlank()) {
|
||||
existing?.readerBookmarks.orEmpty()
|
||||
} else {
|
||||
EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson).map { bookmark ->
|
||||
|
|
@ -552,10 +973,101 @@ internal fun DesktopCloudBookMetadata.toDesktopBookItem(
|
|||
} else {
|
||||
EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
|
||||
},
|
||||
pdfReaderViewport = existing?.pdfReaderViewport
|
||||
pdfReaderViewport = if (useRemoteReadingPosition) remotePdfViewport(existing, pageIndex) else existing?.pdfReaderViewport,
|
||||
readingPositionModifiedTimestamp = if (useRemoteReadingPosition) remoteReadingTimestamp else localReadingTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
internal fun BookItem.withCloudReadingPosition(remote: DesktopCloudBookMetadata): BookItem {
|
||||
val remoteType = remote.fileType()
|
||||
val pageIndex = remote.lastPage
|
||||
val locator = if (remoteType.usesCloudLocatorMetadata()) {
|
||||
ReaderLocator.fromLegacy(
|
||||
chapterIndex = remote.lastChapterIndex,
|
||||
cfi = remote.lastPositionCfi,
|
||||
pageIndex = pageIndex
|
||||
).withFallbacks(
|
||||
blockIndex = remote.locatorBlockIndex,
|
||||
charOffset = remote.locatorCharOffset
|
||||
).takeIf {
|
||||
it.chapterIndex != null ||
|
||||
it.pageIndex != null ||
|
||||
it.cfi != null ||
|
||||
it.startOffset != null ||
|
||||
it.blockIndex != null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return copy(
|
||||
lastPageIndex = pageIndex ?: lastPageIndex,
|
||||
readerPosition = if (remoteType.usesCloudLocatorMetadata()) locator ?: readerPosition else null,
|
||||
progressPercentage = remote.progressPercentage ?: progressPercentage,
|
||||
pdfReaderViewport = if (remoteType.usesCloudLocatorMetadata()) {
|
||||
pdfReaderViewport
|
||||
} else {
|
||||
remote.remotePdfViewport(this, pageIndex)
|
||||
},
|
||||
readingPositionModifiedTimestamp = remote.effectiveCloudReadingPositionModifiedTimestamp()
|
||||
)
|
||||
}
|
||||
|
||||
private fun DesktopCloudBookMetadata.remotePdfViewport(
|
||||
existing: BookItem?,
|
||||
pageIndex: Int?
|
||||
): SharedPdfReaderViewport? {
|
||||
if (fileType().usesCloudLocatorMetadata() || pageIndex == null) return existing?.pdfReaderViewport
|
||||
val base = existing?.pdfReaderViewport ?: SharedPdfReaderViewport()
|
||||
return base.copy(
|
||||
pageIndex = pageIndex,
|
||||
horizontalScrollOffset = 0,
|
||||
paginatedVerticalScrollOffset = 0,
|
||||
verticalFirstPageIndex = pageIndex,
|
||||
verticalFirstPageScrollOffset = 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun FileType.usesCloudLocatorMetadata(): Boolean {
|
||||
return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
|
||||
}
|
||||
|
||||
internal fun BookItem.hasCloudReadingPosition(): Boolean {
|
||||
return lastPageIndex != null ||
|
||||
readerPosition != null ||
|
||||
(progressPercentage ?: 0f) > 0f
|
||||
}
|
||||
|
||||
internal fun BookItem.effectiveCloudReadingPositionModifiedTimestamp(): Long {
|
||||
return readingPositionModifiedTimestamp.takeIf { it > 0L }
|
||||
?: timestamp.takeIf { hasCloudReadingPosition() }
|
||||
?: 0L
|
||||
}
|
||||
|
||||
internal fun DesktopCloudBookMetadata.hasCloudReadingPosition(): Boolean {
|
||||
return lastChapterIndex != null ||
|
||||
lastPage != null ||
|
||||
!lastPositionCfi.isNullOrBlank() ||
|
||||
locatorBlockIndex != null ||
|
||||
locatorCharOffset != null ||
|
||||
(progressPercentage ?: 0f) > 0f
|
||||
}
|
||||
|
||||
internal fun DesktopCloudBookMetadata.effectiveCloudReadingPositionModifiedTimestamp(): Long {
|
||||
return readingPositionModifiedTimestamp.takeIf { it > 0L }
|
||||
?: lastModifiedTimestamp.takeIf { hasCloudReadingPosition() }
|
||||
?: 0L
|
||||
}
|
||||
|
||||
internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(): Long {
|
||||
return annotationModifiedTimestamp.takeIf { it > 0L }
|
||||
?: 0L
|
||||
}
|
||||
|
||||
internal fun DesktopCloudBookMetadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long): Long {
|
||||
return sidecarModifiedTimestamp.takeIf { it > 0L }
|
||||
?: effectiveCloudAnnotationModifiedTimestamp()
|
||||
}
|
||||
|
||||
internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetadata {
|
||||
return DesktopCloudFontMetadata(
|
||||
id = id,
|
||||
|
|
@ -568,8 +1080,7 @@ internal fun CustomFontItem.toDesktopCloudFontMetadata(): DesktopCloudFontMetada
|
|||
}
|
||||
|
||||
internal fun desktopCloudBookDriveFileName(bookId: String, type: FileType): String? {
|
||||
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
|
||||
return "$bookId.$extension"
|
||||
return sharedCloudBookContentFileName(bookId, type)
|
||||
}
|
||||
|
||||
private data class DesktopCloudShelfRecord(
|
||||
|
|
@ -613,18 +1124,53 @@ private fun shouldDownloadRemoteBookContent(local: BookItem, remote: DesktopClou
|
|||
?: 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)
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = localFile?.isFile == true,
|
||||
localContentModifiedTimestamp = localTimestamp,
|
||||
remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp,
|
||||
remoteDeleted = remote.isDeleted
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldUploadLocalBookContent(local: BookItem, remote: DesktopCloudBookMetadata?): Boolean {
|
||||
val localFile = local.path?.let(::File)?.takeIf { it.isFile } ?: return false
|
||||
val localTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L } ?: localFile.lastModified()
|
||||
return local.sourceFolder == null &&
|
||||
localTimestamp > 0L &&
|
||||
localTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L)
|
||||
shouldUploadLocalCloudBookContent(
|
||||
localFileAvailable = true,
|
||||
localContentModifiedTimestamp = localTimestamp,
|
||||
remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldUploadLocalAnnotations(
|
||||
local: BookItem,
|
||||
remote: DesktopCloudBookMetadata?,
|
||||
remoteAnnotationModifiedTimestamp: Long = remote?.effectiveCloudAnnotationModifiedTimestamp() ?: 0L,
|
||||
localSidecarTimestamp: Long = DesktopCloudSidecarSync.localAnnotationTimestamp(local)
|
||||
): Boolean {
|
||||
return DesktopCloudSidecarSync.hasLocalAnnotationData(local) &&
|
||||
(remote == null || !remote.hasAnnotations || localSidecarTimestamp > remoteAnnotationModifiedTimestamp)
|
||||
}
|
||||
|
||||
private fun remoteAnnotationDriveFileTimestamp(
|
||||
bookId: String,
|
||||
driveFiles: Map<String, DesktopDriveFile>
|
||||
): Long {
|
||||
return driveFiles[desktopCloudAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L
|
||||
}
|
||||
|
||||
internal fun desktopCloudAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"
|
||||
|
||||
private fun BookItem.withDownloadedCloudContent(downloaded: BookItem?, replacePath: Boolean = true): BookItem {
|
||||
if (downloaded == null) return this
|
||||
return copy(
|
||||
path = if (replacePath) downloaded.path ?: path else path,
|
||||
fileSize = downloaded.fileSize.takeIf { it > 0L } ?: fileSize,
|
||||
fileContentModifiedTimestamp = downloaded.fileContentModifiedTimestamp.takeIf { it > 0L }
|
||||
?: fileContentModifiedTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun desktopShelfTimestamp(record: ShelfRecord, refs: List<BookShelfRef>): Long {
|
||||
|
|
@ -634,17 +1180,7 @@ private fun desktopShelfTimestamp(record: ShelfRecord, refs: List<BookShelfRef>)
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
return toStablePositionCfi()
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toDesktopCloudEpubBookmarkOrNull(): EpubBookmark? {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
internal const val DesktopCloudSyncLogTag = "EpistemeCloudSync"
|
||||
internal const val DesktopCloudAnnotationSyncLogTag = "EpistemeCloudAnnotations"
|
||||
|
||||
internal fun logDesktopCloudSync(message: () -> String) {
|
||||
logDesktopDiagnostic(DesktopCloudSyncLogTag, message)
|
||||
}
|
||||
|
||||
internal fun logDesktopCloudAnnotations(message: () -> String) {
|
||||
logDesktopDiagnostic(DesktopCloudAnnotationSyncLogTag, message)
|
||||
}
|
||||
|
||||
internal fun BookItem.desktopCloudSyncSummary(prefix: String = "local"): String {
|
||||
val position = readerPosition
|
||||
val page = if (type.usesCloudLocatorForDiagnostics()) {
|
||||
position?.pageIndex ?: lastPageIndex
|
||||
} else {
|
||||
lastPageIndex
|
||||
}
|
||||
return "$prefix{id=$id type=$type ts=$timestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
|
||||
"contentTs=$fileContentModifiedTimestamp " +
|
||||
"page=$page chapter=${position?.chapterIndex} " +
|
||||
"block=${position?.blockIndex} char=${position?.charOffset} progress=$progressPercentage " +
|
||||
"cfi=${position?.cfi.cloudSyncPreview()} sourceFolder=${sourceFolder != null} " +
|
||||
"bookmarks=${readerBookmarks.size} highlights=${readerHighlights.size}}"
|
||||
}
|
||||
|
||||
internal fun DesktopCloudBookMetadata.desktopCloudSyncSummary(prefix: String = "remote"): String {
|
||||
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveCloudReadingPositionModifiedTimestamp()} " +
|
||||
"annTs=${effectiveCloudAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
|
||||
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
|
||||
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted " +
|
||||
"recent=$isRecent hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
|
||||
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
|
||||
}
|
||||
|
||||
internal fun BookItem.hasSameCloudReaderPosition(other: BookItem): Boolean {
|
||||
val thisPage = if (type.usesCloudLocatorForDiagnostics()) readerPosition?.pageIndex ?: lastPageIndex else lastPageIndex
|
||||
val otherPage = if (other.type.usesCloudLocatorForDiagnostics()) {
|
||||
other.readerPosition?.pageIndex ?: other.lastPageIndex
|
||||
} else {
|
||||
other.lastPageIndex
|
||||
}
|
||||
val thisProgress = progressPercentage
|
||||
val otherProgress = other.progressPercentage
|
||||
val progressMatches = when {
|
||||
thisProgress == null && otherProgress == null -> true
|
||||
thisProgress != null && otherProgress != null -> kotlin.math.abs(thisProgress - otherProgress) < 0.001f
|
||||
else -> false
|
||||
}
|
||||
val locatorMatches = if (type.usesCloudLocatorForDiagnostics() || other.type.usesCloudLocatorForDiagnostics()) {
|
||||
readerPosition == other.readerPosition
|
||||
} else {
|
||||
true
|
||||
}
|
||||
return thisPage == otherPage &&
|
||||
locatorMatches &&
|
||||
progressMatches
|
||||
}
|
||||
|
||||
private fun com.aryan.reader.shared.FileType.usesCloudLocatorForDiagnostics(): Boolean {
|
||||
return this != FileType.PDF && this != FileType.PPTX && !SharedFileCapabilities.isComicArchive(this)
|
||||
}
|
||||
|
||||
private fun String?.cloudSyncPreview(maxLength: Int = 80): String {
|
||||
val value = this ?: return "null"
|
||||
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
|
||||
}
|
||||
|
||||
private fun String?.cloudSyncAnnotationSummary(): String {
|
||||
val value = this?.trim() ?: return "null"
|
||||
return when {
|
||||
value.isEmpty() -> "blank"
|
||||
value == "[]" -> "empty"
|
||||
else -> "present(${value.length})"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.opds.OpdsCatalog
|
||||
import com.aryan.reader.shared.opds.OpdsStreamReference
|
||||
import com.sun.jna.Library
|
||||
|
|
@ -8,8 +9,9 @@ import com.sun.jna.Native
|
|||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.ptr.PointerByReference
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import java.awt.Font
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import java.awt.Color
|
||||
import java.awt.Font
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayInputStream
|
||||
|
|
@ -24,7 +26,7 @@ import javax.imageio.ImageIO
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
internal object DesktopComicArchive {
|
||||
private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
private val comicTypes = SharedFileCapabilities.comicArchiveTypes
|
||||
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
|
||||
fun canLoad(type: FileType): Boolean = type in comicTypes
|
||||
|
|
@ -36,6 +38,7 @@ internal object DesktopComicArchive {
|
|||
FileType.CBZ -> loadZip(file)
|
||||
FileType.CBR -> loadRar(file)
|
||||
FileType.CB7 -> loadSevenZ(file)
|
||||
FileType.CBT -> loadTar(file)
|
||||
else -> error("${type.name} is not a comic archive type.")
|
||||
}
|
||||
}
|
||||
|
|
@ -162,6 +165,31 @@ internal object DesktopComicArchive {
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadTar(file: File): DesktopComicDocument {
|
||||
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||
return try {
|
||||
val extracted = mutableListOf<ExtractedComicPage>()
|
||||
TarArchiveInputStream(file.inputStream().buffered()).use { archive ->
|
||||
var entry = archive.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.orEmpty()
|
||||
if (!entry.isDirectory && name.isComicImageName()) {
|
||||
val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}")
|
||||
target.outputStream().use { output ->
|
||||
archive.copyTo(output)
|
||||
}
|
||||
extracted += ExtractedComicPage(name = name, file = target)
|
||||
}
|
||||
entry = archive.nextEntry
|
||||
}
|
||||
}
|
||||
documentFromExtracted(file, extracted, tempDir)
|
||||
} catch (throwable: Throwable) {
|
||||
runCatching { tempDir.deleteRecursively() }
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
|
||||
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||
return try {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,40 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||
private const val DesktopDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
||||
private const val DesktopDiagnosticsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS"
|
||||
private const val DesktopDiagnosticsTagsEnv = "EPISTEME_DESKTOP_DIAGNOSTICS_TAGS"
|
||||
|
||||
private val DesktopDiagnosticTags: Set<String> =
|
||||
listOfNotNull(
|
||||
System.getProperty(DesktopDiagnosticsTagsProperty),
|
||||
System.getenv(DesktopDiagnosticsTagsEnv)
|
||||
)
|
||||
.joinToString(" ")
|
||||
.split(',', ';', ' ', '\t', '\n')
|
||||
.mapNotNull { rawTag ->
|
||||
rawTag.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.lowercase()
|
||||
}
|
||||
.toSet()
|
||||
|
||||
internal val DesktopDiagnosticsEnabled: Boolean =
|
||||
desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty))
|
||||
desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty)) ||
|
||||
desktopDiagnosticsFlag(System.getenv(DesktopDiagnosticsEnv)) ||
|
||||
DesktopDiagnosticTags.isNotEmpty()
|
||||
|
||||
internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean {
|
||||
return rawValue?.trim()?.equals("true", ignoreCase = true) == true
|
||||
}
|
||||
|
||||
internal inline fun logDesktopDiagnostic(tag: String, message: () -> String) {
|
||||
if (DesktopDiagnosticsEnabled) {
|
||||
private fun isDesktopDiagnosticTagEnabled(tag: String): Boolean {
|
||||
if (DesktopDiagnosticTags.isEmpty()) return true
|
||||
return "*" in DesktopDiagnosticTags || tag.lowercase() in DesktopDiagnosticTags
|
||||
}
|
||||
|
||||
internal fun logDesktopDiagnostic(tag: String, message: () -> String) {
|
||||
if (DesktopDiagnosticsEnabled && isDesktopDiagnosticTagEnabled(tag)) {
|
||||
println("$tag ${message()}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.toStableReaderPositionCfi
|
||||
import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
|
|
@ -46,7 +47,8 @@ internal data class DesktopEpubHandledLink(
|
|||
internal enum class DesktopReaderSelectionAction {
|
||||
DEFINE,
|
||||
SPEAK,
|
||||
SEARCH
|
||||
SEARCH,
|
||||
PALETTE
|
||||
}
|
||||
|
||||
internal enum class DesktopReaderKeyNavigation {
|
||||
|
|
@ -59,7 +61,10 @@ internal enum class DesktopReaderKeyNavigation {
|
|||
EXIT_FULLSCREEN
|
||||
}
|
||||
|
||||
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? {
|
||||
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(
|
||||
fullscreen: Boolean,
|
||||
rightToLeftPagination: Boolean = false
|
||||
): DesktopReaderKeyNavigation? {
|
||||
if (id != AwtKeyEvent.KEY_PRESSED) return null
|
||||
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
|
||||
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||
|
|
@ -71,9 +76,17 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean):
|
|||
return DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||
}
|
||||
return when (keyCode) {
|
||||
AwtKeyEvent.VK_RIGHT,
|
||||
AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) {
|
||||
DesktopReaderKeyNavigation.PREVIOUS
|
||||
} else {
|
||||
DesktopReaderKeyNavigation.NEXT
|
||||
}
|
||||
AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) {
|
||||
DesktopReaderKeyNavigation.NEXT
|
||||
} else {
|
||||
DesktopReaderKeyNavigation.PREVIOUS
|
||||
}
|
||||
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
|
||||
AwtKeyEvent.VK_LEFT,
|
||||
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
|
||||
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
|
||||
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
|
||||
|
|
@ -83,7 +96,8 @@ internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean):
|
|||
|
||||
internal data class DesktopReaderSelectionActionPayload(
|
||||
val action: DesktopReaderSelectionAction,
|
||||
val text: String
|
||||
val text: String,
|
||||
val locator: ReaderLocator? = null
|
||||
)
|
||||
|
||||
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
|
||||
|
|
@ -128,9 +142,27 @@ internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionP
|
|||
"define" -> DesktopReaderSelectionAction.DEFINE
|
||||
"speak" -> DesktopReaderSelectionAction.SPEAK
|
||||
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
|
||||
"palette" -> DesktopReaderSelectionAction.PALETTE
|
||||
else -> return@runCatching null
|
||||
}
|
||||
DesktopReaderSelectionActionPayload(action, text)
|
||||
val locator = obj["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonObject
|
||||
?.let { locatorObj ->
|
||||
ReaderLocator(
|
||||
chapterIndex = locatorObj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
chapterId = locatorObj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
href = locatorObj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
pageIndex = locatorObj["pageIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
startOffset = locatorObj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
endOffset = locatorObj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
blockIndex = locatorObj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
charOffset = locatorObj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
textQuote = locatorObj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
cfi = locatorObj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
|
||||
)
|
||||
}
|
||||
DesktopReaderSelectionActionPayload(action, text, locator)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
|
|
@ -181,11 +213,15 @@ internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
|
|||
?: return@runCatching null
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
href = obj["href"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
blockIndex = obj["blockIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
charOffset = obj["charOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toStableReaderPositionCfi()
|
||||
)
|
||||
DesktopReaderPosition(pageIndex, locator)
|
||||
}.getOrNull()
|
||||
|
|
@ -284,9 +320,7 @@ private fun String.readerHrefFromIntercept(): String? {
|
|||
val trimmed = trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
if (trimmed.equals("about:blank", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file://", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file:/", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
|
||||
if (trimmed.startsWith("data:", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
|
||||
|
|
@ -298,9 +332,13 @@ internal fun ReaderLocator.toReaderLocatorJson(): String {
|
|||
append("{")
|
||||
val values = buildList {
|
||||
chapterIndex?.let { add("\"chapterIndex\":$it") }
|
||||
chapterId?.let { add("\"chapterId\":${it.toJsonStringLiteral()}") }
|
||||
href?.let { add("\"href\":${it.toJsonStringLiteral()}") }
|
||||
pageIndex?.let { add("\"pageIndex\":$it") }
|
||||
startOffset?.let { add("\"startOffset\":$it") }
|
||||
endOffset?.let { add("\"endOffset\":$it") }
|
||||
blockIndex?.let { add("\"blockIndex\":$it") }
|
||||
charOffset?.let { add("\"charOffset\":$it") }
|
||||
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
|
||||
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.reader.ReaderLayoutSignature
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderViewportSpec
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
|
||||
|
|
@ -28,6 +30,44 @@ internal data class DesktopEpubPaginationDensity(
|
|||
val fontScale: Float
|
||||
)
|
||||
|
||||
internal fun desktopMeasuredPaginationReady(
|
||||
request: DesktopEpubPaginationRequest?,
|
||||
completedRequest: DesktopEpubPaginationRequest?,
|
||||
currentPages: List<ReaderPage>,
|
||||
measuredPages: List<ReaderPage>
|
||||
): Boolean {
|
||||
return request != null &&
|
||||
completedRequest == request &&
|
||||
measuredPages.isNotEmpty() &&
|
||||
currentPages.samePageLayoutAs(measuredPages)
|
||||
}
|
||||
|
||||
internal fun desktopPaginatedLayoutReadyForDisplay(
|
||||
readingMode: ReaderReadingMode,
|
||||
measuredPagesApplied: Boolean
|
||||
): Boolean {
|
||||
return readingMode != ReaderReadingMode.PAGINATED || measuredPagesApplied
|
||||
}
|
||||
|
||||
internal fun desktopPagesWithMeasuredChapter(
|
||||
currentPages: List<ReaderPage>,
|
||||
chapterIndex: Int,
|
||||
measuredChapterPages: List<ReaderPage>
|
||||
): List<ReaderPage> {
|
||||
if (currentPages.isEmpty() || measuredChapterPages.isEmpty()) return currentPages
|
||||
val firstChapterPage = currentPages.indexOfFirst { it.chapterIndex == chapterIndex }
|
||||
if (firstChapterPage < 0) return currentPages
|
||||
val lastChapterPage = currentPages.indexOfLast { it.chapterIndex == chapterIndex }
|
||||
val combined = currentPages.take(firstChapterPage) +
|
||||
measuredChapterPages +
|
||||
currentPages.drop(lastChapterPage + 1)
|
||||
return combined.mapIndexed { index, page -> page.copy(pageIndex = index) }
|
||||
}
|
||||
|
||||
internal fun List<ReaderPage>.firstPageIndexForChapter(chapterIndex: Int): Int? {
|
||||
return indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
internal fun SharedEpubBook.desktopPaginationContentSignature(): Int {
|
||||
return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter ->
|
||||
31 * acc +
|
||||
|
|
|
|||
|
|
@ -1,57 +1,85 @@
|
|||
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 androidx.compose.ui.graphics.Color
|
||||
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,
|
||||
highlightPaletteScript: String,
|
||||
navigationTarget: ReaderContentNavigationTarget,
|
||||
highlights: List<UserHighlight>,
|
||||
onHighlightCreated: (UserHighlight) -> Unit,
|
||||
onHighlightSelected: (String) -> Unit,
|
||||
isFullscreen: Boolean,
|
||||
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
|
||||
onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit,
|
||||
onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
|
||||
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
|
||||
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
|
||||
onPointerActivity: () -> Unit = {},
|
||||
networkAccessEnabled: Boolean,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val backend = desktopEpubWebViewBackend()
|
||||
LaunchedEffect(html, networkAccessEnabled, highlights.size, navigationTarget.readingMode, backend) {
|
||||
logDesktopWebView2(
|
||||
"backend_selected backend=${backend.logName} htmlChars=${html.length} htmlHash=${html.hashCode()} " +
|
||||
"network=$networkAccessEnabled highlights=${highlights.size} navMode=${navigationTarget.readingMode}"
|
||||
)
|
||||
logDesktopReaderOpenTrace {
|
||||
"event=desktop_webview_selected backend=${backend.logName} htmlChars=${html.length} " +
|
||||
"htmlHash=${html.hashCode()} network=$networkAccessEnabled highlights=${highlights.size} " +
|
||||
"navMode=${navigationTarget.readingMode}"
|
||||
}
|
||||
}
|
||||
DesktopNativeSwtEpubWebView(
|
||||
html = html,
|
||||
appearanceScript = appearanceScript,
|
||||
highlightPaletteScript = highlightPaletteScript,
|
||||
navigationTarget = navigationTarget,
|
||||
highlights = highlights,
|
||||
onHighlightCreated = onHighlightCreated,
|
||||
onHighlightSelected = onHighlightSelected,
|
||||
isFullscreen = isFullscreen,
|
||||
onKeyboardNavigation = onKeyboardNavigation,
|
||||
onSelectionAction = onSelectionAction,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onVisiblePageChanged = onVisiblePageChanged,
|
||||
onPointerActivity = onPointerActivity,
|
||||
networkAccessEnabled = networkAccessEnabled,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
internal data class DesktopEpubBridgeHandler(
|
||||
val methodName: String,
|
||||
val onMessage: (String) -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun rememberDesktopEpubBridgeHandlers(
|
||||
onHighlightCreated: (UserHighlight) -> Unit,
|
||||
onHighlightSelected: (String) -> Unit,
|
||||
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
|
||||
onSelectionAction: (DesktopReaderSelectionActionPayload) -> Unit,
|
||||
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
|
||||
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
|
||||
onPointerActivity: () -> Unit
|
||||
): List<DesktopEpubBridgeHandler> {
|
||||
val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated)
|
||||
val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected)
|
||||
val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation)
|
||||
|
|
@ -60,81 +88,98 @@ internal fun DesktopEpubWebView(
|
|||
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)
|
||||
return remember(scope) {
|
||||
listOf(
|
||||
DesktopEpubBridgeHandler("readerHighlightCreated") { params ->
|
||||
logEpubHighlightFlow("bridge_received method=readerHighlightCreated params=\"${params.logPreview(900)}\"")
|
||||
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(params)
|
||||
if (highlight == null) {
|
||||
logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}")
|
||||
logEpubHighlightFlow("bridge_parse_failed method=readerHighlightCreated")
|
||||
logEpubSelectionDebug("highlight_parse_failed params=${params.logPreview(900)}")
|
||||
} else {
|
||||
logEpubHighlightFlow(
|
||||
"bridge_parse_success id=${highlight.id} color=${highlight.color.id} " +
|
||||
"chapter=${highlight.chapterIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
|
||||
"page=${highlight.locator.pageIndex} textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
|
||||
)
|
||||
logDesktopHighlightMap(
|
||||
"bridge_highlight_created id=${highlight.id} color=${highlight.color.id} " +
|
||||
"chapter=${highlight.chapterIndex} locatorChapter=${highlight.locator.chapterIndex} " +
|
||||
"page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
|
||||
"block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " +
|
||||
"chapterId=${highlight.locator.chapterId.orEmpty().logPreview()} href=${highlight.locator.href.orEmpty().logPreview()} " +
|
||||
"textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview()}\""
|
||||
)
|
||||
scope.launch { latestOnHighlightCreated(highlight) }
|
||||
}
|
||||
},
|
||||
desktopEpubBridgeHandler("readerHighlightClicked") { message ->
|
||||
message.params.readerHighlightClickOrNull()?.let { highlightClick ->
|
||||
DesktopEpubBridgeHandler("readerHighlightClicked") { params ->
|
||||
params.readerHighlightClickOrNull()?.let { highlightClick ->
|
||||
scope.launch { latestOnHighlightSelected(highlightClick.highlightId) }
|
||||
}
|
||||
},
|
||||
desktopEpubBridgeHandler("readerPositionChanged") { message ->
|
||||
message.params.readerPositionOrNull()?.let { position ->
|
||||
DesktopEpubBridgeHandler("readerPositionChanged") { params ->
|
||||
params.readerPositionOrNull()?.let { position ->
|
||||
logDesktopPositionTrace(
|
||||
"event=bridge_position_changed page=${position.pageIndex} " +
|
||||
"locator=${position.locator.desktopPositionTraceSummary()}"
|
||||
)
|
||||
logDesktopHighlightMap(
|
||||
"bridge_position_changed page=${position.pageIndex} chapter=${position.locator?.chapterIndex} " +
|
||||
"offsets=${position.locator?.startOffset}..${position.locator?.endOffset} " +
|
||||
"block=${position.locator?.blockIndex} char=${position.locator?.charOffset} " +
|
||||
"chapterId=${position.locator?.chapterId.orEmpty().logPreview()} href=${position.locator?.href.orEmpty().logPreview()} " +
|
||||
"text=\"${position.locator?.textQuote.orEmpty().logPreview(120)}\" " +
|
||||
"cfi=\"${position.locator?.cfi.orEmpty().logPreview(160)}\""
|
||||
)
|
||||
logDesktopTtsStartTrace {
|
||||
"event=bridge_position_changed page=${position.pageIndex} " +
|
||||
"locator=${position.locator.desktopPositionTraceSummary(160)}"
|
||||
}
|
||||
scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) }
|
||||
}
|
||||
},
|
||||
desktopEpubBridgeHandler("readerSelectionAction") { message ->
|
||||
val selectionAction = message.params.readerSelectionActionOrNull()
|
||||
DesktopEpubBridgeHandler("readerDesktopPositionTraceLog") { params ->
|
||||
logDesktopPositionTrace(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||
},
|
||||
DesktopEpubBridgeHandler("readerTtsStartTraceLog") { params ->
|
||||
logDesktopTtsStartTrace { params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900) }
|
||||
},
|
||||
DesktopEpubBridgeHandler("readerSelectionAction") { params ->
|
||||
val selectionAction = params.readerSelectionActionOrNull()
|
||||
if (selectionAction != null) {
|
||||
scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) }
|
||||
scope.launch { latestOnSelectionAction(selectionAction) }
|
||||
}
|
||||
},
|
||||
desktopEpubBridgeHandler("readerKeyNavigation") { message ->
|
||||
message.params.readerKeyNavigationOrNull()?.let { action ->
|
||||
DesktopEpubBridgeHandler("readerKeyNavigation") { params ->
|
||||
params.readerKeyNavigationOrNull()?.let { action ->
|
||||
scope.launch { latestOnKeyboardNavigation(action) }
|
||||
}
|
||||
},
|
||||
desktopEpubBridgeHandler("readerPointerActivity") { _ ->
|
||||
DesktopEpubBridgeHandler("readerPointerActivity") {
|
||||
scope.launch { latestOnPointerActivity() }
|
||||
},
|
||||
desktopEpubBridgeHandler("readerTtsHighlightLog") { message ->
|
||||
logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}")
|
||||
DesktopEpubBridgeHandler("readerTtsHighlightLog") { params ->
|
||||
logDesktopTts("epub_highlight_js ${params.logPreview(500)}")
|
||||
},
|
||||
desktopEpubBridgeHandler("readerSelectionDebugLog") { message ->
|
||||
logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900))
|
||||
DesktopEpubBridgeHandler("readerSelectionDebugLog") { params ->
|
||||
logEpubSelectionDebug(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||
},
|
||||
desktopEpubBridgeHandler("readerPaginationLayoutLog") { message ->
|
||||
logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
|
||||
DesktopEpubBridgeHandler("readerHighlightFlowLog") { params ->
|
||||
logEpubHighlightFlow(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||
},
|
||||
desktopEpubBridgeHandler("readerGapLayoutLog") { message ->
|
||||
logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
|
||||
DesktopEpubBridgeHandler("readerDesktopHighlightMapLog") { params ->
|
||||
logDesktopHighlightMap(params.readerSelectionDebugMessageOrNull() ?: params.logPreview(900))
|
||||
},
|
||||
desktopEpubBridgeHandler("readerLinkClicked") { message ->
|
||||
logEpubLink("bridge_message params=\"${message.params.logPreview()}\"")
|
||||
val link = message.params.readerLinkClickOrNull()
|
||||
DesktopEpubBridgeHandler("readerPaginationLayoutLog") { params ->
|
||||
logEpubPagination(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
|
||||
},
|
||||
DesktopEpubBridgeHandler("readerGapLayoutLog") { params ->
|
||||
logReaderGap(params.readerPaginationLogMessageOrNull() ?: params.logPreview(900))
|
||||
},
|
||||
DesktopEpubBridgeHandler("readerLinkClicked") { params ->
|
||||
logEpubLink("bridge_message params=\"${params.logPreview()}\"")
|
||||
val link = params.readerLinkClickOrNull()
|
||||
if (link == null) {
|
||||
logEpubLink("bridge_message_ignored reason=parse_failed")
|
||||
} else {
|
||||
|
|
@ -146,214 +191,70 @@ internal fun DesktopEpubWebView(
|
|||
}
|
||||
}
|
||||
)
|
||||
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 = """
|
||||
internal 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.readerDesktopChromeTapInstalled) {
|
||||
window.readerDesktopChromeTapInstalled = true;
|
||||
var chromeTapStart = null;
|
||||
var lastChromeTapNotifiedAt = 0;
|
||||
function notifyChromeTap() {
|
||||
if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
|
||||
window.kmpJsBridge.callNative('readerPointerActivity', '{}');
|
||||
lastChromeTapNotifiedAt = Date.now();
|
||||
}
|
||||
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);
|
||||
function chromeTapIgnored(target) {
|
||||
if (!target || !target.closest) return false;
|
||||
return !!target.closest(
|
||||
'a[href], button, input, textarea, select, [contenteditable="true"], #reader-selection-menu, .reader-selection-handle'
|
||||
);
|
||||
}
|
||||
function hasActiveReaderSelection() {
|
||||
var selection = window.getSelection && window.getSelection();
|
||||
return !!selection && selection.toString().trim().length > 0;
|
||||
}
|
||||
function beginChromeTap(event) {
|
||||
if (event.button !== undefined && event.button !== 0) return;
|
||||
if (chromeTapIgnored(event.target)) {
|
||||
chromeTapStart = null;
|
||||
return;
|
||||
}
|
||||
chromeTapStart = {
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX || 0,
|
||||
y: event.clientY || 0,
|
||||
at: Date.now()
|
||||
};
|
||||
}
|
||||
function finishChromeTap(event) {
|
||||
if (!chromeTapStart) return;
|
||||
if (event.pointerId !== undefined && chromeTapStart.pointerId !== undefined && event.pointerId !== chromeTapStart.pointerId) return;
|
||||
var dx = (event.clientX || 0) - chromeTapStart.x;
|
||||
var dy = (event.clientY || 0) - chromeTapStart.y;
|
||||
var elapsed = Date.now() - chromeTapStart.at;
|
||||
chromeTapStart = null;
|
||||
if ((dx * dx + dy * dy) > 64 || elapsed > 650) return;
|
||||
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
|
||||
notifyChromeTap();
|
||||
}
|
||||
function maybeNotifyChromeTapFromClick(event) {
|
||||
if (Date.now() - lastChromeTapNotifiedAt < 250) return;
|
||||
if (chromeTapIgnored(event.target) || hasActiveReaderSelection()) return;
|
||||
notifyChromeTap();
|
||||
}
|
||||
document.addEventListener('pointerdown', beginChromeTap, true);
|
||||
document.addEventListener('pointerup', finishChromeTap, true);
|
||||
document.addEventListener('pointercancel', function () { chromeTapStart = null; }, true);
|
||||
document.addEventListener('click', function (event) {
|
||||
if (window.PointerEvent) {
|
||||
maybeNotifyChromeTapFromClick(event);
|
||||
return;
|
||||
}
|
||||
beginChromeTap(event);
|
||||
finishChromeTap(event);
|
||||
}, true);
|
||||
}
|
||||
if (window.readerDesktopKeyNavigationInstalled) return;
|
||||
window.readerDesktopKeyNavigationInstalled = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
internal data class DesktopFeatureNoticePlacement(
|
||||
val readerWindowId: String? = null
|
||||
) {
|
||||
fun rendersInMainWindow(): Boolean = readerWindowId == null
|
||||
|
||||
fun rendersInReaderWindow(windowId: String): Boolean = readerWindowId == windowId
|
||||
}
|
||||
|
||||
internal fun desktopFeatureNoticePlacement(readerWindowId: String?): DesktopFeatureNoticePlacement {
|
||||
return DesktopFeatureNoticePlacement(readerWindowId = readerWindowId?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ internal class DesktopFirebaseAuthRepository(
|
|||
googleAccessTokenExpiresAtEpochMillis = googleTokens.expiresAtEpochMillis
|
||||
)
|
||||
session = nextSession
|
||||
store.save(nextSession)
|
||||
persistSession(nextSession)
|
||||
return nextSession
|
||||
}
|
||||
|
||||
|
|
@ -86,24 +86,30 @@ internal class DesktopFirebaseAuthRepository(
|
|||
val current = session ?: store.load()?.also { session = it } ?: return null
|
||||
if (current.isGoogleAccessTokenFresh) return current.googleAccessToken
|
||||
if (current.googleRefreshToken.isBlank()) return null
|
||||
return runCatching {
|
||||
val refreshed = runCatching {
|
||||
refreshGoogleAccessToken(current)
|
||||
}.onSuccess { refreshed ->
|
||||
session = refreshed
|
||||
store.save(refreshed)
|
||||
}.getOrNull()?.googleAccessToken
|
||||
}.getOrNull() ?: return null
|
||||
session = refreshed
|
||||
persistSession(refreshed)
|
||||
return refreshed.googleAccessToken
|
||||
}
|
||||
|
||||
private suspend fun refreshSessionIfNeeded(current: DesktopAuthSession): DesktopAuthSession? {
|
||||
if (current.isFresh) return current
|
||||
return runCatching {
|
||||
val refreshed = runCatching {
|
||||
refreshFirebaseSession(current)
|
||||
}.onSuccess { refreshed ->
|
||||
session = refreshed
|
||||
store.save(refreshed)
|
||||
}.onFailure {
|
||||
signOut()
|
||||
}.getOrNull()
|
||||
}.getOrNull() ?: return null
|
||||
session = refreshed
|
||||
persistSession(refreshed)
|
||||
return refreshed
|
||||
}
|
||||
|
||||
private suspend fun persistSession(session: DesktopAuthSession) {
|
||||
withContext(Dispatchers.IO) {
|
||||
store.save(session)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestGoogleOAuthCode(openUrl: (String) -> Unit): DesktopOAuthCode = withContext(Dispatchers.IO) {
|
||||
|
|
@ -364,24 +370,19 @@ internal class DesktopAuthStore(
|
|||
}
|
||||
|
||||
fun save(session: DesktopAuthSession) {
|
||||
val protectedRefreshToken = protectRequired(RefreshTokenKey, session.refreshToken)
|
||||
val protectedGoogleRefreshToken = session.googleRefreshToken
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { protectRequired(GoogleRefreshTokenKey, it) }
|
||||
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")
|
||||
setProperty(RefreshTokenKey, protectedRefreshToken)
|
||||
protectedGoogleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) }
|
||||
}
|
||||
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account")
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
|
|
@ -394,6 +395,17 @@ internal class DesktopAuthStore(
|
|||
const val RefreshTokenKey = "firebaseRefreshTokenProtected"
|
||||
const val GoogleRefreshTokenKey = "googleRefreshTokenProtected"
|
||||
}
|
||||
|
||||
private fun protectRequired(keyName: String, value: String): String {
|
||||
if (value.isBlank()) {
|
||||
throw IllegalArgumentException("Cannot save a desktop account without a refresh token.")
|
||||
}
|
||||
val protectedValue = secretCodec.protect(keyName, value)
|
||||
if (protectedValue.isBlank()) {
|
||||
throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.")
|
||||
}
|
||||
return protectedValue
|
||||
}
|
||||
}
|
||||
|
||||
private val DesktopAuthJson = Json { ignoreUnknownKeys = true }
|
||||
|
|
|
|||
|
|
@ -59,6 +59,16 @@ object DesktopFolderMetadataExtractor {
|
|||
return enrichBooks(books) { book -> book.sourceFolder == sourceFolder }
|
||||
}
|
||||
|
||||
fun enrichFolderBooks(
|
||||
books: List<BookItem>,
|
||||
sourceFolders: Set<String>
|
||||
): DesktopFolderMetadataExtractionResult {
|
||||
if (sourceFolders.isEmpty()) {
|
||||
return DesktopFolderMetadataExtractionResult(books)
|
||||
}
|
||||
return enrichBooks(books) { book -> book.sourceFolder in sourceFolders }
|
||||
}
|
||||
|
||||
fun enrichImportedBooks(
|
||||
books: List<BookItem>,
|
||||
importedBookIds: Set<String>
|
||||
|
|
@ -465,7 +475,7 @@ object DesktopFolderMetadataExtractor {
|
|||
return when (type) {
|
||||
FileType.PDF -> Color(156, 65, 70)
|
||||
FileType.EPUB -> Color(0, 108, 76)
|
||||
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(112, 93, 73)
|
||||
FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(112, 93, 73)
|
||||
FileType.MD -> Color(83, 101, 120)
|
||||
FileType.HTML -> Color(122, 87, 42)
|
||||
FileType.TXT -> Color(74, 92, 112)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
|
||||
internal fun desktopFolderSyncCompletedState(
|
||||
state: SharedReaderScreenState,
|
||||
message: String,
|
||||
failedFolderCount: Int,
|
||||
showBanner: Boolean
|
||||
): SharedReaderScreenState {
|
||||
return if (showBanner) {
|
||||
state.withBanner(message, isError = failedFolderCount > 0)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import java.net.URI
|
|||
import java.net.URLEncoder
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.WebSocket
|
||||
import java.net.http.WebSocketHandshakeException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
|
@ -56,7 +57,7 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
private val networkAccess: () -> Boolean = { true },
|
||||
private val workerUrlProvider: () -> String = { "" },
|
||||
private val authTokenProvider: suspend () -> String? = { null },
|
||||
private val useWorkerProvider: () -> Boolean = { false },
|
||||
private val useWorkerProvider: () -> Boolean = { true },
|
||||
private val onWorkerUsageCompleted: suspend () -> Unit = {},
|
||||
httpClient: HttpClient? = null,
|
||||
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
|
||||
|
|
@ -75,14 +76,15 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
@Volatile
|
||||
private var activePlayer: DesktopStreamingPcmPlayer? = null
|
||||
|
||||
val isPlaybackActive: Boolean
|
||||
get() = activePlayer != null || activeWebSocket != null || activeLine != null
|
||||
|
||||
override val isAvailable: Boolean
|
||||
get() {
|
||||
val settings = settingsProvider().sanitized()
|
||||
return networkAccess() && if (useWorkerProvider()) {
|
||||
settings.serverBackedCloudTts && workerUrlProvider().isNotBlank()
|
||||
} else {
|
||||
settings.isByokCloudTtsAvailable
|
||||
}
|
||||
return networkAccess() &&
|
||||
(settings.isByokCloudTtsAvailable ||
|
||||
(useWorkerProvider() && settings.serverBackedCloudTts && workerUrlProvider().isNotBlank()))
|
||||
}
|
||||
|
||||
override suspend fun speak(text: String) {
|
||||
|
|
@ -125,6 +127,12 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
)
|
||||
}
|
||||
.filter { it.text.isNotBlank() }
|
||||
logDesktopTtsStartTrace {
|
||||
"event=adapter_speak_chunks book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
|
||||
"inputChunks=${chunks.size} sequenceChunks=${sequenceChunks.size} " +
|
||||
"inputFirst=${chunks.firstOrNull().desktopTtsStartTraceSummary(160)} " +
|
||||
"sequenceFirstText=\"${sequenceChunks.firstOrNull()?.text.orEmpty().desktopTtsPreview(180)}\""
|
||||
}
|
||||
logDesktopTts(
|
||||
"chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
|
||||
"chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}"
|
||||
|
|
@ -182,28 +190,27 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
onChunkStart: suspend (Int) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val settings = settingsProvider().sanitized()
|
||||
val useWorker = useWorkerProvider()
|
||||
val authToken = if (useWorker) authTokenProvider() else null
|
||||
val useWorker = useWorkerProvider() && !settings.isByokCloudTtsAvailable
|
||||
val totalTextChars = chunks.sumOf { it.text.length }
|
||||
logDesktopTts(
|
||||
"stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " +
|
||||
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " +
|
||||
"available=${settings.isCloudTtsAvailable} worker=$useWorker"
|
||||
"available=${settings.isCloudTtsAvailable} serverBacked=${settings.serverBackedCloudTts} worker=$useWorker"
|
||||
)
|
||||
if (!networkAccess()) {
|
||||
logDesktopTts("stream_blocked reason=network_disabled")
|
||||
throw IllegalStateException("Cloud TTS is unavailable in this desktop build.")
|
||||
}
|
||||
if (!settings.isCloudTtsAvailable) {
|
||||
logDesktopTts("stream_blocked reason=not_available")
|
||||
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) {
|
||||
if (!settings.serverBackedCloudTts || workerUrlProvider().isBlank()) {
|
||||
logDesktopTts("stream_blocked reason=server_backed_not_available")
|
||||
throw IllegalStateException("Cloud TTS needs a signed-in account with credits.")
|
||||
}
|
||||
} else if (!settings.isByokCloudTtsAvailable) {
|
||||
logDesktopTts("stream_blocked reason=byok_not_available")
|
||||
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
|
||||
}
|
||||
val authToken = if (useWorker) authTokenProvider() else null
|
||||
if (useWorker && authToken.isNullOrBlank()) {
|
||||
logDesktopTts("stream_blocked reason=missing_auth_token")
|
||||
throw IllegalStateException("Sign in with Google to use cloud TTS.")
|
||||
|
|
@ -220,6 +227,7 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
val messageBuffer = StringBuilder()
|
||||
var webSocket: WebSocket? = null
|
||||
var activeTempCacheFile: File? = null
|
||||
var workerGeneratedAudio = false
|
||||
|
||||
fun handleMessage(message: String) {
|
||||
handleGeminiTtsMessage(
|
||||
|
|
@ -329,7 +337,7 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
.get(15, TimeUnit.SECONDS)
|
||||
}.getOrElse { error ->
|
||||
logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"")
|
||||
throw error
|
||||
throw IllegalStateException(desktopTtsConnectionMessage(error), error)
|
||||
}
|
||||
activeWebSocket = connectedWebSocket
|
||||
webSocket = connectedWebSocket
|
||||
|
|
@ -361,6 +369,10 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
currentTurnAudioBytesReceived.set(0)
|
||||
currentTurnComplete.set(turnComplete)
|
||||
logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}")
|
||||
logDesktopTtsStartTrace {
|
||||
"event=adapter_turn_start index=${index + 1}/${chunks.size} chapter=\"${chunk.chapterTitle.orEmpty().desktopTtsPreview()}\" " +
|
||||
"textChars=${text.length} text=\"${text.desktopTtsPreview(220)}\""
|
||||
}
|
||||
withContext(callbackContext) {
|
||||
onChunkStart(index)
|
||||
}
|
||||
|
|
@ -420,6 +432,10 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}")
|
||||
throw IllegalStateException("Cloud TTS returned no audio for a text chunk.")
|
||||
}
|
||||
if (useWorker) {
|
||||
workerGeneratedAudio = true
|
||||
onWorkerUsageCompleted()
|
||||
}
|
||||
activeCacheOutput.getAndSet(null)?.close()
|
||||
runCatching {
|
||||
patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt())
|
||||
|
|
@ -454,8 +470,15 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
activeWebSocket = null
|
||||
activePlayer = null
|
||||
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
|
||||
if (useWorker) onWorkerUsageCompleted()
|
||||
if (useWorker && workerGeneratedAudio) onWorkerUsageCompleted()
|
||||
} catch (error: Throwable) {
|
||||
if (useWorker && desktopTtsShouldRefreshAccountAfterError(error)) {
|
||||
try {
|
||||
onWorkerUsageCompleted()
|
||||
} catch (_: Throwable) {
|
||||
// Keep the original TTS failure as the visible error.
|
||||
}
|
||||
}
|
||||
currentTurnComplete.set(null)
|
||||
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }
|
||||
activeTempCacheFile?.delete()
|
||||
|
|
@ -629,6 +652,39 @@ private fun ByteArray.upsample16BitMonoLe2x(): ByteArray {
|
|||
return output
|
||||
}
|
||||
|
||||
private fun desktopTtsConnectionMessage(error: Throwable): String {
|
||||
val causes = generateSequence(error) { it.cause }.toList()
|
||||
val handshake = causes.filterIsInstance<WebSocketHandshakeException>().firstOrNull()
|
||||
return when (handshake?.response?.statusCode()) {
|
||||
401 -> "Sign in again to use cloud TTS."
|
||||
402 -> "Out of credits. Pro and credits can only be purchased from the Android app."
|
||||
403 -> "Cloud TTS is unavailable for this account."
|
||||
405 -> "Cloud TTS is not configured for this desktop build."
|
||||
426 -> "Cloud TTS is not configured for this desktop build."
|
||||
502 -> "Cloud TTS service is temporarily unavailable."
|
||||
else -> {
|
||||
val details = causes
|
||||
.joinToString(" ") { it.message.orEmpty() }
|
||||
.trim()
|
||||
when {
|
||||
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ->
|
||||
"Out of credits. Pro and credits can only be purchased from the Android app."
|
||||
details.contains("401") || details.contains("Unauthorized", ignoreCase = true) ->
|
||||
"Sign in again to use cloud TTS."
|
||||
else -> "Cloud TTS failed to connect."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun desktopTtsShouldRefreshAccountAfterError(error: Throwable): Boolean {
|
||||
val details = generateSequence(error) { it.cause }
|
||||
.joinToString(" ") { it.message.orEmpty() }
|
||||
return details.contains("Out of credits", ignoreCase = true) ||
|
||||
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ||
|
||||
details.contains("402", ignoreCase = true)
|
||||
}
|
||||
|
||||
private class DesktopStreamingPcmPlayer(
|
||||
private val onLineChanged: (SourceDataLine?) -> Unit
|
||||
) {
|
||||
|
|
@ -742,7 +798,6 @@ private class DesktopStreamingPcmPlayer(
|
|||
openLine(24_000f)
|
||||
}.onFailure { secondError ->
|
||||
logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"")
|
||||
secondError.printStackTrace()
|
||||
}.getOrElse {
|
||||
throw firstError
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,38 @@ package com.aryan.reader.desktop
|
|||
import com.aryan.reader.shared.SharedLibrarySnapshot
|
||||
import com.aryan.reader.shared.SharedLibrarySnapshotJson
|
||||
import java.io.File
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
class DesktopLibraryDatabase(
|
||||
private val databaseFile: File = defaultDatabaseFile()
|
||||
) {
|
||||
fun load(): SharedLibrarySnapshot {
|
||||
if (!databaseFile.exists()) return SharedLibrarySnapshot()
|
||||
return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText())
|
||||
return loadFile(databaseFile)
|
||||
?: loadFile(backupFile())
|
||||
?: SharedLibrarySnapshot()
|
||||
}
|
||||
|
||||
fun save(snapshot: SharedLibrarySnapshot) {
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot))
|
||||
val encoded = SharedLibrarySnapshotJson.encode(snapshot)
|
||||
databaseFile.writeTextAtomically(encoded)
|
||||
runCatching {
|
||||
backupFile().writeTextAtomically(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFile(file: File): SharedLibrarySnapshot? {
|
||||
if (!file.isFile) return null
|
||||
val raw = runCatching { file.readText() }.getOrNull() ?: return null
|
||||
val isJsonObject = runCatching {
|
||||
libraryDatabaseJson.parseToJsonElement(raw).jsonObject
|
||||
}.isSuccess
|
||||
if (!isJsonObject) return null
|
||||
return SharedLibrarySnapshotJson.decodeOrEmpty(raw)
|
||||
}
|
||||
|
||||
private fun backupFile(): File {
|
||||
return File(databaseFile.parentFile ?: File("."), "${databaseFile.name}.bak")
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -23,3 +43,5 @@ class DesktopLibraryDatabase(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val libraryDatabaseJson = Json { ignoreUnknownKeys = true }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -19,12 +20,14 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -44,7 +47,6 @@ 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.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
|
||||
|
|
@ -117,82 +119,62 @@ internal fun resolvedDesktopReaderSettings(
|
|||
|
||||
@Composable
|
||||
internal fun DesktopReaderOpeningScreen(
|
||||
opening: DesktopReaderOpening
|
||||
opening: DesktopReaderOpening,
|
||||
readerSettings: ReaderSettings? = null
|
||||
) {
|
||||
LaunchedEffect(opening.requestId) {
|
||||
logDesktopReaderOpenTrace {
|
||||
opening.openTracePrefix("desktop_opening_screen_composed")
|
||||
}
|
||||
}
|
||||
val background = readerSettings?.desktopOpeningBackgroundColor() ?: MaterialTheme.colorScheme.background
|
||||
val foreground = readerSettings?.desktopOpeningForegroundColor() ?: MaterialTheme.colorScheme.onBackground
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(background)
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
CircularProgressIndicator(color = foreground)
|
||||
Text(
|
||||
text = readerString("desktop_opening_title", "Opening %1\$s", opening.title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = foreground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = opening.formatLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = foreground.copy(alpha = 0.72f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HomeScreen(
|
||||
state: SharedReaderScreenState,
|
||||
selectedLibraryTab: NonReaderLibraryTab,
|
||||
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onRead: (BookItem) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
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,
|
||||
onSyncFolderMetadata: () -> Unit,
|
||||
onScanFolders: () -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit
|
||||
) {
|
||||
LibraryScreen(
|
||||
state = state,
|
||||
selectedLibraryTab = selectedLibraryTab,
|
||||
onLibraryTabChange = onLibraryTabChange,
|
||||
onStateChange = onStateChange,
|
||||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
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,
|
||||
onSyncFolderMetadata = onSyncFolderMetadata,
|
||||
onScanFolders = onScanFolders,
|
||||
onTogglePinned = onTogglePinned
|
||||
)
|
||||
private fun ReaderSettings.desktopOpeningBackgroundColor(): Color {
|
||||
return backgroundColorArgb?.toDesktopOpeningComposeColor()
|
||||
?: if (darkMode) Color(0xFF171A17) else Color(0xFFFFFCF5)
|
||||
}
|
||||
|
||||
private fun ReaderSettings.desktopOpeningForegroundColor(): Color {
|
||||
return textColorArgb?.toDesktopOpeningComposeColor()
|
||||
?: if (darkMode) Color(0xFFE7E3D8) else Color(0xFF24231F)
|
||||
}
|
||||
|
||||
private fun Long.toDesktopOpeningComposeColor(): Color {
|
||||
val value = this and 0xFFFFFFFFL
|
||||
val alpha = ((value shr 24) and 0xFF) / 255f
|
||||
val red = ((value shr 16) and 0xFF) / 255f
|
||||
val green = ((value shr 8) and 0xFF) / 255f
|
||||
val blue = (value and 0xFF) / 255f
|
||||
return Color(red = red, green = green, blue = blue, alpha = alpha.takeIf { it > 0f } ?: 1f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -209,12 +191,15 @@ internal fun LibraryScreen(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onCreateShelfWithBooks: (String, Set<String>) -> Unit,
|
||||
onCreateSmartShelf: () -> Unit,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit,
|
||||
onTagSelectedBooks: () -> Unit,
|
||||
onAddSelectedBooksToShelf: () -> Unit,
|
||||
onAddBooksToShelf: (Set<String>) -> Unit,
|
||||
onManageShelfBooks: (Shelf) -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncFolderMetadata: () -> Unit,
|
||||
onScanFolders: () -> Unit,
|
||||
|
|
@ -233,12 +218,15 @@ internal fun LibraryScreen(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onCreateShelfWithBooks = onCreateShelfWithBooks,
|
||||
onCreateSmartShelf = onCreateSmartShelf,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
onTagSelectedBooks = onTagSelectedBooks,
|
||||
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncFolderMetadata = onSyncFolderMetadata,
|
||||
onScanFolders = onScanFolders,
|
||||
|
|
@ -248,39 +236,6 @@ internal fun LibraryScreen(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ShelvesScreen(
|
||||
shelves: List<Shelf>,
|
||||
selectedBookIds: Set<String>,
|
||||
pinnedBookIds: Set<String>,
|
||||
onRead: (BookItem) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onCreateSmartShelf: () -> Unit,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit
|
||||
) {
|
||||
SharedShelvesScreen(
|
||||
shelves = shelves,
|
||||
selectedBookIds = selectedBookIds,
|
||||
pinnedBookIds = pinnedBookIds,
|
||||
onOpenBook = onRead,
|
||||
onToggleSelection = onSelect,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onCreateSmartShelf = onCreateSmartShelf,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder
|
||||
)
|
||||
}
|
||||
|
||||
private data class DesktopSmartRuleDraft(
|
||||
val field: SmartField = SmartField.TITLE,
|
||||
val operator: SmartOperator = SmartOperator.CONTAINS,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
|||
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
|
||||
import com.aryan.reader.shared.toSharedFolderBookMetadata
|
||||
import com.aryan.reader.shared.toStablePositionCfi
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
|
@ -46,7 +47,8 @@ data class DesktopLocalFolderSyncResult(
|
|||
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
|
||||
val idMigrations: Map<String, String> = emptyMap(),
|
||||
val removedBookIds: Set<String> = emptySet(),
|
||||
val failedFolders: List<String> = emptyList()
|
||||
val failedFolders: List<String> = emptyList(),
|
||||
val processedFolderUris: List<String> = emptyList()
|
||||
)
|
||||
|
||||
object DesktopLocalFolderSync {
|
||||
|
|
@ -56,10 +58,18 @@ object DesktopLocalFolderSync {
|
|||
if (!folder.isDirectory) return false
|
||||
return folder.walkTopDown()
|
||||
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
|
||||
.onFail { file, error ->
|
||||
logDesktopFolderSync(
|
||||
"folder.supportedFiles.skipInaccessible path=\"${file.absolutePath.folderSyncPreview()}\" " +
|
||||
"error=${error.folderSyncSummary()}"
|
||||
)
|
||||
}
|
||||
.any { file ->
|
||||
file.isFile &&
|
||||
file.shouldSyncBookFile() &&
|
||||
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
|
||||
runCatching {
|
||||
file.isFile &&
|
||||
file.shouldSyncBookFile() &&
|
||||
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,9 +78,11 @@ object DesktopLocalFolderSync {
|
|||
shelfRefs: List<BookShelfRef>,
|
||||
targetFolder: File? = null,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
metadataOnly: Boolean = false
|
||||
metadataOnly: Boolean = false,
|
||||
extractMetadata: Boolean = true
|
||||
): DesktopLocalFolderSyncResult {
|
||||
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
|
||||
.filter { it.localSyncEnabled }
|
||||
val mode = if (metadataOnly) "metadata" else "full"
|
||||
logDesktopFolderSync(
|
||||
"sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " +
|
||||
|
|
@ -84,6 +96,7 @@ object DesktopLocalFolderSync {
|
|||
val allMigrations = linkedMapOf<String, String>()
|
||||
val allRemovedBookIds = linkedSetOf<String>()
|
||||
val failedFolders = mutableListOf<String>()
|
||||
val processedFolderUris = mutableListOf<String>()
|
||||
|
||||
requestedFolders.forEach { folder ->
|
||||
val root = File(folder.uriString)
|
||||
|
|
@ -95,6 +108,7 @@ object DesktopLocalFolderSync {
|
|||
failedFolders += folder.name
|
||||
return@forEach
|
||||
}
|
||||
processedFolderUris += folder.uriString
|
||||
|
||||
logDesktopFolderSync(
|
||||
"folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
|
|
@ -138,8 +152,27 @@ object DesktopLocalFolderSync {
|
|||
logDesktopFolderSync(
|
||||
"folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}"
|
||||
)
|
||||
importAnnotationSidecars(root, syncedBooks)
|
||||
if (!metadataOnly) {
|
||||
runCatching {
|
||||
importAnnotationSidecars(root, syncedBooks)
|
||||
}.onFailure { error ->
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.failed mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
"root=\"${root.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
|
||||
)
|
||||
}
|
||||
syncedBooks.forEach { book ->
|
||||
remoteMetadata[book.id]?.let { metadata ->
|
||||
runCatching {
|
||||
importDesktopPdfBookmarksMetadata(book, metadata.bookmarksJson, metadata.lastModifiedTimestamp)
|
||||
}.onFailure { error ->
|
||||
logDesktopFolderSync(
|
||||
"metadata.bookmarks.importFailed book=${book.id} " +
|
||||
"error=${error.folderSyncSummary()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!metadataOnly && extractMetadata) {
|
||||
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
|
||||
books = nextState.rawLibraryBooks,
|
||||
sourceFolder = folder.uriString
|
||||
|
|
@ -173,7 +206,8 @@ object DesktopLocalFolderSync {
|
|||
metadataStats = totalMetadataStats,
|
||||
idMigrations = allMigrations,
|
||||
removedBookIds = allRemovedBookIds,
|
||||
failedFolders = failedFolders
|
||||
failedFolders = failedFolders,
|
||||
processedFolderUris = processedFolderUris
|
||||
)
|
||||
logDesktopFolderSync(
|
||||
"sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " +
|
||||
|
|
@ -188,8 +222,13 @@ object DesktopLocalFolderSync {
|
|||
savePdfAnnotationSidecar(book)
|
||||
}
|
||||
|
||||
fun deleteSyncDataFolder(root: File): Boolean {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||
return !syncDir.exists() || syncDir.isDirectory && syncDir.deleteRecursively()
|
||||
}
|
||||
|
||||
fun saveBookMetadata(book: BookItem) {
|
||||
val metadata = book.toSharedFolderBookMetadata()
|
||||
val metadata = book.toDesktopFolderBookMetadata()
|
||||
if (metadata == null) {
|
||||
logDesktopFolderSync(
|
||||
"metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " +
|
||||
|
|
@ -242,11 +281,9 @@ object DesktopLocalFolderSync {
|
|||
val data = buildMap {
|
||||
if (annotationFile.isFile) {
|
||||
val annotationJson = annotationFile.readText().trim()
|
||||
val annotations = SharedPdfAnnotationSerializer.decode(annotationJson)
|
||||
put(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||
)
|
||||
desktopPdfAnnotationElementForSync(annotationJson)?.let { annotations ->
|
||||
put(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS, annotations)
|
||||
}
|
||||
}
|
||||
if (bookmarkFile.isFile) {
|
||||
val bookmarksJson = bookmarkFile.readText().trim()
|
||||
|
|
@ -267,7 +304,7 @@ object DesktopLocalFolderSync {
|
|||
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
|
||||
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
|
||||
)
|
||||
put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument))
|
||||
desktopPdfRichTextElementForSync(richTextJson)?.let { put("text", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -279,9 +316,9 @@ object DesktopLocalFolderSync {
|
|||
return
|
||||
}
|
||||
val timestamp = maxOf(
|
||||
annotationFile.lastModifiedIfFile(),
|
||||
annotationFile.lastModifiedIfSyncableAnnotations(),
|
||||
bookmarkFile.lastModifiedIfFile(),
|
||||
richTextFile.lastModifiedIfFile(),
|
||||
richTextFile.lastModifiedIfSyncableRichText(),
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
val dataJson = desktopFolderSyncJson.encodeToString(
|
||||
|
|
@ -329,7 +366,15 @@ object DesktopLocalFolderSync {
|
|||
val rootPath = root.toPath().toAbsolutePath().normalize()
|
||||
return root.walkTopDown()
|
||||
.onEnter { it == root || it.shouldEnterSyncedFolder() }
|
||||
.filter { it.isFile && it.shouldSyncBookFile() }
|
||||
.onFail { file, error ->
|
||||
logDesktopFolderSync(
|
||||
"folder.scan.skipInaccessible root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||
"path=\"${file.absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
|
||||
)
|
||||
}
|
||||
.filter { file ->
|
||||
runCatching { file.isFile && file.shouldSyncBookFile() }.getOrDefault(false)
|
||||
}
|
||||
.mapNotNull { file ->
|
||||
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||
.takeIf { it in desktopSyncableTypes }
|
||||
|
|
@ -344,8 +389,8 @@ object DesktopLocalFolderSync {
|
|||
sourceFolder = sourceFolder,
|
||||
relativePath = relativePath,
|
||||
type = type,
|
||||
size = file.length(),
|
||||
lastModified = file.lastModified()
|
||||
size = runCatching { file.length() }.getOrDefault(0L),
|
||||
lastModified = runCatching { file.lastModified() }.getOrDefault(0L)
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
|
@ -559,13 +604,18 @@ object DesktopLocalFolderSync {
|
|||
}
|
||||
if (sidecar.data.hasPdfAnnotationPayload()) {
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
|
||||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
annotationFile.setLastModified(sidecar.timestamp)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
|
||||
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
if (annotations.isEmpty()) {
|
||||
if (annotationFile.isFile) annotationFile.delete()
|
||||
logDesktopFolderSync("annotation.import.deleteEmptyAnnotations book=${book.id}")
|
||||
} else {
|
||||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
annotationFile.setLastModified(sidecar.timestamp)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
|
||||
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
}
|
||||
sidecar.data["bookmarks"]?.let { bookmarks ->
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
|
|
@ -582,13 +632,18 @@ object DesktopLocalFolderSync {
|
|||
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
|
||||
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
||||
)
|
||||
richTextFile.parentFile?.mkdirs()
|
||||
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||
richTextFile.setLastModified(sidecar.timestamp)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
|
||||
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
if (richDocument.text.isEmpty() && richDocument.spans.isEmpty()) {
|
||||
if (richTextFile.isFile) richTextFile.delete()
|
||||
logDesktopFolderSync("annotation.import.deleteEmptyText book=${book.id}")
|
||||
} else {
|
||||
richTextFile.parentFile?.mkdirs()
|
||||
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||
richTextFile.setLastModified(sidecar.timestamp)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
|
||||
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -814,6 +869,56 @@ private fun File.lastModifiedIfFile(): Long {
|
|||
return if (isFile) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun File.hasSyncablePdfAnnotations(): Boolean {
|
||||
return isFile && desktopPdfAnnotationElementForSync(readText()) != null
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfSyncableAnnotations(): Long {
|
||||
return if (hasSyncablePdfAnnotations()) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun File.hasSyncablePdfRichText(): Boolean {
|
||||
return isFile && desktopPdfRichTextElementForSync(readText()) != null
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfSyncableRichText(): Long {
|
||||
return if (hasSyncablePdfRichText()) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun BookItem.toDesktopFolderBookMetadata(): SharedFolderBookMetadata? {
|
||||
val base = toSharedFolderBookMetadata()
|
||||
val pdfBookmarksJson = desktopPdfBookmarksMetadataJson(this)
|
||||
if (base == null && pdfBookmarksJson == null) return null
|
||||
|
||||
val timestamp = maxOf(
|
||||
base?.lastModifiedTimestamp ?: 0L,
|
||||
desktopPdfBookmarkMetadataTimestamp(this),
|
||||
this.timestamp
|
||||
)
|
||||
|
||||
return (base ?: SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = displayName,
|
||||
type = type.name,
|
||||
lastChapterIndex = readerPosition?.chapterIndex,
|
||||
lastPage = readerPosition?.pageIndex ?: lastPageIndex,
|
||||
lastPositionCfi = readerPosition?.toStablePositionCfi(),
|
||||
progressPercentage = progressPercentage ?: 0f,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = timestamp,
|
||||
bookmarksJson = null,
|
||||
locatorBlockIndex = readerPosition?.blockIndex,
|
||||
locatorCharOffset = readerPosition?.charOffset,
|
||||
customName = null,
|
||||
highlightsJson = null
|
||||
)).copy(
|
||||
lastModifiedTimestamp = timestamp,
|
||||
bookmarksJson = pdfBookmarksJson ?: base?.bookmarksJson
|
||||
)
|
||||
}
|
||||
|
||||
private fun uniqueFolderSyncTempName(baseName: String): String {
|
||||
val stem = baseName.removeSuffix(".tmp")
|
||||
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.math.ceil
|
||||
|
||||
internal class DesktopPaidAiAdapter(
|
||||
private val config: DesktopCloudConfig,
|
||||
|
|
@ -25,7 +26,7 @@ internal class DesktopPaidAiAdapter(
|
|||
private val currentSignedIn: () -> Boolean,
|
||||
private val currentIsProUser: () -> Boolean,
|
||||
private val currentCredits: () -> Int,
|
||||
private val onUsageCompleted: suspend () -> Unit = {}
|
||||
private val onUsageReported: (DesktopPaidAiUsage) -> Unit = {}
|
||||
) : AiAdapter {
|
||||
override val isAvailable: Boolean
|
||||
get() = networkAccess() &&
|
||||
|
|
@ -196,13 +197,18 @@ internal class DesktopPaidAiAdapter(
|
|||
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)
|
||||
val parsed = readWorkerStream(
|
||||
stream = stream,
|
||||
onChunk = onChunk,
|
||||
onUsageReceived = onUsageReceived,
|
||||
onUsageReported = onUsageReported
|
||||
)
|
||||
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")) {
|
||||
onUsageReported(DesktopPaidAiUsage())
|
||||
throw IllegalStateException("Out of credits. Pro and credits can only be purchased from the Android app.")
|
||||
}
|
||||
if (connection.responseCode == 401) {
|
||||
|
|
@ -222,6 +228,19 @@ internal class DesktopPaidAiAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
internal data class DesktopPaidAiUsage(
|
||||
val cost: Double? = null,
|
||||
val freeRemaining: Int? = null
|
||||
)
|
||||
|
||||
internal fun desktopCreditsAfterPaidAiUsage(currentCredits: Int, cost: Double?): Int {
|
||||
val deducted = cost
|
||||
?.takeIf { it.isFinite() && it > 0.0 }
|
||||
?.let { ceil(it).toInt() }
|
||||
?: return currentCredits
|
||||
return (currentCredits - deducted).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private data class DesktopPaidAiResponse(
|
||||
val text: String,
|
||||
val cost: Double? = null,
|
||||
|
|
@ -233,18 +252,35 @@ private val DesktopPaidAiJson = Json { ignoreUnknownKeys = true }
|
|||
private fun readWorkerStream(
|
||||
stream: InputStream?,
|
||||
onChunk: (String) -> Unit,
|
||||
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit
|
||||
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit,
|
||||
onUsageReported: (DesktopPaidAiUsage) -> Unit
|
||||
): DesktopPaidAiResponse {
|
||||
val output = StringBuilder()
|
||||
var cost: Double? = null
|
||||
var freeRemaining: Int? = null
|
||||
var paidUsageReported = false
|
||||
var freeUsageReported = false
|
||||
stream?.bufferedReader(Charsets.UTF_8)?.useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
val parsed = parseWorkerStreamLine(line) ?: return@forEach
|
||||
val parsed = try {
|
||||
parseWorkerStreamLine(line)
|
||||
} catch (error: IllegalStateException) {
|
||||
if (desktopPaidAiShouldRefreshAccountAfterError(error)) {
|
||||
onUsageReported(DesktopPaidAiUsage())
|
||||
}
|
||||
throw error
|
||||
} ?: return@forEach
|
||||
parsed.cost?.let { cost = it }
|
||||
parsed.freeRemaining?.let { freeRemaining = it }
|
||||
if (parsed.cost != null || parsed.freeRemaining != null) {
|
||||
onUsageReceived(parsed.cost, parsed.freeRemaining)
|
||||
if (parsed.cost != null && !paidUsageReported) {
|
||||
paidUsageReported = true
|
||||
onUsageReported(DesktopPaidAiUsage(cost = parsed.cost, freeRemaining = parsed.freeRemaining))
|
||||
} else if (parsed.freeRemaining != null && !freeUsageReported) {
|
||||
freeUsageReported = true
|
||||
onUsageReported(DesktopPaidAiUsage(freeRemaining = parsed.freeRemaining))
|
||||
}
|
||||
}
|
||||
parsed.chunk?.let { chunk ->
|
||||
output.append(chunk)
|
||||
|
|
@ -275,6 +311,14 @@ private data class DesktopPaidAiStreamLine(
|
|||
val freeRemaining: Int? = null
|
||||
)
|
||||
|
||||
private fun desktopPaidAiShouldRefreshAccountAfterError(error: Throwable): Boolean {
|
||||
val details = generateSequence(error) { it.cause }
|
||||
.joinToString(" ") { it.message.orEmpty() }
|
||||
return details.contains("Out of credits", ignoreCase = true) ||
|
||||
details.contains("INSUFFICIENT_CREDITS", ignoreCase = true) ||
|
||||
details.contains("402", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun workerErrorMessage(errorBody: String): String? {
|
||||
return when {
|
||||
errorBody.contains("INSUFFICIENT_CREDITS") -> "Out of credits. Pro and credits can only be purchased from the Android app."
|
||||
|
|
|
|||
|
|
@ -9,14 +9,17 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
|
|
@ -40,24 +43,32 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.pdf.DEFAULT_SHARED_PDF_COMMENT_AUTHOR
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
|
||||
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.pdf.pdfCommentChildren
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokePercent
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange
|
||||
import com.aryan.reader.shared.pdf.sharedPdfTextStyle
|
||||
import com.aryan.reader.shared.pdf.visiblePdfAnnotationComments
|
||||
import com.aryan.reader.shared.pdf.withoutPdfCommentThread
|
||||
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
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
internal val DesktopPdfAnnotationTools = listOf(
|
||||
PdfInkTool.PEN,
|
||||
|
|
@ -69,6 +80,11 @@ internal val DesktopPdfAnnotationTools = listOf(
|
|||
PdfInkTool.ERASER
|
||||
)
|
||||
|
||||
private enum class DesktopPdfAnnotationSheetSection {
|
||||
NOTE,
|
||||
COMMENTS
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfAnnotationEditor(
|
||||
annotation: SharedPdfAnnotation,
|
||||
|
|
@ -82,12 +98,51 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
onSearch: () -> Unit
|
||||
) {
|
||||
val highlighterColors = remember(highlighterPalette) {
|
||||
SharedPdfAndroidHighlightColors.palette
|
||||
SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors
|
||||
}
|
||||
var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf<Int?>(null) }
|
||||
var editingHighlighterDraftColors by remember(annotation.id, highlighterColors) {
|
||||
mutableStateOf<List<Int>>(emptyList())
|
||||
}
|
||||
val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT ||
|
||||
annotation.tool == PdfInkTool.HIGHLIGHTER ||
|
||||
annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
var selectedSection by remember(annotation.id) { mutableStateOf(DesktopPdfAnnotationSheetSection.NOTE) }
|
||||
var commentText by remember(annotation.id) { mutableStateOf("") }
|
||||
var replyTargetId by remember(annotation.id) { mutableStateOf<String?>(null) }
|
||||
var editingCommentId by remember(annotation.id) { mutableStateOf<String?>(null) }
|
||||
var commentAuthor by remember(annotation.id) {
|
||||
mutableStateOf(
|
||||
annotation.comments
|
||||
.lastOrNull { it.author.isNotBlank() }
|
||||
?.author
|
||||
?: DEFAULT_SHARED_PDF_COMMENT_AUTHOR
|
||||
)
|
||||
}
|
||||
|
||||
fun updateComments(nextComments: List<SharedPdfAnnotationComment>) {
|
||||
onUpdate(annotation.copy(comments = nextComments))
|
||||
}
|
||||
|
||||
fun highlighterDraftColors(): List<Int> {
|
||||
return editingHighlighterDraftColors.ifEmpty { highlighterColors }
|
||||
}
|
||||
|
||||
fun updateHighlighterDraft(slotIndex: Int, color: Color): List<Int> {
|
||||
val nextColors = highlighterDraftColors().toMutableList()
|
||||
if (slotIndex in nextColors.indices) {
|
||||
nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
editingHighlighterDraftColors = nextColors
|
||||
}
|
||||
return nextColors
|
||||
}
|
||||
|
||||
fun openHighlighterEditor(slotIndex: Int) {
|
||||
if (editingHighlighterSlot == null) {
|
||||
editingHighlighterDraftColors = highlighterColors
|
||||
}
|
||||
editingHighlighterSlot = slotIndex
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
|
|
@ -127,7 +182,7 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
)
|
||||
Text(
|
||||
"\"${annotation.text}\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f),
|
||||
|
|
@ -185,12 +240,7 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.clickable {
|
||||
val nextColor = if (isHighlighterAnnotation) {
|
||||
SharedPdfAndroidHighlightColors.nearestArgb(argb)
|
||||
} else {
|
||||
argb
|
||||
}
|
||||
onUpdate(annotation.copy(colorArgb = nextColor))
|
||||
onUpdate(annotation.copy(colorArgb = argb))
|
||||
},
|
||||
color = Color(argb),
|
||||
shape = RoundedCornerShape(13.dp),
|
||||
|
|
@ -217,24 +267,103 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp))
|
||||
.clickable {
|
||||
editingHighlighterSlot = highlighterColors
|
||||
.indexOf(annotation.colorArgb)
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
openHighlighterEditor(
|
||||
highlighterColors
|
||||
.indexOf(annotation.colorArgb)
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
value = annotation.note.orEmpty(),
|
||||
onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) },
|
||||
label = { Text(readerString("label_note", "Note")) },
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
selectionKey = annotation.id
|
||||
DesktopPdfAnnotationSheetTabs(
|
||||
selectedSection = selectedSection,
|
||||
commentCount = annotation.comments.count { it.contents.isNotBlank() },
|
||||
onSectionChange = { selectedSection = it }
|
||||
)
|
||||
if (selectedSection == DesktopPdfAnnotationSheetSection.NOTE) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = annotation.note.orEmpty(),
|
||||
onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) },
|
||||
label = { Text(readerString("label_note", "Note")) },
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
selectionKey = annotation.id
|
||||
)
|
||||
} else {
|
||||
DesktopPdfHighlightCommentsEditor(
|
||||
comments = annotation.comments,
|
||||
commentText = commentText,
|
||||
commentAuthor = commentAuthor,
|
||||
replyTargetId = replyTargetId,
|
||||
editingCommentId = editingCommentId,
|
||||
onCommentTextChange = { commentText = it },
|
||||
onCommentAuthorChange = { commentAuthor = it },
|
||||
onReply = { comment ->
|
||||
editingCommentId = null
|
||||
replyTargetId = comment.id
|
||||
commentText = ""
|
||||
},
|
||||
onCancelReply = { replyTargetId = null },
|
||||
onEdit = { comment ->
|
||||
editingCommentId = comment.id
|
||||
replyTargetId = null
|
||||
commentText = comment.contents
|
||||
commentAuthor = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
|
||||
},
|
||||
onCancelEdit = {
|
||||
editingCommentId = null
|
||||
commentText = ""
|
||||
},
|
||||
onDelete = { comment ->
|
||||
val nextComments = annotation.comments.withoutPdfCommentThread(comment.id)
|
||||
updateComments(nextComments)
|
||||
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
|
||||
replyTargetId = null
|
||||
}
|
||||
if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) {
|
||||
editingCommentId = null
|
||||
commentText = ""
|
||||
}
|
||||
},
|
||||
onAddComment = {
|
||||
val contents = commentText.trim()
|
||||
if (contents.isNotBlank()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val author = commentAuthor.trim().ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
|
||||
val nextComments = if (editingCommentId != null) {
|
||||
annotation.comments.map { comment ->
|
||||
if (comment.id == editingCommentId) {
|
||||
comment.copy(
|
||||
author = author,
|
||||
contents = contents,
|
||||
modifiedAt = now
|
||||
)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
} else {
|
||||
annotation.comments + SharedPdfAnnotationComment(
|
||||
id = UUID.randomUUID().toString(),
|
||||
parentId = replyTargetId,
|
||||
author = author,
|
||||
contents = contents,
|
||||
createdAt = now,
|
||||
modifiedAt = now
|
||||
)
|
||||
}
|
||||
updateComments(nextComments)
|
||||
commentText = ""
|
||||
replyTargetId = null
|
||||
editingCommentId = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (annotation.kind == PdfAnnotationKind.INK) {
|
||||
val strokeRange = annotation.tool.sharedPdfStrokeWidthRange()
|
||||
|
|
@ -261,23 +390,29 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
}
|
||||
}
|
||||
editingHighlighterSlot?.let { requestedSlot ->
|
||||
val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex)
|
||||
val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f)
|
||||
val draftColors = highlighterDraftColors()
|
||||
val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors }
|
||||
val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex)
|
||||
val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) }
|
||||
SharedHsvColorPickerDialog(
|
||||
initialColor = initialColor,
|
||||
title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1),
|
||||
onDismiss = { editingHighlighterSlot = null },
|
||||
onSave = { color ->
|
||||
val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb)
|
||||
val nextColors = updateHighlighterDraft(slot, color)
|
||||
onHighlighterPaletteChange(
|
||||
SharedPdfHighlighterPalette(highlighterColors).withColorAt(
|
||||
slotIndex = slot,
|
||||
colorArgb = nextArgb
|
||||
)
|
||||
SharedPdfHighlighterPalette(nextColors).sanitized()
|
||||
)
|
||||
onUpdate(annotation.copy(colorArgb = syncedArgb))
|
||||
onUpdate(annotation.copy(colorArgb = nextArgb))
|
||||
editingHighlighterSlot = null
|
||||
},
|
||||
resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) {
|
||||
SharedPdfHighlighterPalette.defaultColors.first()
|
||||
}).copy(alpha = 1f),
|
||||
stateKey = slot,
|
||||
onLiveColorChange = { color ->
|
||||
updateHighlighterDraft(slot, color)
|
||||
}
|
||||
) { liveColor ->
|
||||
Row(
|
||||
|
|
@ -285,7 +420,7 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
highlighterColors.forEachIndexed { index, argb ->
|
||||
highlighterDraftColors().forEachIndexed { index, argb ->
|
||||
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -294,10 +429,14 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
.background(color)
|
||||
.border(
|
||||
width = if (index == slot) 3.dp else 1.dp,
|
||||
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||
color = if (index == slot) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
|
||||
},
|
||||
shape = RoundedCornerShape(21.dp)
|
||||
)
|
||||
.clickable { editingHighlighterSlot = index },
|
||||
.clickable { openHighlighterEditor(index) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
|
|
@ -313,6 +452,265 @@ internal fun DesktopPdfAnnotationEditor(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfAnnotationSheetTabs(
|
||||
selectedSection: DesktopPdfAnnotationSheetSection,
|
||||
commentCount: Int,
|
||||
onSectionChange: (DesktopPdfAnnotationSheetSection) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(4.dp)) {
|
||||
DesktopPdfAnnotationSheetTab(
|
||||
label = readerString("label_note", "Note"),
|
||||
selected = selectedSection == DesktopPdfAnnotationSheetSection.NOTE,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.NOTE) }
|
||||
)
|
||||
DesktopPdfAnnotationSheetTab(
|
||||
label = "${readerString("label_comments", "Comments")} ($commentCount)",
|
||||
selected = selectedSection == DesktopPdfAnnotationSheetSection.COMMENTS,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onSectionChange(DesktopPdfAnnotationSheetSection.COMMENTS) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfAnnotationSheetTab(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfHighlightCommentsEditor(
|
||||
comments: List<SharedPdfAnnotationComment>,
|
||||
commentText: String,
|
||||
commentAuthor: String,
|
||||
replyTargetId: String?,
|
||||
editingCommentId: String?,
|
||||
onCommentTextChange: (String) -> Unit,
|
||||
onCommentAuthorChange: (String) -> Unit,
|
||||
onReply: (SharedPdfAnnotationComment) -> Unit,
|
||||
onCancelReply: () -> Unit,
|
||||
onEdit: (SharedPdfAnnotationComment) -> Unit,
|
||||
onCancelEdit: () -> Unit,
|
||||
onDelete: (SharedPdfAnnotationComment) -> Unit,
|
||||
onAddComment: () -> Unit
|
||||
) {
|
||||
val visibleComments = comments.visiblePdfAnnotationComments()
|
||||
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
|
||||
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
|
||||
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
DesktopPdfHighlightCommentThread(
|
||||
comments = visibleComments,
|
||||
parentId = null,
|
||||
depth = 0,
|
||||
visitedIds = emptySet(),
|
||||
onReply = onReply,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
|
||||
if (editingComment != null || replyTarget != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (editingComment != null) {
|
||||
readerString("label_editing_comment", "Editing comment")
|
||||
} else {
|
||||
readerString(
|
||||
"label_replying_to",
|
||||
"Replying to %1\$s",
|
||||
replyTarget?.author?.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }.orEmpty()
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SharedStableOutlinedTextField(
|
||||
value = commentAuthor,
|
||||
onValueChange = onCommentAuthorChange,
|
||||
label = { Text(readerString("author", "Author")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
selectionKey = "comment-author-${editingCommentId ?: replyTargetId ?: "new"}"
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
SharedStableOutlinedTextField(
|
||||
value = commentText,
|
||||
onValueChange = onCommentTextChange,
|
||||
placeholder = { Text(readerString("placeholder_add_comment", "Add a comment...")) },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
|
||||
minLines = 3,
|
||||
maxLines = 4,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
selectionKey = "comment-text-${editingCommentId ?: replyTargetId ?: "new"}"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) {
|
||||
Text(
|
||||
readerString(
|
||||
if (editingComment != null) "action_save_comment" else "action_add_comment",
|
||||
if (editingComment != null) "Save Comment" else "Add Comment"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfHighlightCommentThread(
|
||||
comments: List<SharedPdfAnnotationComment>,
|
||||
parentId: String?,
|
||||
depth: Int,
|
||||
visitedIds: Set<String>,
|
||||
onReply: (SharedPdfAnnotationComment) -> Unit,
|
||||
onEdit: (SharedPdfAnnotationComment) -> Unit,
|
||||
onDelete: (SharedPdfAnnotationComment) -> Unit
|
||||
) {
|
||||
comments.pdfCommentChildren(parentId).forEach { comment ->
|
||||
if (comment.id in visitedIds) return@forEach
|
||||
DesktopPdfHighlightCommentItem(
|
||||
comment = comment,
|
||||
depth = depth,
|
||||
onReply = { onReply(comment) },
|
||||
onEdit = { onEdit(comment) },
|
||||
onDelete = { onDelete(comment) }
|
||||
)
|
||||
DesktopPdfHighlightCommentThread(
|
||||
comments = comments,
|
||||
parentId = comment.id,
|
||||
depth = depth + 1,
|
||||
visitedIds = visitedIds + comment.id,
|
||||
onReply = onReply,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfHighlightCommentItem(
|
||||
comment: SharedPdfAnnotationComment,
|
||||
depth: Int,
|
||||
onReply: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val indentSize = (depth * 16).dp
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = indentSize, top = 6.dp, bottom = 6.dp)
|
||||
) {
|
||||
if (depth > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val timestamp = comment.createdAt.formatDesktopPdfCommentTimestamp()
|
||||
if (timestamp.isNotBlank()) {
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = comment.contents,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Row {
|
||||
TextButton(onClick = onReply) {
|
||||
Text(readerString("action_reply", "Reply"))
|
||||
}
|
||||
TextButton(onClick = onEdit) {
|
||||
Text(readerString("label_edit", "Edit"))
|
||||
}
|
||||
TextButton(onClick = onDelete) {
|
||||
Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.formatDesktopPdfCommentTimestamp(): String {
|
||||
if (this <= 0L) return ""
|
||||
return runCatching {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this))
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopBottomSheetToolButton(
|
||||
icon: ImageVector,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -20,12 +19,17 @@ import androidx.compose.ui.graphics.BlendMode
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
|
|
@ -33,9 +37,11 @@ import com.aryan.reader.shared.ReaderTheme
|
|||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
internal enum class DesktopPdfInspectorTab(val title: String) {
|
||||
VIEW("View"),
|
||||
APPEARANCE("Appearance"),
|
||||
APP_THEME("App theme"),
|
||||
VISUAL("Visual"),
|
||||
MARKUP("Markup"),
|
||||
ASSIST("Assist")
|
||||
TTS("TTS")
|
||||
}
|
||||
|
||||
internal data class DesktopPdfThemeStyle(
|
||||
|
|
@ -56,15 +62,25 @@ internal fun DesktopPdfThemedPageImage(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = themeStyle.colorFilter,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
val textureBitmap = themeStyle.textureBitmap
|
||||
if (textureBitmap != null && themeStyle.textureAlpha > 0f) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.semantics { this.contentDescription = contentDescription }
|
||||
) {
|
||||
drawImage(
|
||||
image = bitmap,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(bitmap.width, bitmap.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(
|
||||
size.width.toInt().coerceAtLeast(1),
|
||||
size.height.toInt().coerceAtLeast(1)
|
||||
),
|
||||
colorFilter = themeStyle.colorFilter,
|
||||
filterQuality = FilterQuality.High
|
||||
)
|
||||
if (textureBitmap != null && themeStyle.textureAlpha > 0f) {
|
||||
drawRect(
|
||||
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||
size = size,
|
||||
|
|
@ -77,7 +93,7 @@ internal fun DesktopPdfThemedPageImage(
|
|||
}
|
||||
|
||||
internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings {
|
||||
val defaults = ReaderSettings(themeId = "no_theme")
|
||||
val defaults = DesktopDefaultPdfReaderSettings
|
||||
val settings = this ?: defaults
|
||||
val themeId = settings.themeId
|
||||
val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId }
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
|||
import androidx.compose.material.icons.filled.ZoomOut
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
|
|
@ -49,6 +48,7 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -56,6 +56,7 @@ import androidx.compose.ui.zIndex
|
|||
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.ReaderTooltipIconButton
|
||||
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||
import com.aryan.reader.shared.ui.readerString
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -82,12 +83,11 @@ internal fun DesktopPdfFullscreenBottomChrome(
|
|||
val chromeBackground = MaterialTheme.colorScheme.surfaceVariant
|
||||
val chromeContent = MaterialTheme.colorScheme.onSurface
|
||||
val sliderActive = MaterialTheme.colorScheme.primary
|
||||
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
|
||||
val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp),
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp),
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
color = chromeBackground,
|
||||
contentColor = chromeContent,
|
||||
tonalElevation = 0.dp,
|
||||
|
|
@ -113,7 +113,11 @@ internal fun DesktopPdfFullscreenBottomChrome(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPrevious, enabled = canGoPrevious) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("desktop_previous_page", "Previous page"),
|
||||
onClick = onPrevious,
|
||||
enabled = canGoPrevious
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||
contentDescription = readerString("desktop_previous_page", "Previous page"),
|
||||
|
|
@ -136,7 +140,11 @@ internal fun DesktopPdfFullscreenBottomChrome(
|
|||
thumbColor = sliderActive,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onNext, enabled = canGoNext) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("desktop_next_page", "Next page"),
|
||||
onClick = onNext,
|
||||
enabled = canGoNext
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = readerString("desktop_next_page", "Next page"),
|
||||
|
|
@ -171,10 +179,10 @@ internal fun DesktopPdfBottomChrome(
|
|||
val chromeBackground = MaterialTheme.colorScheme.surfaceVariant
|
||||
val chromeContent = MaterialTheme.colorScheme.onSurface
|
||||
val sliderActive = MaterialTheme.colorScheme.primary
|
||||
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
|
||||
val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
color = chromeBackground,
|
||||
contentColor = chromeContent,
|
||||
tonalElevation = 0.dp,
|
||||
|
|
@ -199,7 +207,11 @@ internal fun DesktopPdfBottomChrome(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPrevious, enabled = canGoPrevious) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("desktop_previous_page", "Previous page"),
|
||||
onClick = onPrevious,
|
||||
enabled = canGoPrevious
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||
contentDescription = readerString("desktop_previous_page", "Previous page"),
|
||||
|
|
@ -230,7 +242,11 @@ internal fun DesktopPdfBottomChrome(
|
|||
style = MaterialTheme.typography.labelSmall,
|
||||
color = chromeContent.copy(alpha = 0.72f)
|
||||
)
|
||||
IconButton(onClick = onNext, enabled = canGoNext) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("desktop_next_page", "Next page"),
|
||||
onClick = onNext,
|
||||
enabled = canGoNext
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = readerString("desktop_next_page", "Next page"),
|
||||
|
|
@ -298,7 +314,7 @@ internal fun DesktopPdfSearchTopBar(
|
|||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
|
|
@ -307,7 +323,11 @@ internal fun DesktopPdfSearchTopBar(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("tooltip_close_search_desc", "Exit search and go back to the reader"),
|
||||
onClick = onClose,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = readerString("content_desc_close_search", "Close search"))
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
|
|
@ -318,7 +338,10 @@ internal fun DesktopPdfSearchTopBar(
|
|||
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||
trailingIcon = if (query.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("tooltip_clear_search_desc", "Erase your current search query and start over"),
|
||||
onClick = { onQueryChange("") }
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = readerString("tooltip_clear_search", "Clear search"))
|
||||
}
|
||||
}
|
||||
|
|
@ -327,7 +350,16 @@ internal fun DesktopPdfSearchTopBar(
|
|||
},
|
||||
selectionKey = "desktop-pdf-search"
|
||||
)
|
||||
IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) {
|
||||
val resultsTooltip = if (showResultsPanel) {
|
||||
readerString("tooltip_hide_results_desc", "Collapse the search results panel")
|
||||
} else {
|
||||
readerString("tooltip_show_results_desc", "Expand the panel to see all search matches")
|
||||
}
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = resultsTooltip,
|
||||
onClick = onToggleResults,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = if (showResultsPanel) {
|
||||
|
|
@ -507,7 +539,15 @@ private fun DesktopPdfSearchNavigationPill(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = if (highlightMode == SearchHighlightMode.ALL) {
|
||||
readerString("desktop_show_current_match_only", "Show current match only")
|
||||
} else {
|
||||
readerString("desktop_show_all_search_matches", "Show all search matches")
|
||||
},
|
||||
onClick = onToggleHighlightMode,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = readerString("content_desc_toggle_search_highlights", "Toggle search highlights"),
|
||||
|
|
@ -518,7 +558,12 @@ private fun DesktopPdfSearchNavigationPill(
|
|||
}
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("tooltip_prev_result_desc", "Jump to the previous search match in the document"),
|
||||
onClick = onPrevious,
|
||||
enabled = resultCount > 0,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = readerString("desktop_previous_search_result", "Previous search result"))
|
||||
}
|
||||
Text(
|
||||
|
|
@ -531,7 +576,12 @@ private fun DesktopPdfSearchNavigationPill(
|
|||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp)
|
||||
)
|
||||
IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
|
||||
ReaderTooltipIconButton(
|
||||
tooltip = readerString("tooltip_next_result_desc", "Jump to the next search match in the document"),
|
||||
onClick = onNext,
|
||||
enabled = resultCount > 0,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = readerString("desktop_next_search_result", "Next search result"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,50 +5,46 @@ 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.horizontalScroll
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ZoomIn
|
||||
import androidx.compose.material.icons.filled.ZoomOut
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.ReaderTheme
|
||||
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
|
||||
|
|
@ -56,8 +52,6 @@ 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
|
||||
|
|
@ -68,131 +62,93 @@ import com.aryan.reader.shared.ui.sharedAcceleratedLazyWheelScroll
|
|||
@Composable
|
||||
internal fun DesktopPdfInspectorPanel(
|
||||
document: DesktopPdfDocument,
|
||||
pageIndex: Int,
|
||||
displayMode: PdfDisplayMode,
|
||||
pdfReaderSettings: ReaderSettings,
|
||||
appThemeControls: (@Composable () -> Unit)? = null,
|
||||
customReaderThemes: List<ReaderTheme>,
|
||||
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
|
||||
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,
|
||||
onCloudTtsVoiceChange: (String) -> Unit,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
|
||||
) {
|
||||
var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VIEW) }
|
||||
val viewInspectorListState = rememberLazyListState()
|
||||
val inspectorTabs = remember(appThemeControls != null) {
|
||||
desktopPdfInspectorTabs(appThemeControlsAvailable = appThemeControls != null)
|
||||
}
|
||||
var selectedPdfInspectorTab by remember(document.handleId) { mutableStateOf(DesktopPdfInspectorTab.VISUAL) }
|
||||
LaunchedEffect(inspectorTabs) {
|
||||
if (selectedPdfInspectorTab !in inspectorTabs) {
|
||||
selectedPdfInspectorTab = DesktopPdfInspectorTab.VISUAL.takeIf { it in inspectorTabs }
|
||||
?: inspectorTabs.first()
|
||||
}
|
||||
}
|
||||
val appThemeInspectorListState = rememberLazyListState()
|
||||
val appearanceInspectorListState = rememberLazyListState()
|
||||
val visualInspectorListState = rememberLazyListState()
|
||||
val markupInspectorListState = rememberLazyListState()
|
||||
val assistInspectorListState = rememberLazyListState()
|
||||
val ttsInspectorListState = rememberLazyListState()
|
||||
val pdfInspectorListState = when (selectedPdfInspectorTab) {
|
||||
DesktopPdfInspectorTab.VIEW -> viewInspectorListState
|
||||
DesktopPdfInspectorTab.APP_THEME -> appThemeInspectorListState
|
||||
DesktopPdfInspectorTab.APPEARANCE -> appearanceInspectorListState
|
||||
DesktopPdfInspectorTab.VISUAL -> visualInspectorListState
|
||||
DesktopPdfInspectorTab.MARKUP -> markupInspectorListState
|
||||
DesktopPdfInspectorTab.ASSIST -> assistInspectorListState
|
||||
DesktopPdfInspectorTab.TTS -> ttsInspectorListState
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(340.dp)
|
||||
.fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
DesktopPdfInspectorHeader(
|
||||
tabs = inspectorTabs,
|
||||
selectedTab = selectedPdfInspectorTab,
|
||||
onTabSelected = { selectedPdfInspectorTab = it }
|
||||
)
|
||||
HorizontalDivider()
|
||||
DesktopPdfInspectorContent(
|
||||
document = document,
|
||||
pageIndex = pageIndex,
|
||||
displayMode = displayMode,
|
||||
pdfReaderSettings = pdfReaderSettings,
|
||||
appThemeControls = appThemeControls,
|
||||
customReaderThemes = customReaderThemes,
|
||||
onCustomReaderThemesChange = onCustomReaderThemesChange,
|
||||
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,
|
||||
onCloudTtsVoiceChange = onCloudTtsVoiceChange,
|
||||
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange
|
||||
)
|
||||
}
|
||||
|
|
@ -201,32 +157,33 @@ internal fun DesktopPdfInspectorPanel(
|
|||
|
||||
@Composable
|
||||
private fun DesktopPdfInspectorHeader(
|
||||
tabs: List<DesktopPdfInspectorTab>,
|
||||
selectedTab: DesktopPdfInspectorTab,
|
||||
onTabSelected: (DesktopPdfInspectorTab) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = tabs.indexOf(selectedTab).coerceAtLeast(0),
|
||||
edgePadding = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
tabs.forEach { tab ->
|
||||
Tab(
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) },
|
||||
icon = {
|
||||
Icon(
|
||||
tab.icon(),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
tab.localizedTitle(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -234,56 +191,31 @@ private fun DesktopPdfInspectorHeader(
|
|||
@Composable
|
||||
private fun ColumnScope.DesktopPdfInspectorContent(
|
||||
document: DesktopPdfDocument,
|
||||
pageIndex: Int,
|
||||
displayMode: PdfDisplayMode,
|
||||
pdfReaderSettings: ReaderSettings,
|
||||
appThemeControls: (@Composable () -> Unit)?,
|
||||
customReaderThemes: List<ReaderTheme>,
|
||||
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
|
||||
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,
|
||||
onCloudTtsVoiceChange: (String) -> Unit,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
|
|
@ -296,14 +228,54 @@ private fun ColumnScope.DesktopPdfInspectorContent(
|
|||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
when (selectedTab) {
|
||||
DesktopPdfInspectorTab.VIEW -> {
|
||||
DesktopPdfInspectorTab.APP_THEME -> {
|
||||
appThemeControls?.let { controls ->
|
||||
item {
|
||||
controls()
|
||||
}
|
||||
}
|
||||
}
|
||||
DesktopPdfInspectorTab.APPEARANCE -> {
|
||||
item {
|
||||
DesktopPdfInspectorSection(readerString("label_reading", "Reading")) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
DesktopPdfInspectorSection(readerString("desktop_pdf_theme", "PDF theme")) {
|
||||
SharedReaderThemeControls(
|
||||
settings = pdfReaderSettings,
|
||||
builtInThemes = BuiltInPdfReaderThemes,
|
||||
customThemes = customReaderThemes,
|
||||
onCustomThemesChange = onCustomReaderThemesChange,
|
||||
customTextureIds = customTextureIds,
|
||||
onImportTexture = onImportTexture,
|
||||
texturePreviewContent = { textureId, previewModifier ->
|
||||
DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier)
|
||||
},
|
||||
onSettingsChange = onReaderSettingsChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DesktopPdfInspectorTab.VISUAL -> {
|
||||
item {
|
||||
DesktopPdfInspectorSection(readerString("visual_options_title", "Visual options")) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
) {
|
||||
FilterChip(
|
||||
selected = displayMode == PdfDisplayMode.PAGINATION,
|
||||
onClick = { onDisplayModeSelected(PdfDisplayMode.PAGINATION) },
|
||||
label = { Text(readerString("desktop_page", "Page")) }
|
||||
selected = displayMode == PdfDisplayMode.PAGINATION && !pdfReaderSettings.rightToLeftPagination,
|
||||
onClick = {
|
||||
onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = false))
|
||||
onDisplayModeSelected(PdfDisplayMode.PAGINATION)
|
||||
},
|
||||
label = { Text(readerString("menu_reading_mode_paginated", "Paginated (left-to-right)")) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = displayMode == PdfDisplayMode.PAGINATION && pdfReaderSettings.rightToLeftPagination,
|
||||
onClick = {
|
||||
onReaderSettingsChange(pdfReaderSettings.copy(rightToLeftPagination = true))
|
||||
onDisplayModeSelected(PdfDisplayMode.PAGINATION)
|
||||
},
|
||||
label = { Text(readerString("menu_right_to_left_pagination", "Paginated (right-to-left)")) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = displayMode == PdfDisplayMode.VERTICAL_SCROLL,
|
||||
|
|
@ -348,53 +320,11 @@ private fun ColumnScope.DesktopPdfInspectorContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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."
|
||||
"Applies to vertical reading and two-page spreads."
|
||||
),
|
||||
checked = !pdfReaderSettings.pdfVerticalPageGapVisible,
|
||||
onCheckedChange = { removeGap ->
|
||||
|
|
@ -418,43 +348,11 @@ private fun ColumnScope.DesktopPdfInspectorContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
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")) {
|
||||
DesktopPdfInspectorSection(readerString("desktop_document_text", "Document text")) {
|
||||
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,
|
||||
|
|
@ -463,24 +361,6 @@ private fun ColumnScope.DesktopPdfInspectorContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
|
|
@ -510,21 +390,14 @@ private fun ColumnScope.DesktopPdfInspectorContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
DesktopPdfInspectorTab.ASSIST -> {
|
||||
DesktopPdfInspectorTab.TTS -> {
|
||||
item {
|
||||
DesktopPdfExtrasPanel(
|
||||
pageText = pageText(),
|
||||
DesktopPdfTtsPanel(
|
||||
extrasState = pdfExtrasState,
|
||||
aiByokSettings = aiByokSettings,
|
||||
externalLookupAvailable = externalLookupAvailable,
|
||||
cloudTtsFeatureAvailable = cloudTtsFeatureAvailable,
|
||||
onExternalLookup = onExternalLookup,
|
||||
onOpenAiHub = onOpenAiHub,
|
||||
onCloudTtsStart = onCloudTtsStart,
|
||||
onCloudTtsPauseResume = onCloudTtsPauseResume,
|
||||
onCloudTtsStop = onCloudTtsStop,
|
||||
onCloudTtsClearCache = onCloudTtsClearCache,
|
||||
onAutoScrollChange = onAutoScrollChange,
|
||||
onCloudTtsVoiceChange = onCloudTtsVoiceChange,
|
||||
ttsReplacementPreferences = ttsReplacementPreferences,
|
||||
ttsReplacementBookId = document.path,
|
||||
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange
|
||||
|
|
@ -543,8 +416,29 @@ private fun ColumnScope.DesktopPdfInspectorContent(
|
|||
@Composable
|
||||
private fun DesktopPdfInspectorTab.localizedTitle(): String {
|
||||
return when (this) {
|
||||
DesktopPdfInspectorTab.VIEW -> readerString("desktop_view", "View")
|
||||
DesktopPdfInspectorTab.APP_THEME -> readerString("app_theme_title", "App theme")
|
||||
DesktopPdfInspectorTab.APPEARANCE -> readerString("desktop_pdf_theme", "PDF theme")
|
||||
DesktopPdfInspectorTab.VISUAL -> readerString("visual_options_title", "Visual")
|
||||
DesktopPdfInspectorTab.MARKUP -> readerString("desktop_markup", "Markup")
|
||||
DesktopPdfInspectorTab.ASSIST -> readerString("desktop_assist", "Assist")
|
||||
DesktopPdfInspectorTab.TTS -> readerString("menu_tts_settings", "TTS")
|
||||
}
|
||||
}
|
||||
|
||||
private fun DesktopPdfInspectorTab.icon(): ImageVector {
|
||||
return when (this) {
|
||||
DesktopPdfInspectorTab.APP_THEME -> Icons.Default.Palette
|
||||
DesktopPdfInspectorTab.APPEARANCE -> Icons.Default.Palette
|
||||
DesktopPdfInspectorTab.VISUAL -> Icons.Default.Tune
|
||||
DesktopPdfInspectorTab.MARKUP -> Icons.Default.Edit
|
||||
DesktopPdfInspectorTab.TTS -> Icons.AutoMirrored.Filled.VolumeUp
|
||||
}
|
||||
}
|
||||
|
||||
private fun desktopPdfInspectorTabs(appThemeControlsAvailable: Boolean): List<DesktopPdfInspectorTab> {
|
||||
return buildList {
|
||||
add(DesktopPdfInspectorTab.APPEARANCE)
|
||||
if (appThemeControlsAvailable) add(DesktopPdfInspectorTab.APP_THEME)
|
||||
add(DesktopPdfInspectorTab.VISUAL)
|
||||
add(DesktopPdfInspectorTab.TTS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ internal enum class DesktopPdfKeyCommand {
|
|||
|
||||
internal fun KeyEvent.desktopPdfKeyCommandOrNull(
|
||||
fullscreen: Boolean,
|
||||
editingText: Boolean
|
||||
editingText: Boolean,
|
||||
rightToLeftPagination: Boolean = false
|
||||
): DesktopPdfKeyCommand? {
|
||||
if (type != KeyEventType.KeyDown) return null
|
||||
if (fullscreen && key == Key.Escape) {
|
||||
|
|
@ -33,8 +34,16 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull(
|
|||
return null
|
||||
}
|
||||
return when {
|
||||
key == Key.DirectionLeft -> DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
key == Key.DirectionRight -> DesktopPdfKeyCommand.NEXT_PAGE
|
||||
key == Key.DirectionLeft -> if (rightToLeftPagination) {
|
||||
DesktopPdfKeyCommand.NEXT_PAGE
|
||||
} else {
|
||||
DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
}
|
||||
key == Key.DirectionRight -> if (rightToLeftPagination) {
|
||||
DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
} else {
|
||||
DesktopPdfKeyCommand.NEXT_PAGE
|
||||
}
|
||||
key == Key.DirectionUp -> DesktopPdfKeyCommand.SCROLL_UP
|
||||
key == Key.DirectionDown -> DesktopPdfKeyCommand.SCROLL_DOWN
|
||||
key == Key.PageUp -> DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
|
|
@ -50,7 +59,8 @@ internal fun KeyEvent.desktopPdfKeyCommandOrNull(
|
|||
|
||||
internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull(
|
||||
fullscreen: Boolean,
|
||||
editingText: Boolean
|
||||
editingText: Boolean,
|
||||
rightToLeftPagination: Boolean = false
|
||||
): DesktopPdfKeyCommand? {
|
||||
if (id != AwtKeyEvent.KEY_PRESSED) return null
|
||||
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
|
||||
|
|
@ -60,8 +70,16 @@ internal fun AwtKeyEvent.desktopPdfKeyCommandOrNull(
|
|||
return null
|
||||
}
|
||||
return when (keyCode) {
|
||||
AwtKeyEvent.VK_LEFT -> DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
AwtKeyEvent.VK_RIGHT -> DesktopPdfKeyCommand.NEXT_PAGE
|
||||
AwtKeyEvent.VK_LEFT -> if (rightToLeftPagination) {
|
||||
DesktopPdfKeyCommand.NEXT_PAGE
|
||||
} else {
|
||||
DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
}
|
||||
AwtKeyEvent.VK_RIGHT -> if (rightToLeftPagination) {
|
||||
DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
} else {
|
||||
DesktopPdfKeyCommand.NEXT_PAGE
|
||||
}
|
||||
AwtKeyEvent.VK_UP -> DesktopPdfKeyCommand.SCROLL_UP
|
||||
AwtKeyEvent.VK_DOWN -> DesktopPdfKeyCommand.SCROLL_DOWN
|
||||
AwtKeyEvent.VK_PAGE_UP -> DesktopPdfKeyCommand.PREVIOUS_PAGE
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
|
|
@ -38,9 +39,11 @@ import androidx.compose.material3.AlertDialog
|
|||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
|
|
@ -59,6 +62,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -66,7 +70,6 @@ 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
|
||||
|
|
@ -199,27 +202,28 @@ internal fun desktopVisiblePdfTocEntries(
|
|||
return result
|
||||
}
|
||||
|
||||
internal fun desktopPdfSidebarHighlights(annotations: List<SharedPdfAnnotation>): List<SharedPdfAnnotation> {
|
||||
return annotations
|
||||
.filter { it.kind == PdfAnnotationKind.HIGHLIGHT }
|
||||
.sortedBy { it.pageIndex }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DesktopPdfNavigationSidebar(
|
||||
document: DesktopPdfDocument,
|
||||
pageIndex: Int,
|
||||
sortedAnnotations: List<SharedPdfAnnotation>,
|
||||
sortedEmbeddedAnnotations: List<SharedPdfEmbeddedAnnotation>,
|
||||
sortedHighlights: List<SharedPdfAnnotation>,
|
||||
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
|
||||
onAnnotationDeleted: (SharedPdfAnnotation) -> Unit
|
||||
) {
|
||||
val documentHandleId = document.handleId
|
||||
val tabs = listOf(
|
||||
readerString("desktop_toc", "TOC"),
|
||||
readerString("tab_annotations", "Annotations"),
|
||||
readerString("tab_highlights", "Highlights"),
|
||||
readerString("tab_bookmarks", "Bookmarks"),
|
||||
readerString("tab_pages", "Pages")
|
||||
)
|
||||
|
|
@ -344,180 +348,158 @@ internal fun DesktopPdfNavigationSidebar(
|
|||
}
|
||||
}
|
||||
1 -> {
|
||||
if (sortedAnnotations.isEmpty() && sortedEmbeddedAnnotations.isEmpty()) {
|
||||
DesktopPdfNavigationEmpty(readerString("desktop_no_annotations_yet", "No annotations yet"))
|
||||
if (sortedHighlights.isEmpty()) {
|
||||
DesktopPdfNavigationEmpty(readerString("no_highlights_yet", "No highlights 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val highlightsListState = rememberLazyListState()
|
||||
var deleteHighlightConfirmFor by remember { mutableStateOf<SharedPdfAnnotation?>(null) }
|
||||
var filterWithNotesOnly by remember { mutableStateOf(false) }
|
||||
val filteredHighlights = remember(sortedHighlights, filterWithNotesOnly) {
|
||||
if (filterWithNotesOnly) {
|
||||
sortedHighlights.filter { !it.note.isNullOrBlank() }
|
||||
} else {
|
||||
sortedHighlights
|
||||
}
|
||||
SharedReaderVerticalScrollbar(
|
||||
listState = annotationsListState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
deleteAnnotationConfirmFor?.let { annotation ->
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !filterWithNotesOnly,
|
||||
onClick = { filterWithNotesOnly = false },
|
||||
label = { Text(readerString("read_status_all", "All")) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterWithNotesOnly,
|
||||
onClick = { filterWithNotesOnly = true },
|
||||
label = { Text(readerString("filter_with_notes", "With notes")) }
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
LazyColumn(
|
||||
state = highlightsListState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.sharedAcceleratedLazyWheelScroll(highlightsListState)
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(filteredHighlights, key = { "nav_highlight_${it.id}" }) { highlight ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = highlight.text.ifBlank {
|
||||
readerString(
|
||||
"msg_highlighted_section_default",
|
||||
"Highlighted section"
|
||||
)
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(Color(highlight.colorArgb).copy(alpha = 1f), CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
readerString("pdf_page_short", "Page %1\$d", highlight.pageIndex + 1),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
highlight.note?.takeIf { it.isNotBlank() }?.let { note ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = note,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Box {
|
||||
var highlightMenuExpanded by remember(highlight.id) { mutableStateOf(false) }
|
||||
IconButton(onClick = { highlightMenuExpanded = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = readerString("content_desc_options", "Options")
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = highlightMenuExpanded,
|
||||
onDismissRequest = { highlightMenuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (highlight.note.isNullOrBlank()) {
|
||||
readerString("menu_add_note", "Add note")
|
||||
} else {
|
||||
readerString("menu_edit_note", "Edit note")
|
||||
}
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onAnnotationSelected(highlight)
|
||||
highlightMenuExpanded = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(readerString("action_delete", "Delete")) },
|
||||
onClick = {
|
||||
deleteHighlightConfirmFor = highlight
|
||||
highlightMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onAnnotationOpened(highlight) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
SharedReaderVerticalScrollbar(
|
||||
listState = highlightsListState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
deleteHighlightConfirmFor?.let { highlight ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { 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.")) },
|
||||
onDismissRequest = { deleteHighlightConfirmFor = null },
|
||||
title = { Text(readerString("dialog_delete_highlight", "Delete highlight?")) },
|
||||
text = { Text(readerString("dialog_delete_highlight_desc", "This removes the highlight from this PDF.")) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
deleteAnnotationConfirmFor = null
|
||||
onAnnotationDeleted(annotation)
|
||||
onAnnotationDeleted(highlight)
|
||||
deleteHighlightConfirmFor = null
|
||||
}
|
||||
) {
|
||||
Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error)
|
||||
Text(readerString("action_delete", "Delete"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteAnnotationConfirmFor = null }) {
|
||||
TextButton(onClick = { deleteHighlightConfirmFor = null }) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
|
|
@ -67,6 +69,8 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val DesktopVerticalPdfPageTurnAnimationMillis = 140
|
||||
|
||||
@Composable
|
||||
internal fun DesktopVerticalPdfPage(
|
||||
document: DesktopPdfDocument,
|
||||
|
|
@ -96,6 +100,8 @@ internal fun DesktopVerticalPdfPage(
|
|||
themeStyle: DesktopPdfThemeStyle,
|
||||
shouldRender: Boolean,
|
||||
zoomPreview: DesktopPdfZoomPreview?,
|
||||
zoomPreviewAnchorPageRootOffset: Offset? = null,
|
||||
zoomPreviewScrollBounds: DesktopPdfZoomScrollBounds? = null,
|
||||
zoomViewportRootOffset: Offset,
|
||||
showPageNumberOverlay: Boolean = true,
|
||||
onSelectPage: (Int) -> Unit,
|
||||
|
|
@ -116,13 +122,16 @@ internal fun DesktopVerticalPdfPage(
|
|||
onTextDraftChanged: (String, IntSize) -> Unit,
|
||||
onTextDraftBoundsChanged: (PdfPageBounds) -> Unit,
|
||||
onPan: (Offset) -> Unit,
|
||||
onPageSizeChanged: (Int, IntSize) -> Unit = { _, _ -> },
|
||||
onPagePositioned: (Int, Offset) -> Unit
|
||||
) {
|
||||
val documentHandleId = document.handleId
|
||||
val density = LocalDensity.current
|
||||
var renderedPage by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfPageRender?>(null) }
|
||||
var renderError by remember(documentHandleId, pageIndex) { mutableStateOf<String?>(null) }
|
||||
var isRendering by remember(documentHandleId, pageIndex) { mutableStateOf(true) }
|
||||
var renderedPage by remember(documentHandleId) { mutableStateOf<DesktopPdfPageRender?>(null) }
|
||||
var renderedPageIndex by remember(documentHandleId) { mutableStateOf<Int?>(null) }
|
||||
var renderedPageScale by remember(documentHandleId) { mutableStateOf<Float?>(null) }
|
||||
var renderError by remember(documentHandleId) { mutableStateOf<String?>(null) }
|
||||
var isRendering by remember(documentHandleId) { mutableStateOf(true) }
|
||||
var pageCanvasSize by remember(documentHandleId, pageIndex) { mutableStateOf(IntSize.Zero) }
|
||||
var pageRootOffset by remember(documentHandleId, pageIndex) { mutableStateOf(Offset.Zero) }
|
||||
var selectionStartIndex by remember(documentHandleId, pageIndex) { mutableStateOf<Int?>(null) }
|
||||
|
|
@ -156,34 +165,73 @@ internal fun DesktopVerticalPdfPage(
|
|||
|
||||
LaunchedEffect(documentHandleId, pageIndex, scale, shouldRender) {
|
||||
if (!shouldRender) {
|
||||
logPdfZoomSettle {
|
||||
"item_render_skip page=${pageIndex + 1} reason=outside_window scale=${scale.formatLogFloat()}"
|
||||
}
|
||||
renderedPage = null
|
||||
renderedPageIndex = null
|
||||
renderedPageScale = null
|
||||
renderError = null
|
||||
isRendering = false
|
||||
clearInteractionState()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val hasPageRender = renderedPage != null
|
||||
val hasPageRender = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex)
|
||||
logPdfZoomSettle {
|
||||
"item_render_effect page=${pageIndex + 1} scale=${scale.formatLogFloat()} shouldRender=$shouldRender " +
|
||||
"hasRender=$hasPageRender renderedPage=${renderedPageIndex?.plus(1) ?: "none"} " +
|
||||
"renderScale=${renderedPageScale?.formatLogFloat() ?: "none"}"
|
||||
}
|
||||
if (!hasPageRender) {
|
||||
renderedPage = null
|
||||
renderedPageIndex = null
|
||||
renderedPageScale = null
|
||||
isRendering = true
|
||||
}
|
||||
renderError = null
|
||||
val pageSize = document.pageSizes.getOrNull(pageIndex)
|
||||
if (pageSize == null) {
|
||||
renderedPage = null
|
||||
renderedPageIndex = null
|
||||
renderedPageScale = null
|
||||
renderError = failedRenderMessage
|
||||
isRendering = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
||||
if (hasPageRender && !desktopPdfRenderScaleNeedsUpgrade(renderedPageScale, safeScale)) {
|
||||
logPdfZoomSettle {
|
||||
"item_render_skip page=${pageIndex + 1} reason=no_scale_upgrade " +
|
||||
"safeScale=${safeScale.formatLogFloat()} existingScale=${renderedPageScale?.formatLogFloat() ?: "none"}"
|
||||
}
|
||||
isRendering = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
logPdfZoomSettle {
|
||||
"item_render_scheduled page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " +
|
||||
"delayMs=${if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L} hasRender=$hasPageRender"
|
||||
}
|
||||
delay(if (hasPageRender) DesktopPdfZoomRenderDebounceMillis else 45L)
|
||||
isRendering = true
|
||||
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
||||
val renderStartedAt = System.currentTimeMillis()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { DesktopPdfium.renderPage(document, pageIndex, safeScale) }
|
||||
}
|
||||
result.getOrNull()?.let { renderedPage = it }
|
||||
val renderElapsedMs = System.currentTimeMillis() - renderStartedAt
|
||||
result.getOrNull()?.let {
|
||||
renderedPage = it
|
||||
renderedPageIndex = pageIndex
|
||||
renderedPageScale = safeScale
|
||||
}
|
||||
val renderedCurrentPage = renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex)
|
||||
renderError = result.exceptionOrNull()?.message
|
||||
?: if (renderedPage == null) failedRenderMessage else null
|
||||
?: if (!renderedCurrentPage && renderedPage == null) failedRenderMessage else null
|
||||
isRendering = false
|
||||
logPdfZoomSettle {
|
||||
"item_render_end page=${pageIndex + 1} safeScale=${safeScale.formatLogFloat()} " +
|
||||
"elapsedMs=$renderElapsedMs success=${result.isSuccess} bitmap=${renderedPage?.width ?: 0}x${renderedPage?.height ?: 0} " +
|
||||
"canvas=${pageCanvasSize.formatLogSize()} root=${pageRootOffset.formatLogOffset()}"
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isTextSelectionMode) {
|
||||
|
|
@ -205,6 +253,8 @@ internal fun DesktopVerticalPdfPage(
|
|||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
val pageSize = document.pageSizes.getOrNull(pageIndex)
|
||||
val displayPageIndex = renderedPageIndex ?: pageIndex
|
||||
val displayPageIsCurrent = displayPageIndex == pageIndex
|
||||
val placeholderScale = zoomSpec.clamp(scale)
|
||||
val placeholderWidthDp = with(density) { ((pageSize?.width ?: 612f) * placeholderScale).toDp() }
|
||||
val placeholderHeightDp = with(density) { ((pageSize?.height ?: 792f) * placeholderScale).toDp() }
|
||||
|
|
@ -224,24 +274,60 @@ internal fun DesktopVerticalPdfPage(
|
|||
.size(placeholderWidthDp, placeholderHeightDp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val rootOffset = coordinates.positionInRoot()
|
||||
if (rootOffset != pageRootOffset) {
|
||||
logPdfZoomSettle {
|
||||
"item_layout page=${pageIndex + 1} prevRoot=${pageRootOffset.formatLogOffset()} " +
|
||||
"nextRoot=${rootOffset.formatLogOffset()} scale=${scale.formatLogFloat()} " +
|
||||
"preview=${zoomPreview != null} canvas=${pageCanvasSize.formatLogSize()}"
|
||||
}
|
||||
}
|
||||
pageRootOffset = rootOffset
|
||||
onPagePositioned(pageIndex, rootOffset)
|
||||
}
|
||||
.onSizeChanged { pageCanvasSize = it }
|
||||
.onSizeChanged { size ->
|
||||
if (pageCanvasSize != size) {
|
||||
logPdfZoomSettle {
|
||||
"item_size page=${pageIndex + 1} prev=${pageCanvasSize.formatLogSize()} " +
|
||||
"next=${size.formatLogSize()} scale=${scale.formatLogFloat()} " +
|
||||
"preview=${zoomPreview != null} renderScale=${pageRenderScale.formatLogFloat()}"
|
||||
}
|
||||
}
|
||||
pageCanvasSize = size
|
||||
onPageSizeChanged(pageIndex, size)
|
||||
}
|
||||
.desktopPdfDocumentZoomPreviewLayer(
|
||||
preview = zoomPreview,
|
||||
currentZoom = scale,
|
||||
viewportRootOffset = zoomViewportRootOffset,
|
||||
pageRootOffset = pageRootOffset
|
||||
pageRootOffset = pageRootOffset,
|
||||
anchorPageRootOffset = zoomPreviewAnchorPageRootOffset,
|
||||
scrollBounds = zoomPreviewScrollBounds
|
||||
)
|
||||
.background(themeStyle.pageBackgroundColor, RoundedCornerShape(2.dp))
|
||||
.pointerInput(pageIndex, pageCanvasSize, isTextSelectionMode, selectedTool, isRichTextMode) {
|
||||
if (isRichTextMode) return@pointerInput
|
||||
.pointerInput(
|
||||
pageIndex,
|
||||
displayPageIsCurrent,
|
||||
pageCanvasSize,
|
||||
isTextSelectionMode,
|
||||
selectedTool,
|
||||
isRichTextMode
|
||||
) {
|
||||
if (!displayPageIsCurrent || isRichTextMode) return@pointerInput
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val point = event.changes.firstOrNull()?.position ?: continue
|
||||
if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) {
|
||||
if (isTextSelectionMode) {
|
||||
logPdfChromeTap {
|
||||
"page_press source=vertical_page page=${pageIndex + 1} " +
|
||||
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
|
||||
"consumedBefore=${event.changes.any { it.isConsumed }} " +
|
||||
"selectionActive=${currentTextSelection != null} " +
|
||||
"selectionMenuOpen=${selectionMenuOffset != null} " +
|
||||
"selectedTool=$selectedTool richText=$isRichTextMode"
|
||||
}
|
||||
}
|
||||
val highlightHit = if (selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER) {
|
||||
currentAnnotations.asReversed().firstOrNull {
|
||||
it.isDesktopTextSelectionHighlight &&
|
||||
|
|
@ -252,6 +338,10 @@ internal fun DesktopVerticalPdfPage(
|
|||
null
|
||||
}
|
||||
if (highlightHit != null) {
|
||||
logPdfChromeTap {
|
||||
"page_press_consume source=vertical_page page=${pageIndex + 1} " +
|
||||
"reason=text_selection_highlight annotation=${highlightHit.id}"
|
||||
}
|
||||
onSelectPage(pageIndex)
|
||||
onAnnotationSelected(highlightHit)
|
||||
clearInteractionState()
|
||||
|
|
@ -261,6 +351,10 @@ internal fun DesktopVerticalPdfPage(
|
|||
if (selectedTool != PdfInkTool.TEXT) {
|
||||
val linkTarget = document.linkAt(pageIndex, point, pageCanvasSize)
|
||||
if (linkTarget != null) {
|
||||
logPdfChromeTap {
|
||||
"page_press_consume source=vertical_page page=${pageIndex + 1} " +
|
||||
"reason=link target=${linkTarget.formatLogTarget()}"
|
||||
}
|
||||
logPdfLink(
|
||||
"tap_hit mode=vertical page=${pageIndex + 1} " +
|
||||
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
|
||||
|
|
@ -277,6 +371,10 @@ internal fun DesktopVerticalPdfPage(
|
|||
it.sharedPdfEmbeddedHitTest(point, pageCanvasSize)
|
||||
}
|
||||
if (embeddedHit != null) {
|
||||
logPdfChromeTap {
|
||||
"page_press_consume source=vertical_page page=${pageIndex + 1} " +
|
||||
"reason=embedded_annotation annotation=${embeddedHit.id}"
|
||||
}
|
||||
onSelectPage(pageIndex)
|
||||
onEmbeddedAnnotationSelected(embeddedHit)
|
||||
clearInteractionState()
|
||||
|
|
@ -285,7 +383,16 @@ internal fun DesktopVerticalPdfPage(
|
|||
currentTextSelection != null &&
|
||||
selectionMenuOffset == null
|
||||
) {
|
||||
logPdfChromeTap {
|
||||
"page_press_passthrough source=vertical_page page=${pageIndex + 1} " +
|
||||
"action=clear_selection consumed=false"
|
||||
}
|
||||
clearSelection()
|
||||
} else if (isTextSelectionMode) {
|
||||
logPdfChromeTap {
|
||||
"page_press_passthrough source=vertical_page page=${pageIndex + 1} " +
|
||||
"action=none consumed=false"
|
||||
}
|
||||
}
|
||||
} else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) {
|
||||
val selection = currentTextSelection
|
||||
|
|
@ -304,33 +411,41 @@ internal fun DesktopVerticalPdfPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
.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, displayPageIsCurrent, pageCanvasSize, isTextSelectionMode, isRichTextMode) {
|
||||
if (!displayPageIsCurrent || isRichTextMode || !isTextSelectionMode) return@pointerInput
|
||||
detectDesktopPdfTextSelectionLongPress(
|
||||
source = "vertical_page",
|
||||
pageIndex = pageIndex
|
||||
) { point ->
|
||||
val selection = document.wordSelectionAt(pageIndex, point, pageCanvasSize)
|
||||
logPdfChromeTap {
|
||||
"long_press_selection source=vertical_page page=${pageIndex + 1} " +
|
||||
"selectionFound=${selection != null} " +
|
||||
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()}"
|
||||
}
|
||||
)
|
||||
if (selection != null) {
|
||||
onSelectPage(pageIndex)
|
||||
selectionStartIndex = null
|
||||
selectionEndIndex = null
|
||||
selectionStartHit = null
|
||||
selectionEndHit = null
|
||||
activeSelectionHandle = null
|
||||
textSelection = selection
|
||||
selectionMenuOffset = selection.menuAnchor(pageCanvasSize, point)
|
||||
logPdfSelection(
|
||||
"long_press page=${pageIndex + 1} " +
|
||||
"x=${point.x.formatLogFloat()} y=${point.y.formatLogFloat()} " +
|
||||
"range=${selection.startIndex}..${selection.endIndex} " +
|
||||
"chars=${selection.text.length} " +
|
||||
"text=\"${selection.text.logPreview()}\""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(pageIndex, selectedTool, isTextSelectionMode, isRichTextMode) {
|
||||
if (isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) return@pointerInput
|
||||
.pointerInput(pageIndex, displayPageIsCurrent, selectedTool, isTextSelectionMode, isRichTextMode) {
|
||||
if (!displayPageIsCurrent || isRichTextMode || isTextSelectionMode || selectedTool != PdfInkTool.NONE) {
|
||||
return@pointerInput
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture
|
||||
|
|
@ -371,9 +486,10 @@ internal fun DesktopVerticalPdfPage(
|
|||
isRichTextMode,
|
||||
pageCanvasSize,
|
||||
renderedPageWidth,
|
||||
renderedPageHeight
|
||||
renderedPageHeight,
|
||||
displayPageIsCurrent
|
||||
) {
|
||||
if (renderedPageWidth > 0 && renderedPageHeight > 0) {
|
||||
if (displayPageIsCurrent && renderedPageWidth > 0 && renderedPageHeight > 0) {
|
||||
if (isRichTextMode) return@pointerInput
|
||||
if (isTextSelectionMode) {
|
||||
var latestSelectionDragPoint: Offset? = null
|
||||
|
|
@ -638,8 +754,21 @@ internal fun DesktopVerticalPdfPage(
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
renderedPage != null -> {
|
||||
renderError != null && renderedPageIndex != pageIndex -> Text(
|
||||
renderError ?: readerString("desktop_failed_render_page", "Failed to render page."),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
renderedPage != null && desktopPdfRenderBelongsToPage(renderedPageIndex, pageIndex) -> {
|
||||
val currentRenderedPageIndex = renderedPageIndex!!
|
||||
Crossfade(
|
||||
targetState = currentRenderedPageIndex,
|
||||
animationSpec = tween(DesktopVerticalPdfPageTurnAnimationMillis),
|
||||
label = "DesktopVerticalPdfPage"
|
||||
) { pageIndex ->
|
||||
val pageRender = renderedPage!!
|
||||
val pageEmbeddedAnnotations = remember(document.embeddedAnnotations, pageIndex) {
|
||||
document.embeddedAnnotations.filter { it.pageIndex == pageIndex }
|
||||
}
|
||||
val pageAnnotations = remember(annotations, pageIndex, pageCanvasSize) {
|
||||
annotations
|
||||
.filter { it.pageIndex == pageIndex }
|
||||
|
|
@ -806,6 +935,10 @@ internal fun DesktopVerticalPdfPage(
|
|||
.matchParentSize()
|
||||
.pointerInput(pageIndex, selectionMenuOffset) {
|
||||
detectTapGestures {
|
||||
logPdfChromeTap {
|
||||
"selection_menu_scrim_tap source=vertical_page page=${pageIndex + 1} " +
|
||||
"consumedByScrim=true"
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
|
@ -842,6 +975,7 @@ internal fun DesktopVerticalPdfPage(
|
|||
showSearch = externalLookupAvailable,
|
||||
onClear = ::clearSelection
|
||||
)
|
||||
}
|
||||
}
|
||||
isRendering -> CircularProgressIndicator()
|
||||
renderError != null -> Text(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
|
|
@ -9,11 +14,17 @@ import com.aryan.reader.shared.pdf.PdfPageBounds
|
|||
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||
import com.aryan.reader.shared.pdf.PdfSelectionGeometry
|
||||
import com.aryan.reader.shared.pdf.PdfTextCharBounds
|
||||
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfInkRenderer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderAction
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderState
|
||||
import com.aryan.reader.shared.pdf.SharedPdfTextDraft
|
||||
import com.aryan.reader.shared.pdf.reduce
|
||||
import com.aryan.reader.shared.ui.sharedPdfHitTest
|
||||
import com.aryan.reader.shared.ui.toSharedPdfPoint
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
internal val PdfInkTool.isDesktopHighlighter: Boolean
|
||||
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
|
@ -24,6 +35,24 @@ internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean
|
|||
rangeStartIndex != null &&
|
||||
rangeEndIndex != null
|
||||
|
||||
internal fun SharedPdfReaderState.withDesktopPdfTextSelectionHighlightAdded(
|
||||
annotation: SharedPdfAnnotation,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
val next = reduce(SharedPdfReaderAction.AnnotationAdded(annotation), zoomSpec)
|
||||
return if (annotation.isDesktopTextSelectionHighlight) {
|
||||
next.reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec)
|
||||
} else {
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedPdfReaderState.withDesktopPdfTextHighlightSheetDismissed(
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
return reduce(SharedPdfReaderAction.AnnotationSelected(null), zoomSpec)
|
||||
}
|
||||
|
||||
internal fun List<PdfPagePoint>.withDesktopPdfDragPoint(
|
||||
point: Offset,
|
||||
canvasSize: IntSize,
|
||||
|
|
@ -46,6 +75,95 @@ internal fun List<PdfPagePoint>.withDesktopPdfDragPoint(
|
|||
return this + nextPoint
|
||||
}
|
||||
|
||||
internal suspend fun PointerInputScope.detectDesktopPdfTextSelectionLongPress(
|
||||
source: String,
|
||||
pageIndex: Int,
|
||||
onLongPress: (Offset) -> Unit
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val secondaryDown = currentEvent.buttons.isSecondaryPressed
|
||||
logPdfChromeTap {
|
||||
"long_press_down source=$source page=${pageIndex + 1} " +
|
||||
"x=${down.position.x.formatLogFloat()} y=${down.position.y.formatLogFloat()} " +
|
||||
"downConsumed=${down.isConsumed} secondary=$secondaryDown"
|
||||
}
|
||||
if (down.isConsumed || secondaryDown) {
|
||||
logPdfChromeTap {
|
||||
"long_press_skip source=$source page=${pageIndex + 1} " +
|
||||
"reason=${if (down.isConsumed) "down_consumed" else "secondary_button"}"
|
||||
}
|
||||
return@awaitEachGesture
|
||||
}
|
||||
val pointerId = down.id
|
||||
val start = down.position
|
||||
var latestPosition = start
|
||||
var canceledBeforeLongPress = false
|
||||
var longPressReached = false
|
||||
var cancelReason = ""
|
||||
|
||||
try {
|
||||
withTimeout(viewConfiguration.longPressTimeoutMillis) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.buttons.isSecondaryPressed) {
|
||||
canceledBeforeLongPress = true
|
||||
cancelReason = "secondary_button"
|
||||
return@withTimeout
|
||||
}
|
||||
val change = event.changes.firstOrNull { it.id == pointerId }
|
||||
if (change == null) {
|
||||
canceledBeforeLongPress = true
|
||||
cancelReason = "pointer_lost"
|
||||
return@withTimeout
|
||||
}
|
||||
latestPosition = change.position
|
||||
val distance = (latestPosition - start).getDistance()
|
||||
when {
|
||||
change.isConsumed -> {
|
||||
canceledBeforeLongPress = true
|
||||
cancelReason = "change_consumed"
|
||||
return@withTimeout
|
||||
}
|
||||
change.changedToUp() || !change.pressed -> {
|
||||
canceledBeforeLongPress = true
|
||||
cancelReason = "up_before_long_press"
|
||||
return@withTimeout
|
||||
}
|
||||
distance > viewConfiguration.touchSlop -> {
|
||||
canceledBeforeLongPress = true
|
||||
cancelReason = "moved distance=${distance.formatLogFloat()}"
|
||||
return@withTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
longPressReached = !canceledBeforeLongPress
|
||||
}
|
||||
|
||||
if (!longPressReached) {
|
||||
logPdfChromeTap {
|
||||
"long_press_cancel source=$source page=${pageIndex + 1} " +
|
||||
"reason=${cancelReason.ifBlank { "unknown" }} " +
|
||||
"x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}"
|
||||
}
|
||||
return@awaitEachGesture
|
||||
}
|
||||
logPdfChromeTap {
|
||||
"long_press_reached source=$source page=${pageIndex + 1} " +
|
||||
"x=${latestPosition.x.formatLogFloat()} y=${latestPosition.y.formatLogFloat()}"
|
||||
}
|
||||
onLongPress(latestPosition)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture
|
||||
change.consume()
|
||||
if (change.changedToUp() || !change.pressed) return@awaitEachGesture
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DesktopPdfCharHit(
|
||||
val index: Int,
|
||||
val source: String,
|
||||
|
|
@ -318,7 +436,7 @@ private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds {
|
|||
}
|
||||
|
||||
internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L
|
||||
internal const val DesktopPdfZoomCommitDebounceMillis = 180L
|
||||
internal const val DesktopPdfZoomCommitDebounceMillis = 260L
|
||||
internal const val DesktopPdfZoomRenderDebounceMillis = 300L
|
||||
internal const val DesktopPdfViewportPersistDebounceMillis = 300L
|
||||
internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.pdf.PdfSpreadLayout
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal fun desktopPdfPageScrubTarget(
|
||||
value: Float,
|
||||
pageCount: Int,
|
||||
displayMode: PdfDisplayMode,
|
||||
settings: ReaderSettings
|
||||
): Int {
|
||||
val clampedPage = value.roundToInt().coerceIn(0, (pageCount - 1).coerceAtLeast(0))
|
||||
return if (displayMode == PdfDisplayMode.PAGINATION) {
|
||||
PdfSpreadLayout.normalizePageIndex(clampedPage, pageCount, settings)
|
||||
} else {
|
||||
clampedPage
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopPdfPageScrubCommitTarget(
|
||||
previewPage: Int?,
|
||||
currentPage: Int,
|
||||
pageCount: Int
|
||||
): Int {
|
||||
return (previewPage ?: currentPage).coerceIn(0, (pageCount - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
|
@ -53,7 +53,6 @@ import androidx.compose.ui.unit.IntOffset
|
|||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||
|
|
@ -292,11 +291,15 @@ internal fun PdfSelectionMenu(
|
|||
val anchor = menuOffset ?: return
|
||||
val selectionBounds = selection.canvasBounds(canvasSize)
|
||||
val paletteColors = remember(highlighterPalette) {
|
||||
SharedPdfAndroidHighlightColors.palette
|
||||
SharedPdfHighlighterPalette(highlighterPalette).sanitized().colors
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) {
|
||||
mutableStateOf<Int?>(null)
|
||||
}
|
||||
var editingHighlighterDraftColors by remember(selection.startIndex, selection.endIndex, paletteColors) {
|
||||
mutableStateOf<List<Int>>(emptyList())
|
||||
}
|
||||
val actions = buildList {
|
||||
add(PdfSelectionMenuAction(readerString("action_copy", "Copy"), DesktopPdfSelectionMenuIcons.Copy, onCopy))
|
||||
if (showDefine) add(PdfSelectionMenuAction(readerString("action_define", "Define"), DesktopPdfSelectionMenuIcons.Dictionary, onDefine))
|
||||
|
|
@ -304,13 +307,38 @@ internal fun PdfSelectionMenu(
|
|||
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)
|
||||
|
||||
fun highlighterDraftColors(): List<Int> {
|
||||
return editingHighlighterDraftColors.ifEmpty { paletteColors }
|
||||
}
|
||||
|
||||
fun updateHighlighterDraft(slotIndex: Int, color: Color): List<Int> {
|
||||
val nextColors = highlighterDraftColors().toMutableList()
|
||||
if (slotIndex in nextColors.indices) {
|
||||
nextColors[slotIndex] = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
editingHighlighterDraftColors = nextColors
|
||||
}
|
||||
return nextColors
|
||||
}
|
||||
|
||||
fun openHighlighterEditor(slotIndex: Int) {
|
||||
if (editingHighlighterSlot == null) {
|
||||
editingHighlighterDraftColors = paletteColors
|
||||
}
|
||||
editingHighlighterSlot = slotIndex
|
||||
}
|
||||
|
||||
val actionRowCount = ((actions.size + 2) / 3).coerceAtLeast(1)
|
||||
val popupWidthPx = with(density) { PdfSelectionMenuWidth.toPx() }
|
||||
val estimatedHeightPx = with(density) {
|
||||
PdfSelectionMenuPaletteHeight.toPx() +
|
||||
(actionRowCount * PdfSelectionMenuActionRowHeight.toPx())
|
||||
}
|
||||
val placement = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height),
|
||||
popup = SharedSelectionMenuSize(
|
||||
width = PdfSelectionMenuWidthPx.roundToInt(),
|
||||
height = estimatedHeight.roundToInt()
|
||||
width = popupWidthPx.roundToInt(),
|
||||
height = estimatedHeightPx.roundToInt()
|
||||
),
|
||||
selection = if (selectionBounds != null) {
|
||||
SharedSelectionMenuRect(
|
||||
|
|
@ -327,8 +355,8 @@ internal fun PdfSelectionMenu(
|
|||
bottom = anchor.y
|
||||
)
|
||||
},
|
||||
marginPx = PdfSelectionMenuMarginPx,
|
||||
gapPx = PdfSelectionMenuAnchorGapPx
|
||||
marginPx = with(density) { PdfSelectionMenuMargin.toPx() },
|
||||
gapPx = with(density) { PdfSelectionMenuAnchorGap.toPx() }
|
||||
)
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||
Surface(
|
||||
|
|
@ -385,7 +413,7 @@ internal fun PdfSelectionMenu(
|
|||
)
|
||||
)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp))
|
||||
.clickable { editingHighlighterSlot = 0 }
|
||||
.clickable { openHighlighterEditor(0) }
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
|
@ -436,20 +464,27 @@ internal fun PdfSelectionMenu(
|
|||
}
|
||||
}
|
||||
editingHighlighterSlot?.let { requestedSlot ->
|
||||
val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex)
|
||||
val initialColor = Color(paletteColors[slot]).copy(alpha = 1f)
|
||||
val draftColors = highlighterDraftColors()
|
||||
val safeDraftColors = draftColors.ifEmpty { SharedPdfHighlighterPalette.defaultColors }
|
||||
val slot = requestedSlot.coerceIn(0, safeDraftColors.lastIndex)
|
||||
val initialColor = remember(slot) { Color(safeDraftColors[slot]).copy(alpha = 1f) }
|
||||
SharedHsvColorPickerDialog(
|
||||
initialColor = initialColor,
|
||||
title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1),
|
||||
onDismiss = { editingHighlighterSlot = null },
|
||||
onSave = { color ->
|
||||
val nextColors = updateHighlighterDraft(slot, color)
|
||||
onHighlighterPaletteChange(
|
||||
SharedPdfHighlighterPalette(paletteColors).withColorAt(
|
||||
slotIndex = slot,
|
||||
colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
)
|
||||
SharedPdfHighlighterPalette(nextColors).sanitized()
|
||||
)
|
||||
editingHighlighterSlot = null
|
||||
},
|
||||
resetColor = Color(SharedPdfHighlighterPalette.defaultColors.getOrElse(slot) {
|
||||
SharedPdfHighlighterPalette.defaultColors.first()
|
||||
}).copy(alpha = 1f),
|
||||
stateKey = slot,
|
||||
onLiveColorChange = { color ->
|
||||
updateHighlighterDraft(slot, color)
|
||||
}
|
||||
) { liveColor ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
|
|
@ -458,7 +493,7 @@ internal fun PdfSelectionMenu(
|
|||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
paletteColors.forEachIndexed { index, argb ->
|
||||
highlighterDraftColors().forEachIndexed { index, argb ->
|
||||
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -467,10 +502,14 @@ internal fun PdfSelectionMenu(
|
|||
.background(color)
|
||||
.border(
|
||||
width = if (index == slot) 3.dp else 1.dp,
|
||||
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||
color = if (index == slot) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
|
||||
},
|
||||
shape = RoundedCornerShape(21.dp)
|
||||
)
|
||||
.clickable { editingHighlighterSlot = index },
|
||||
.clickable { openHighlighterEditor(index) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
|
|
@ -494,11 +533,11 @@ private data class PdfSelectionMenuAction(
|
|||
val isDestructive: Boolean = false
|
||||
)
|
||||
|
||||
private const val PdfSelectionMenuWidthPx = 220f
|
||||
private const val PdfSelectionMenuPaletteHeightPx = 54f
|
||||
private const val PdfSelectionMenuActionRowHeightPx = 66f
|
||||
private const val PdfSelectionMenuAnchorGapPx = 16f
|
||||
private const val PdfSelectionMenuMarginPx = 6f
|
||||
private val PdfSelectionMenuWidth = 220.dp
|
||||
private val PdfSelectionMenuPaletteHeight = 54.dp
|
||||
private val PdfSelectionMenuActionRowHeight = 66.dp
|
||||
private val PdfSelectionMenuAnchorGap = 16.dp
|
||||
private val PdfSelectionMenuMargin = 6.dp
|
||||
private const val DesktopPdfSelectionHandleTouchWidthPx = 44f
|
||||
private const val DesktopPdfSelectionHandleTouchTopPx = 8f
|
||||
private const val DesktopPdfSelectionHandleTouchBottomPx = 40f
|
||||
|
|
|
|||
|
|
@ -36,18 +36,40 @@ internal fun DesktopPdfAnnotationSidecarEffect(
|
|||
emptyList()
|
||||
}
|
||||
onAnnotationsLoaded(loadedAnnotations)
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.local.load_annotations document=$documentHandleId count=${loadedAnnotations.size} " +
|
||||
"exists=${annotationFile.exists()} bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()}"
|
||||
}
|
||||
onAnnotationsLoadedChange(true)
|
||||
}
|
||||
|
||||
LaunchedEffect(documentHandleId, annotations, annotationsLoaded) {
|
||||
if (!annotationsLoaded) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) {
|
||||
val changed = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
}
|
||||
val nextJson = SharedPdfAnnotationSerializer.encode(annotations)
|
||||
when {
|
||||
annotations.isEmpty() && annotationFile.isFile -> {
|
||||
annotationFile.delete()
|
||||
}
|
||||
annotations.isEmpty() -> false
|
||||
annotationFile.isFile && annotationFile.readText() == nextJson -> false
|
||||
else -> {
|
||||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(nextJson)
|
||||
true
|
||||
}
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
if (changed) {
|
||||
logDesktopCloudAnnotations {
|
||||
"desktop.local.save_annotations document=$documentHandleId count=${annotations.size} " +
|
||||
"bytes=${annotationFile.length()} ts=${annotationFile.lastModifiedIfFileForCloudLog()} " +
|
||||
"path=${annotationFile.absolutePath.logPreview(140)}"
|
||||
}
|
||||
onLocalSidecarsChanged()
|
||||
}
|
||||
onLocalSidecarsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,13 +98,23 @@ internal fun DesktopPdfBookmarkSidecarEffect(
|
|||
|
||||
LaunchedEffect(documentHandleId, bookmarks, bookmarksLoaded) {
|
||||
if (!bookmarksLoaded) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) {
|
||||
val changed = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks))
|
||||
}
|
||||
val nextJson = SharedPdfBookmarkSerializer.encode(bookmarks)
|
||||
when {
|
||||
bookmarks.isEmpty() && !bookmarkFile.isFile -> false
|
||||
bookmarkFile.isFile && bookmarkFile.readText() == nextJson -> false
|
||||
else -> {
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
bookmarkFile.writeText(nextJson)
|
||||
true
|
||||
}
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
if (changed) {
|
||||
onLocalSidecarsChanged()
|
||||
}
|
||||
onLocalSidecarsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,3 +209,7 @@ internal fun DesktopPdfSearchResultsEffect(
|
|||
onSearchResultsChange(results)
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfFileForCloudLog(): Long {
|
||||
return if (isFile) lastModified() else 0L
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,49 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
|
||||
private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1"
|
||||
private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t2"
|
||||
|
||||
internal fun desktopPdfAnnotationFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json")
|
||||
val safeName = desktopPdfDocumentKey(documentPath)
|
||||
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
|
||||
return sidecarFileWithLegacyMigration(
|
||||
file = File(desktopUserDataRoot(), "annotations/pdf_$safeName.json"),
|
||||
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_$legacyName.json")
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfAnnotationDeletionFile(documentPath: String): File {
|
||||
val safeName = desktopPdfDocumentKey(documentPath)
|
||||
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
|
||||
return sidecarFileWithLegacyMigration(
|
||||
file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_deleted_annotations.json"),
|
||||
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_deleted_annotations.json")
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfBookmarkFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json")
|
||||
val safeName = desktopPdfDocumentKey(documentPath)
|
||||
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
|
||||
return sidecarFileWithLegacyMigration(
|
||||
file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json"),
|
||||
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_bookmarks.json")
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfRichTextFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json")
|
||||
val safeName = desktopPdfDocumentKey(documentPath)
|
||||
val legacyName = desktopPdfLegacyDocumentKey(documentPath)
|
||||
return sidecarFileWithLegacyMigration(
|
||||
file = File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json"),
|
||||
legacyFile = File(desktopUserDataRoot(), "annotations/pdf_${legacyName}_rich_text.json")
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfSearchIndexFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
val safeName = desktopPdfDocumentKey(documentPath)
|
||||
return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv")
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +60,7 @@ internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFil
|
|||
if (parts.size == 2) parts[0] to parts[1] else null
|
||||
}
|
||||
.toMap()
|
||||
val isFresh = metadata["pathHash"] == document.path.hashCode().toString() &&
|
||||
val isFresh = metadata["pathKey"] == desktopPdfDocumentKey(document.path) &&
|
||||
metadata["fileSize"] == sourceFile.length().toString() &&
|
||||
metadata["lastModified"] == sourceFile.lastModified().toString() &&
|
||||
metadata["pageCount"] == document.pageCount.toString()
|
||||
|
|
@ -65,7 +87,7 @@ internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile:
|
|||
val encoder = Base64.getEncoder()
|
||||
val payload = buildString {
|
||||
appendLine(DesktopPdfSearchIndexHeader)
|
||||
appendLine("pathHash\t${document.path.hashCode()}")
|
||||
appendLine("pathKey\t${desktopPdfDocumentKey(document.path)}")
|
||||
appendLine("fileSize\t${sourceFile.length()}")
|
||||
appendLine("lastModified\t${sourceFile.lastModified()}")
|
||||
appendLine("pageCount\t${document.pageCount}")
|
||||
|
|
@ -81,3 +103,30 @@ internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile:
|
|||
indexFile.writeText(payload, Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopPdfDocumentKey(documentPath: String): String {
|
||||
val normalizedPath = runCatching { File(documentPath).canonicalPath }
|
||||
.getOrElse { documentPath.trim() }
|
||||
return sha256Hex(normalizedPath).take(32)
|
||||
}
|
||||
|
||||
private fun desktopPdfLegacyDocumentKey(documentPath: String): String {
|
||||
return documentPath.hashCode().toString().replace("-", "n")
|
||||
}
|
||||
|
||||
private fun sidecarFileWithLegacyMigration(file: File, legacyFile: File): File {
|
||||
if (!file.exists() && legacyFile.isFile && legacyFile != file) {
|
||||
runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
if (!legacyFile.renameTo(file)) {
|
||||
legacyFile.copyTo(file, overwrite = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun sha256Hex(value: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
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.SharedPdfBookmark
|
||||
import com.aryan.reader.shared.pdf.SharedPdfBookmarkSerializer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
private val desktopPdfSyncJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
internal fun desktopPdfAnnotationElementForSync(rawJson: String): JsonElement? {
|
||||
val annotations = SharedPdfAnnotationSerializer.decode(rawJson)
|
||||
if (annotations.isEmpty()) return null
|
||||
return SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||
}
|
||||
|
||||
internal fun desktopPdfRichTextElementForSync(rawJson: String): JsonElement? {
|
||||
val element = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull()
|
||||
?: return null
|
||||
val document = SharedPdfRichTextSerializer.decodeElement(element)
|
||||
if (document.text.isEmpty() && document.spans.isEmpty()) return null
|
||||
return SharedPdfRichTextSerializer.encodeElement(document)
|
||||
}
|
||||
|
||||
internal fun desktopPdfBookmarksMetadataJson(book: BookItem): String? {
|
||||
if (book.type != FileType.PDF) return null
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return null
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path).takeIf { it.isFile } ?: return null
|
||||
return desktopPdfBookmarksMetadataJson(
|
||||
bookmarks = SharedPdfBookmarkSerializer.decode(bookmarkFile.readText()),
|
||||
lastPageIndex = book.lastPageIndex
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfBookmarksMetadataJson(
|
||||
bookmarks: List<SharedPdfBookmark>,
|
||||
lastPageIndex: Int?
|
||||
): String {
|
||||
val totalPages = maxOf(
|
||||
(lastPageIndex ?: 0) + 1,
|
||||
(bookmarks.maxOfOrNull { it.pageIndex } ?: 0) + 1
|
||||
).coerceAtLeast(1)
|
||||
return desktopPdfSyncJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonArray(
|
||||
bookmarks.map { bookmark ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"pageIndex" to JsonPrimitive(bookmark.pageIndex.coerceAtLeast(0)),
|
||||
"title" to JsonPrimitive(bookmark.label.ifBlank { "Page ${bookmark.pageIndex + 1}" }),
|
||||
"totalPages" to JsonPrimitive(totalPages)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfBookmarkMetadataTimestamp(book: BookItem): Long {
|
||||
if (book.type != FileType.PDF) return 0L
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return 0L
|
||||
return desktopPdfBookmarkFile(path).lastModifiedIfFile()
|
||||
}
|
||||
|
||||
internal fun importDesktopPdfBookmarksMetadata(
|
||||
book: BookItem,
|
||||
bookmarksJson: String?,
|
||||
timestamp: Long
|
||||
): Boolean {
|
||||
if (book.type != FileType.PDF) return false
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return false
|
||||
val rawJson = bookmarksJson?.takeIf { it.isNotBlank() } ?: return false
|
||||
val bookmarks = desktopPdfBookmarksFromMetadataJson(rawJson)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val localTimestamp = bookmarkFile.lastModifiedIfFile()
|
||||
if (timestamp <= localTimestamp + 1000L) return false
|
||||
|
||||
if (bookmarks.isEmpty()) {
|
||||
if (bookmarkFile.isFile) bookmarkFile.delete()
|
||||
return true
|
||||
}
|
||||
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
bookmarkFile.writeText(SharedPdfBookmarkSerializer.encode(bookmarks))
|
||||
bookmarkFile.setLastModified(timestamp)
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun desktopPdfBookmarksFromMetadataJson(rawJson: String): List<SharedPdfBookmark> {
|
||||
val root = runCatching { desktopPdfSyncJson.parseToJsonElement(rawJson) }.getOrNull()
|
||||
?: return emptyList()
|
||||
|
||||
root.jsonArrayOrNull()?.let { androidBookmarks ->
|
||||
return androidBookmarks.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val pageIndex = obj.int("pageIndex") ?: return@mapNotNull null
|
||||
SharedPdfBookmark(
|
||||
pageIndex = pageIndex.coerceAtLeast(0),
|
||||
label = obj.string("title") ?: obj.string("label") ?: "Page ${pageIndex + 1}",
|
||||
createdAt = obj.longString("createdAt") ?: 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return SharedPdfBookmarkSerializer.decode(rawJson)
|
||||
}
|
||||
|
||||
private fun File.lastModifiedIfFile(): Long {
|
||||
return if (isFile()) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return this[name]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.longString(name: String): Long? {
|
||||
return string(name)?.toLongOrNull()
|
||||
?: this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
||||
}
|
||||
|
|
@ -2,12 +2,15 @@ package com.aryan.reader.desktop
|
|||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderTheme
|
||||
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
|
||||
|
||||
internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.VERTICAL_SCROLL
|
||||
internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.PAGINATION
|
||||
internal val DesktopDefaultPdfVerticalPageGap = 8.dp
|
||||
internal val DesktopDefaultPdfSpreadPageGap = 18.dp
|
||||
|
||||
internal fun desktopPdfPageBackgroundColor(
|
||||
theme: ReaderTheme,
|
||||
|
|
@ -28,3 +31,26 @@ internal fun desktopPdfVerticalViewportBackgroundColor(
|
|||
): Color {
|
||||
return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor
|
||||
}
|
||||
|
||||
internal fun desktopPdfViewportBackgroundColor(
|
||||
displayMode: PdfDisplayMode,
|
||||
pageBackgroundColor: Color,
|
||||
appBackgroundColor: Color,
|
||||
isVerticalPageGapVisible: Boolean
|
||||
): Color {
|
||||
return when (displayMode) {
|
||||
PdfDisplayMode.VERTICAL_SCROLL -> desktopPdfVerticalViewportBackgroundColor(
|
||||
pageBackgroundColor = pageBackgroundColor,
|
||||
gapBackgroundColor = appBackgroundColor,
|
||||
isPageGapVisible = isVerticalPageGapVisible
|
||||
)
|
||||
PdfDisplayMode.PAGINATION -> appBackgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopPdfSpreadPageGapDp(
|
||||
isPageGapVisible: Boolean
|
||||
): Dp = pdfVerticalPageGapDp(
|
||||
isPageGapVisible = isPageGapVisible,
|
||||
defaultGap = DesktopDefaultPdfSpreadPageGap
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import kotlin.math.exp
|
|||
import kotlin.math.roundToInt
|
||||
|
||||
private const val DesktopPdfZoomGestureFrameMillis = 16L
|
||||
private const val DesktopPdfZoomPreviewTolerance = 0.0001f
|
||||
internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f
|
||||
|
||||
internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float {
|
||||
|
|
@ -117,19 +118,320 @@ internal fun desktopPdfPaginationFirstRenderScale(
|
|||
return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale)
|
||||
}
|
||||
|
||||
internal fun desktopPdfRenderBelongsToPage(
|
||||
renderedPageIndex: Int?,
|
||||
requestedPageIndex: Int
|
||||
): Boolean {
|
||||
return renderedPageIndex == requestedPageIndex
|
||||
}
|
||||
|
||||
internal fun desktopPdfRenderScaleNeedsUpgrade(
|
||||
renderedScale: Float?,
|
||||
requestedScale: Float
|
||||
): Boolean {
|
||||
if (renderedScale == null) return true
|
||||
if (!requestedScale.isFinite() || requestedScale <= 0f) return false
|
||||
if (!renderedScale.isFinite() || renderedScale <= 0f) return true
|
||||
return requestedScale - renderedScale > DesktopPdfRenderScaleTolerance
|
||||
}
|
||||
|
||||
internal fun desktopPdfSpreadZoomAnchorPageIndex(
|
||||
viewportRootOffset: Offset,
|
||||
anchor: Offset?,
|
||||
visiblePageIndices: List<Int>,
|
||||
pageRootOffsets: Map<Int, Offset>,
|
||||
pageSizes: Map<Int, IntSize>,
|
||||
fallbackPageIndex: Int
|
||||
): Int {
|
||||
if (anchor == null || visiblePageIndices.isEmpty()) return fallbackPageIndex
|
||||
if (!anchor.x.isFinite() || !anchor.y.isFinite()) return fallbackPageIndex
|
||||
val rootAnchor = viewportRootOffset + anchor
|
||||
if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return fallbackPageIndex
|
||||
val candidates = visiblePageIndices.mapNotNull { pageIndex ->
|
||||
pageRootOffsets[pageIndex]?.let { root ->
|
||||
pageIndex to root
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty()) return fallbackPageIndex
|
||||
candidates.firstOrNull { (pageIndex, root) ->
|
||||
val size = pageSizes[pageIndex] ?: return@firstOrNull false
|
||||
val width = size.width.toFloat()
|
||||
val height = size.height.toFloat()
|
||||
rootAnchor.x >= root.x &&
|
||||
rootAnchor.x <= root.x + width &&
|
||||
rootAnchor.y >= root.y &&
|
||||
rootAnchor.y <= root.y + height
|
||||
}?.let { return it.first }
|
||||
return candidates.minByOrNull { (pageIndex, root) ->
|
||||
val size = pageSizes[pageIndex]
|
||||
val dx: Float
|
||||
val dy: Float
|
||||
if (size == null) {
|
||||
dx = rootAnchor.x - root.x
|
||||
dy = rootAnchor.y - root.y
|
||||
} else {
|
||||
val right = root.x + size.width.toFloat()
|
||||
val bottom = root.y + size.height.toFloat()
|
||||
dx = when {
|
||||
rootAnchor.x < root.x -> root.x - rootAnchor.x
|
||||
rootAnchor.x > right -> rootAnchor.x - right
|
||||
else -> 0f
|
||||
}
|
||||
dy = when {
|
||||
rootAnchor.y < root.y -> root.y - rootAnchor.y
|
||||
rootAnchor.y > bottom -> rootAnchor.y - bottom
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
dx * dx + dy * dy
|
||||
}?.first ?: fallbackPageIndex
|
||||
}
|
||||
|
||||
internal data class DesktopPdfZoomPreview(
|
||||
val baseZoom: Float,
|
||||
val zoom: Float,
|
||||
val anchor: Offset?,
|
||||
val displayMode: PdfDisplayMode,
|
||||
val pageIndex: Int?
|
||||
val pageIndex: Int?,
|
||||
val viewportRootOffset: Offset = Offset.Zero,
|
||||
val pageRootOffset: Offset? = null,
|
||||
val commitTargetHorizontalScroll: Int? = null,
|
||||
val commitTargetVerticalScroll: Int? = null,
|
||||
val diagnosticSequence: Int = 0
|
||||
)
|
||||
|
||||
internal data class DesktopPdfZoomScrollBounds(
|
||||
val currentHorizontalScroll: Int? = null,
|
||||
val maxHorizontalScroll: Int? = null,
|
||||
val currentVerticalScroll: Int? = null,
|
||||
val maxVerticalScroll: Int? = null
|
||||
)
|
||||
|
||||
internal interface DesktopPdfLayoutScrollPrediction {
|
||||
val maxHorizontalScroll: Int
|
||||
val maxVerticalScroll: Int
|
||||
}
|
||||
|
||||
internal data class DesktopPdfSinglePageLayoutPrediction(
|
||||
val rootOffset: Offset,
|
||||
override val maxHorizontalScroll: Int,
|
||||
override val maxVerticalScroll: Int
|
||||
) : DesktopPdfLayoutScrollPrediction
|
||||
|
||||
internal data class DesktopPdfSpreadLayoutPrediction(
|
||||
val pageRootOffsets: Map<Int, Offset>,
|
||||
override val maxHorizontalScroll: Int,
|
||||
override val maxVerticalScroll: Int
|
||||
) : DesktopPdfLayoutScrollPrediction
|
||||
|
||||
internal data class DesktopPdfCachedPageRender(
|
||||
val render: DesktopPdfPageRender,
|
||||
val scale: Float
|
||||
)
|
||||
|
||||
internal data class DesktopPdfNavigationZoomSnapshot(
|
||||
val zoom: Float,
|
||||
val horizontalScroll: Int,
|
||||
val verticalScroll: Int
|
||||
)
|
||||
|
||||
internal fun desktopPdfNavigationZoomSnapshot(
|
||||
preview: DesktopPdfZoomPreview?,
|
||||
currentHorizontalScroll: Int,
|
||||
currentVerticalScroll: Int
|
||||
): DesktopPdfNavigationZoomSnapshot? {
|
||||
val activePreview = preview ?: return null
|
||||
val baseZoom = activePreview.baseZoom.takeIf { it.isFinite() && it > 0f } ?: return null
|
||||
val targetZoom = activePreview.zoom.takeIf { it.isFinite() && it > 0f } ?: return null
|
||||
val anchor = activePreview.anchor
|
||||
return DesktopPdfNavigationZoomSnapshot(
|
||||
zoom = targetZoom,
|
||||
horizontalScroll = anchor?.let {
|
||||
desktopPdfAnchoredScrollTarget(currentHorizontalScroll, it.x, baseZoom, targetZoom)
|
||||
} ?: currentHorizontalScroll.coerceAtLeast(0),
|
||||
verticalScroll = anchor?.let {
|
||||
desktopPdfAnchoredScrollTarget(currentVerticalScroll, it.y, baseZoom, targetZoom)
|
||||
} ?: currentVerticalScroll.coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfZoomPreviewMatchesScale(
|
||||
preview: DesktopPdfZoomPreview,
|
||||
scale: Float
|
||||
): Boolean {
|
||||
return abs(preview.baseZoom - scale) <= DesktopPdfZoomPreviewTolerance ||
|
||||
abs(preview.zoom - scale) <= DesktopPdfZoomPreviewTolerance
|
||||
}
|
||||
|
||||
internal fun desktopPdfReachableScrollDelta(
|
||||
currentScroll: Int?,
|
||||
maxScroll: Int?,
|
||||
requestedDelta: Int
|
||||
): Int {
|
||||
if (currentScroll == null || maxScroll == null) return requestedDelta
|
||||
val safeMax = maxScroll.coerceAtLeast(0)
|
||||
val safeCurrent = currentScroll.coerceIn(0, safeMax)
|
||||
val targetScroll = (safeCurrent + requestedDelta).coerceIn(0, safeMax)
|
||||
return targetScroll - safeCurrent
|
||||
}
|
||||
|
||||
internal fun desktopPdfReachableScrollDelta(
|
||||
requestedDelta: IntOffset,
|
||||
scrollBounds: DesktopPdfZoomScrollBounds?
|
||||
): IntOffset {
|
||||
if (scrollBounds == null) return requestedDelta
|
||||
return IntOffset(
|
||||
x = desktopPdfReachableScrollDelta(
|
||||
currentScroll = scrollBounds.currentHorizontalScroll,
|
||||
maxScroll = scrollBounds.maxHorizontalScroll,
|
||||
requestedDelta = requestedDelta.x
|
||||
),
|
||||
y = desktopPdfReachableScrollDelta(
|
||||
currentScroll = scrollBounds.currentVerticalScroll,
|
||||
maxScroll = scrollBounds.maxVerticalScroll,
|
||||
requestedDelta = requestedDelta.y
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfZoomScrollBoundsWithCommitTargets(
|
||||
preview: DesktopPdfZoomPreview?,
|
||||
currentHorizontalScroll: Int,
|
||||
maxHorizontalScroll: Int,
|
||||
currentVerticalScroll: Int? = null,
|
||||
maxVerticalScroll: Int? = null
|
||||
): DesktopPdfZoomScrollBounds {
|
||||
return DesktopPdfZoomScrollBounds(
|
||||
currentHorizontalScroll = currentHorizontalScroll,
|
||||
maxHorizontalScroll = maxOf(maxHorizontalScroll, preview?.commitTargetHorizontalScroll ?: 0),
|
||||
currentVerticalScroll = currentVerticalScroll,
|
||||
maxVerticalScroll = maxVerticalScroll?.let {
|
||||
maxOf(it, preview?.commitTargetVerticalScroll ?: 0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfSinglePageLayoutPrediction(
|
||||
viewportRootOffset: Offset,
|
||||
viewportSize: IntSize,
|
||||
pageCanvasSize: IntSize,
|
||||
horizontalScroll: Int,
|
||||
verticalScroll: Int,
|
||||
paddingPx: Float
|
||||
): DesktopPdfSinglePageLayoutPrediction? {
|
||||
if (viewportSize.width <= 0 || viewportSize.height <= 0) return null
|
||||
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null
|
||||
if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null
|
||||
if (!paddingPx.isFinite() || paddingPx < 0f) return null
|
||||
val viewportWidth = viewportSize.width.toFloat()
|
||||
val viewportHeight = viewportSize.height.toFloat()
|
||||
val pageWidth = pageCanvasSize.width.toFloat()
|
||||
val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f)
|
||||
val maxHorizontalScroll = (pageWidth + paddingPx * 2f - viewportWidth)
|
||||
.roundToInt()
|
||||
.coerceAtLeast(0)
|
||||
val maxVerticalScroll = (pageCanvasSize.height.toFloat() + paddingPx * 2f - viewportHeight)
|
||||
.roundToInt()
|
||||
.coerceAtLeast(0)
|
||||
val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll)
|
||||
val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll)
|
||||
val pageX = if (pageWidth <= contentWidth) {
|
||||
((viewportWidth - pageWidth) / 2f) - safeHorizontalScroll.toFloat()
|
||||
} else {
|
||||
paddingPx - safeHorizontalScroll.toFloat()
|
||||
}
|
||||
val pageY = paddingPx - safeVerticalScroll.toFloat()
|
||||
return DesktopPdfSinglePageLayoutPrediction(
|
||||
rootOffset = Offset(
|
||||
x = viewportRootOffset.x + pageX,
|
||||
y = viewportRootOffset.y + pageY
|
||||
),
|
||||
maxHorizontalScroll = maxHorizontalScroll,
|
||||
maxVerticalScroll = maxVerticalScroll
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfSpreadLayoutPrediction(
|
||||
viewportRootOffset: Offset,
|
||||
viewportSize: IntSize,
|
||||
visiblePageIndices: List<Int>,
|
||||
pageCanvasSizes: Map<Int, IntSize>,
|
||||
horizontalScroll: Int,
|
||||
verticalScroll: Int,
|
||||
paddingPx: Float,
|
||||
pageGapPx: Float
|
||||
): DesktopPdfSpreadLayoutPrediction? {
|
||||
if (viewportSize.width <= 0 || viewportSize.height <= 0) return null
|
||||
if (visiblePageIndices.isEmpty()) return null
|
||||
if (!viewportRootOffset.x.isFinite() || !viewportRootOffset.y.isFinite()) return null
|
||||
if (!paddingPx.isFinite() || paddingPx < 0f) return null
|
||||
if (!pageGapPx.isFinite() || pageGapPx < 0f) return null
|
||||
val pageSizes = visiblePageIndices.map { pageIndex ->
|
||||
val pageSize = pageCanvasSizes[pageIndex] ?: return null
|
||||
if (pageSize.width <= 0 || pageSize.height <= 0) return null
|
||||
pageSize
|
||||
}
|
||||
val viewportWidth = viewportSize.width.toFloat()
|
||||
val viewportHeight = viewportSize.height.toFloat()
|
||||
val rowWidth = pageSizes.sumOf { it.width }.toFloat() +
|
||||
(pageGapPx * (pageSizes.size - 1).coerceAtLeast(0))
|
||||
val rowHeight = pageSizes.maxOf { it.height }.toFloat()
|
||||
val contentWidth = (viewportWidth - paddingPx * 2f).coerceAtLeast(0f)
|
||||
val maxHorizontalScroll = (rowWidth + paddingPx * 2f - viewportWidth)
|
||||
.roundToInt()
|
||||
.coerceAtLeast(0)
|
||||
val maxVerticalScroll = (rowHeight + paddingPx * 2f - viewportHeight)
|
||||
.roundToInt()
|
||||
.coerceAtLeast(0)
|
||||
val safeHorizontalScroll = horizontalScroll.coerceIn(0, maxHorizontalScroll)
|
||||
val safeVerticalScroll = verticalScroll.coerceIn(0, maxVerticalScroll)
|
||||
val rowX = if (rowWidth <= contentWidth) {
|
||||
((viewportWidth - rowWidth) / 2f) - safeHorizontalScroll.toFloat()
|
||||
} else {
|
||||
paddingPx - safeHorizontalScroll.toFloat()
|
||||
}
|
||||
val rowY = paddingPx - safeVerticalScroll.toFloat()
|
||||
var pageX = viewportRootOffset.x + rowX
|
||||
val pageY = viewportRootOffset.y + rowY
|
||||
val roots = visiblePageIndices.mapIndexed { index, pageIndex ->
|
||||
val root = Offset(pageX, pageY)
|
||||
pageX += pageSizes[index].width.toFloat() + pageGapPx
|
||||
pageIndex to root
|
||||
}.toMap()
|
||||
return DesktopPdfSpreadLayoutPrediction(
|
||||
pageRootOffsets = roots,
|
||||
maxHorizontalScroll = maxHorizontalScroll,
|
||||
maxVerticalScroll = maxVerticalScroll
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset: Offset,
|
||||
oldPageRootOffset: Offset?,
|
||||
currentAnchorPageRootOffset: Offset,
|
||||
anchor: Offset?,
|
||||
oldZoom: Float,
|
||||
newZoom: Float,
|
||||
currentZoom: Float,
|
||||
scrollBounds: DesktopPdfZoomScrollBounds? = null
|
||||
): Offset? {
|
||||
if (oldPageRootOffset == null || anchor == null) return null
|
||||
if (abs(currentZoom - newZoom) > DesktopPdfZoomPreviewTolerance) return null
|
||||
val pageDelta = desktopPdfAnchoredPageScrollDelta(
|
||||
viewportRootOffset = viewportRootOffset,
|
||||
oldPageRootOffset = oldPageRootOffset,
|
||||
currentPageRootOffset = currentAnchorPageRootOffset,
|
||||
anchor = anchor,
|
||||
oldZoom = oldZoom,
|
||||
newZoom = newZoom
|
||||
) ?: return null
|
||||
val reachableDelta = desktopPdfReachableScrollDelta(pageDelta, scrollBounds)
|
||||
return Offset(
|
||||
x = if (reachableDelta.x == 0) 0f else -reachableDelta.x.toFloat(),
|
||||
y = if (reachableDelta.y == 0) 0f else -reachableDelta.y.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfZoomPreviewPivotFraction(
|
||||
viewportRootOffset: Offset,
|
||||
pageRootOffset: Offset,
|
||||
|
|
@ -167,14 +469,39 @@ internal fun Modifier.desktopPdfZoomPreviewLayer(
|
|||
currentZoom: Float,
|
||||
viewportRootOffset: Offset,
|
||||
pageRootOffset: Offset,
|
||||
pageCanvasSize: IntSize
|
||||
pageCanvasSize: IntSize,
|
||||
commitPageRootOffset: Offset? = null,
|
||||
scrollBounds: DesktopPdfZoomScrollBounds? = null
|
||||
): Modifier {
|
||||
val activePreview = preview ?: return this
|
||||
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this
|
||||
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
|
||||
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
|
||||
val previewScale = activePreview.zoom / currentZoom
|
||||
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
|
||||
val commitTranslation = desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = activePreview.viewportRootOffset,
|
||||
oldPageRootOffset = activePreview.pageRootOffset,
|
||||
currentAnchorPageRootOffset = commitPageRootOffset ?: pageRootOffset,
|
||||
anchor = activePreview.anchor,
|
||||
oldZoom = activePreview.baseZoom,
|
||||
newZoom = activePreview.zoom,
|
||||
currentZoom = currentZoom,
|
||||
scrollBounds = scrollBounds
|
||||
)
|
||||
if (
|
||||
!previewScale.isFinite() ||
|
||||
(abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null)
|
||||
) {
|
||||
return this
|
||||
}
|
||||
logPdfZoomSettle {
|
||||
"preview_layer seq=${activePreview.diagnosticSequence} kind=page currentZoom=${currentZoom.formatLogFloat()} " +
|
||||
"previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " +
|
||||
"pageRoot=${pageRootOffset.formatLogOffset()} commitRoot=${commitPageRootOffset.formatLogOffset()} " +
|
||||
"commit=${commitTranslation.formatLogOffset()} " +
|
||||
"h=${scrollBounds?.currentHorizontalScroll ?: "none"}/${scrollBounds?.maxHorizontalScroll ?: "none"} " +
|
||||
"v=${scrollBounds?.currentVerticalScroll ?: "none"}/${scrollBounds?.maxVerticalScroll ?: "none"}"
|
||||
}
|
||||
val transformOrigin = activePreview.anchor?.let { anchor ->
|
||||
desktopPdfZoomPreviewPivotFraction(
|
||||
viewportRootOffset = viewportRootOffset,
|
||||
|
|
@ -188,6 +515,8 @@ internal fun Modifier.desktopPdfZoomPreviewLayer(
|
|||
return graphicsLayer {
|
||||
scaleX = previewScale
|
||||
scaleY = previewScale
|
||||
translationX = commitTranslation?.x ?: 0f
|
||||
translationY = commitTranslation?.y ?: 0f
|
||||
this.transformOrigin = transformOrigin
|
||||
}
|
||||
}
|
||||
|
|
@ -196,13 +525,38 @@ internal fun Modifier.desktopPdfDocumentZoomPreviewLayer(
|
|||
preview: DesktopPdfZoomPreview?,
|
||||
currentZoom: Float,
|
||||
viewportRootOffset: Offset,
|
||||
pageRootOffset: Offset
|
||||
pageRootOffset: Offset,
|
||||
anchorPageRootOffset: Offset? = null,
|
||||
scrollBounds: DesktopPdfZoomScrollBounds? = null
|
||||
): Modifier {
|
||||
val activePreview = preview ?: return this
|
||||
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
|
||||
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
|
||||
val previewScale = activePreview.zoom / currentZoom
|
||||
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
|
||||
val commitTranslation = desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = activePreview.viewportRootOffset,
|
||||
oldPageRootOffset = activePreview.pageRootOffset,
|
||||
currentAnchorPageRootOffset = anchorPageRootOffset ?: pageRootOffset,
|
||||
anchor = activePreview.anchor,
|
||||
oldZoom = activePreview.baseZoom,
|
||||
newZoom = activePreview.zoom,
|
||||
currentZoom = currentZoom,
|
||||
scrollBounds = scrollBounds
|
||||
)
|
||||
if (
|
||||
!previewScale.isFinite() ||
|
||||
(abs(previewScale - 1f) < DesktopPdfZoomPreviewTolerance && commitTranslation == null)
|
||||
) {
|
||||
return this
|
||||
}
|
||||
logPdfZoomSettle {
|
||||
"preview_layer seq=${activePreview.diagnosticSequence} kind=document currentZoom=${currentZoom.formatLogFloat()} " +
|
||||
"previewZoom=${activePreview.zoom.formatLogFloat()} scale=${previewScale.formatLogFloat()} " +
|
||||
"pageRoot=${pageRootOffset.formatLogOffset()} anchorRoot=${(anchorPageRootOffset ?: pageRootOffset).formatLogOffset()} " +
|
||||
"commit=${commitTranslation.formatLogOffset()} h=${scrollBounds?.currentHorizontalScroll ?: "none"}/" +
|
||||
"${scrollBounds?.maxHorizontalScroll ?: "none"} v=${scrollBounds?.currentVerticalScroll ?: "none"}/" +
|
||||
"${scrollBounds?.maxVerticalScroll ?: "none"}"
|
||||
}
|
||||
val translation = activePreview.anchor?.let { anchor ->
|
||||
desktopPdfDocumentZoomPreviewTranslation(
|
||||
viewportRootOffset = viewportRootOffset,
|
||||
|
|
@ -214,8 +568,8 @@ internal fun Modifier.desktopPdfDocumentZoomPreviewLayer(
|
|||
return graphicsLayer {
|
||||
scaleX = previewScale
|
||||
scaleY = previewScale
|
||||
translationX = translation.x
|
||||
translationY = translation.y
|
||||
translationX = translation.x + (commitTranslation?.x ?: 0f)
|
||||
translationY = translation.y + (commitTranslation?.y ?: 0f)
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,6 @@ internal data class DesktopPlatform(
|
|||
val isLinux: Boolean get() = os == DesktopOperatingSystem.LINUX
|
||||
val isWindows: Boolean get() = os == DesktopOperatingSystem.WINDOWS
|
||||
|
||||
val kcefBundleDirectoryName: String
|
||||
get() = when (os) {
|
||||
DesktopOperatingSystem.WINDOWS -> "kcef-bundle"
|
||||
DesktopOperatingSystem.LINUX -> "kcef-bundle-linux-${architecture.resourceName}"
|
||||
DesktopOperatingSystem.MACOS -> "kcef-bundle-macos-${architecture.resourceName}"
|
||||
DesktopOperatingSystem.OTHER -> "kcef-bundle-${architecture.resourceName}"
|
||||
}
|
||||
|
||||
val pdfiumDirectoryName: String
|
||||
get() = when (os) {
|
||||
DesktopOperatingSystem.WINDOWS -> "win-${architecture.resourceName}-v8"
|
||||
|
|
@ -140,7 +132,7 @@ private fun xdgBase(
|
|||
): File {
|
||||
return env(envName)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.takeIf { it.startsWith("/") }
|
||||
?.let(::File)
|
||||
?.takeIf { it.isAbsolute }
|
||||
?: File(userHome, fallbackRelativePath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ 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
|
||||
|
|
@ -55,7 +54,7 @@ internal fun DesktopProScreen(
|
|||
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_account_and_credits", "Account & credits"), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
readerString("desktop_pro_sign_in_desc", "Sign in to check your account status on desktop."),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
|
@ -73,7 +72,7 @@ internal fun DesktopProScreen(
|
|||
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)
|
||||
Text(readerString("desktop_account_overview", "Account overview"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
if (user == null) {
|
||||
Text(
|
||||
|
|
@ -92,11 +91,13 @@ internal fun DesktopProScreen(
|
|||
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)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(user.displayName ?: user.email ?: readerString("desktop_signed_in", "Signed in"), style = MaterialTheme.typography.titleMedium)
|
||||
user.email?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onRefresh, enabled = !isBusy) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
|
@ -107,33 +108,26 @@ internal fun DesktopProScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
DesktopAccountValue(
|
||||
label = readerString("desktop_plan", "Plan"),
|
||||
value = if (isProUser) {
|
||||
readerString("desktop_pro_unlocked_account", "Pro is unlocked for this account.")
|
||||
} else {
|
||||
readerString("desktop_pro_not_unlocked_account", "Pro is not unlocked for this account.")
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
DesktopAccountValue(
|
||||
label = readerString("credits_tab", "Credits"),
|
||||
value = readerString("desktop_credits_available_format", "%1\$d credits available", credits),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
readerString(
|
||||
"desktop_pro_purchase_android_desc",
|
||||
|
|
@ -142,9 +136,25 @@ internal fun DesktopProScreen(
|
|||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
statusMessage?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopAccountValue(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import com.aryan.reader.shared.UserData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
@Composable
|
||||
internal fun DesktopProfileAvatar(
|
||||
user: UserData,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val photoUrl = user.photoUrl?.takeIf { it.isNotBlank() }
|
||||
var bitmap by remember(photoUrl) { mutableStateOf(photoUrl?.let(DesktopProfileAvatarCache::peek)) }
|
||||
|
||||
LaunchedEffect(photoUrl) {
|
||||
bitmap = if (photoUrl == null) {
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.IO) {
|
||||
DesktopProfileAvatarCache.load(photoUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imageBitmap = bitmap
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
DesktopProfileAvatarFallback(user = user, modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopProfileAvatarFallback(
|
||||
user: UserData,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val initial = (user.displayName ?: user.email)
|
||||
?.trim()
|
||||
?.firstOrNull()
|
||||
?.uppercase()
|
||||
if (initial != null) {
|
||||
Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
} else {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object DesktopProfileAvatarCache {
|
||||
private const val MaxEntries = 24
|
||||
|
||||
private val cache = object : LinkedHashMap<String, ImageBitmap>(MaxEntries, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?): Boolean {
|
||||
return size > MaxEntries
|
||||
}
|
||||
}
|
||||
|
||||
fun peek(url: String): ImageBitmap? {
|
||||
return synchronized(cache) { cache[url] }
|
||||
}
|
||||
|
||||
fun load(url: String): ImageBitmap? {
|
||||
peek(url)?.let { return it }
|
||||
val bitmap = runCatching {
|
||||
DesktopOpdsHttp.fetchBytes(url, catalog = null).toImageBitmap()
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
synchronized(cache) {
|
||||
cache[url] = bitmap
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun ByteArray.toImageBitmap(): ImageBitmap? {
|
||||
return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
internal const val DesktopReaderDefaultsVersion = 1
|
||||
|
||||
internal enum class DesktopReaderSettingsEngine {
|
||||
TEXT,
|
||||
PDF
|
||||
}
|
||||
|
||||
internal val DesktopDefaultTextReaderSettings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
internal val DesktopDefaultPdfReaderSettings = ReaderSettings(
|
||||
themeId = "no_theme",
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
internal fun FileType.desktopReaderSettingsEngine(): DesktopReaderSettingsEngine? {
|
||||
return when (SharedFileCapabilities.surfaceFor(this, ReaderPlatform.DESKTOP)) {
|
||||
ReaderFeatureSurface.PDF_VIEWER -> DesktopReaderSettingsEngine.PDF
|
||||
ReaderFeatureSurface.EPUB_READER,
|
||||
ReaderFeatureSurface.TEXT_READER -> DesktopReaderSettingsEngine.TEXT
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BookItem.usesDesktopReaderSettingsEngine(engine: DesktopReaderSettingsEngine): Boolean {
|
||||
return type.desktopReaderSettingsEngine() == engine
|
||||
}
|
||||
|
||||
internal fun List<BookItem>.withDesktopReaderEngineSettings(
|
||||
engine: DesktopReaderSettingsEngine,
|
||||
settings: ReaderSettings
|
||||
): List<BookItem> {
|
||||
return map { book ->
|
||||
if (book.usesDesktopReaderSettingsEngine(engine)) {
|
||||
book.copy(readerSettings = settings)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedReaderScreenState.withDesktopReaderEngineDefaultSettings(
|
||||
engine: DesktopReaderSettingsEngine,
|
||||
settings: ReaderSettings
|
||||
): SharedReaderScreenState {
|
||||
val engineSettings = if (engine == DesktopReaderSettingsEngine.PDF) {
|
||||
settings.toDesktopPdfReaderSettings()
|
||||
} else {
|
||||
settings
|
||||
}
|
||||
return when (engine) {
|
||||
DesktopReaderSettingsEngine.TEXT -> copy(
|
||||
readerDefaultSettings = engineSettings,
|
||||
rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings)
|
||||
)
|
||||
DesktopReaderSettingsEngine.PDF -> copy(
|
||||
pdfReaderDefaultSettings = engineSettings,
|
||||
rawLibraryBooks = rawLibraryBooks.withDesktopReaderEngineSettings(engine, engineSettings)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ReaderSettings.toDesktopPdfDisplayMode(): PdfDisplayMode {
|
||||
return when (readingMode) {
|
||||
ReaderReadingMode.PAGINATED -> PdfDisplayMode.PAGINATION
|
||||
ReaderReadingMode.VERTICAL -> PdfDisplayMode.VERTICAL_SCROLL
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PdfDisplayMode.toDesktopReaderReadingMode(): ReaderReadingMode {
|
||||
return when (this) {
|
||||
PdfDisplayMode.PAGINATION -> ReaderReadingMode.PAGINATED
|
||||
PdfDisplayMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,26 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
|
||||
private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf"
|
||||
private const val PdfZoomSettleLogTag = "EpistemePdfZoomSettle"
|
||||
private const val PdfLinkLogTag = "EpistemePdfLink"
|
||||
private const val PdfChromeTapLogTag = "EpistemePdfChromeTap"
|
||||
private const val EpubLinkLogTag = "EpistemeEpubLink"
|
||||
private const val EpubPaginationLogTag = "EpistemeEpubPagination"
|
||||
private const val EpubCutoffLogTag = "EpistemeEpubCutoff"
|
||||
private const val ReaderGapLogTag = "EpistemeReaderGap"
|
||||
private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG"
|
||||
private const val EpubHighlightFlowLogTag = "EpistemeEpubHighlightFlow"
|
||||
private const val DesktopHighlightMapLogTag = "EpistemeDesktopHighlightMap"
|
||||
private const val DesktopPositionTraceLogTag = "EpistemeDesktopPositionTrace"
|
||||
private const val DesktopReaderCloseLogTag = "EpistemeDesktopReaderClose"
|
||||
private const val DesktopNativeWebViewLogTag = "EpistemeNativeWebView"
|
||||
private const val WebViewLayoutLogTag = "EpistemeWebViewLayout"
|
||||
private const val ReaderModeSwitchLogTag = "EpistemeReaderModeSwitch"
|
||||
|
||||
internal fun logPdfSelection(message: String) {
|
||||
}
|
||||
|
|
@ -21,10 +33,26 @@ internal fun logPdfZoomPerf(message: () -> String) {
|
|||
logDesktopDiagnostic(PdfZoomPerfLogTag, message)
|
||||
}
|
||||
|
||||
internal fun logPdfZoomSettle(message: String) {
|
||||
logDesktopDiagnostic(PdfZoomSettleLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logPdfZoomSettle(message: () -> String) {
|
||||
logDesktopDiagnostic(PdfZoomSettleLogTag, message)
|
||||
}
|
||||
|
||||
internal fun logPdfLink(message: String) {
|
||||
logDesktopDiagnostic(PdfLinkLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logPdfChromeTap(message: String) {
|
||||
logDesktopDiagnostic(PdfChromeTapLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logPdfChromeTap(message: () -> String) {
|
||||
logDesktopDiagnostic(PdfChromeTapLogTag, message)
|
||||
}
|
||||
|
||||
internal fun logEpubLink(message: String) {
|
||||
logDesktopDiagnostic(EpubLinkLogTag) { message }
|
||||
}
|
||||
|
|
@ -33,6 +61,10 @@ internal fun logEpubPagination(message: String) {
|
|||
logDesktopDiagnostic(EpubPaginationLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logEpubCutoff(message: String) {
|
||||
logDesktopDiagnostic(EpubCutoffLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logReaderGap(message: String) {
|
||||
logDesktopDiagnostic(ReaderGapLogTag) { message }
|
||||
}
|
||||
|
|
@ -41,6 +73,38 @@ internal fun logEpubSelectionDebug(message: String) {
|
|||
logDesktopDiagnostic(EpubSelectionDebugLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logEpubHighlightFlow(message: String) {
|
||||
logDesktopDiagnostic(EpubHighlightFlowLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logDesktopHighlightMap(message: String) {
|
||||
logDesktopDiagnostic(DesktopHighlightMapLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logDesktopPositionTrace(message: String) {
|
||||
logDesktopDiagnostic(DesktopPositionTraceLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logDesktopPositionTrace(message: () -> String) {
|
||||
logDesktopDiagnostic(DesktopPositionTraceLogTag, message)
|
||||
}
|
||||
|
||||
internal fun logDesktopReaderClose(message: String) {
|
||||
logDesktopDiagnostic(DesktopReaderCloseLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logDesktopWebView2(message: String) {
|
||||
logDesktopDiagnostic(DesktopNativeWebViewLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logWebViewLayoutDiag(message: String) {
|
||||
logDesktopDiagnostic(WebViewLayoutLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logReaderModeSwitch(message: String) {
|
||||
logDesktopDiagnostic(ReaderModeSwitchLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun DesktopPdfLinkTarget.formatLogTarget(): String {
|
||||
return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\""
|
||||
}
|
||||
|
|
@ -54,6 +118,11 @@ internal fun Offset?.formatLogOffset(): String {
|
|||
return "${x.formatLogFloat()},${y.formatLogFloat()}"
|
||||
}
|
||||
|
||||
internal fun IntOffset?.formatLogIntOffset(): String {
|
||||
if (this == null) return "none"
|
||||
return "${this.x},${this.y}"
|
||||
}
|
||||
|
||||
internal fun IntSize.formatLogSize(): String {
|
||||
return "${width}x${height}"
|
||||
}
|
||||
|
|
@ -66,3 +135,12 @@ internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String {
|
|||
"${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " +
|
||||
"${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}"
|
||||
}
|
||||
|
||||
internal fun ReaderLocator?.desktopPositionTraceSummary(maxTextLength: Int = 90): String {
|
||||
if (this == null) return "null"
|
||||
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
|
||||
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
|
||||
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
|
||||
"chapterId=\"${chapterId.orEmpty().logPreview(80)}\" href=\"${href.orEmpty().logPreview(120)}\" " +
|
||||
"cfi=\"${cfi.orEmpty().logPreview(180)}\" text=\"${textQuote.orEmpty().logPreview(maxTextLength)}\""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
internal const val DesktopReaderOpenTraceTag = "EpistemeDesktopOpenTrace"
|
||||
|
||||
internal fun logDesktopReaderOpenTrace(message: () -> String) {
|
||||
logDesktopDiagnostic(DesktopReaderOpenTraceTag, message)
|
||||
}
|
||||
|
||||
internal fun Long.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long {
|
||||
return ((nowNanos - this).coerceAtLeast(0L)) / 1_000_000L
|
||||
}
|
||||
|
||||
internal fun DesktopReaderOpening.elapsedOpenTraceMs(nowNanos: Long = System.nanoTime()): Long {
|
||||
return startedAtNanos.elapsedOpenTraceMs(nowNanos)
|
||||
}
|
||||
|
||||
internal fun DesktopReaderOpening.openTracePrefix(event: String): String {
|
||||
return "event=$event requestId=$requestId bookId=\"${bookId.logPreview(80)}\" " +
|
||||
"title=\"${title.logPreview(120)}\" format=\"$formatLabel\" elapsedMs=${elapsedOpenTraceMs()}"
|
||||
}
|
||||
|
||||
internal fun DesktopReaderOpenResult.openTraceKind(): String {
|
||||
return when (this) {
|
||||
is DesktopReaderOpenResult.Failure -> "failure"
|
||||
is DesktopReaderOpenResult.PasswordRequired -> "password_required"
|
||||
is DesktopReaderOpenResult.Pdf -> "pdf"
|
||||
is DesktopReaderOpenResult.Text -> "text"
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ internal data class DesktopReaderOpening(
|
|||
val title: String,
|
||||
val formatLabel: String,
|
||||
val returnTab: SharedAppTab,
|
||||
val password: String? = null
|
||||
val password: String? = null,
|
||||
val startedAtNanos: Long = System.nanoTime()
|
||||
)
|
||||
|
||||
internal sealed interface DesktopReaderOpenResult {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -49,11 +48,8 @@ import com.aryan.reader.shared.ReaderAiByokSettings
|
|||
import com.aryan.reader.shared.ReaderAiModelOption
|
||||
import com.aryan.reader.shared.ReaderAiModelOptions
|
||||
import com.aryan.reader.shared.ReaderAiResultState
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderCloudTtsVoices
|
||||
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.maskedReaderAiKey
|
||||
import com.aryan.reader.shared.ui.SharedMarkdownText
|
||||
|
|
@ -197,7 +193,7 @@ internal fun DesktopAiByokSettingsDialog(
|
|||
DesktopSavedAiKeyRow(
|
||||
label = readerString("provider_gemini", "Gemini"),
|
||||
keyValue = sanitized.geminiKey,
|
||||
onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) }
|
||||
onClear = { onSettingsChange(sanitized.copy(geminiKey = "")) }
|
||||
)
|
||||
DesktopSavedAiKeyRow(
|
||||
label = readerString("provider_groq", "Groq"),
|
||||
|
|
@ -250,26 +246,6 @@ internal fun DesktopAiByokSettingsDialog(
|
|||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(readerString("options_show_ai_in_reader", "Show AI in reader"), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
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
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = !sanitized.hideReaderAiFeatures,
|
||||
onCheckedChange = { enabled ->
|
||||
onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(readerString("ai_settings_use_one_model", "Use one model for all features"), style = MaterialTheme.typography.titleMedium)
|
||||
|
|
@ -320,31 +296,6 @@ internal fun DesktopAiByokSettingsDialog(
|
|||
options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
||||
onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) }
|
||||
)
|
||||
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())) {
|
||||
rowVoices.forEach { voice ->
|
||||
FilterChip(
|
||||
selected = sanitized.ttsSpeakerId == voice.id,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
|
@ -405,53 +356,21 @@ private fun DesktopAiModelSelector(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfExtrasPanel(
|
||||
pageText: String,
|
||||
internal fun DesktopPdfTtsPanel(
|
||||
extrasState: ReaderExtrasState,
|
||||
aiByokSettings: ReaderAiByokSettings,
|
||||
externalLookupAvailable: Boolean,
|
||||
cloudTtsFeatureAvailable: Boolean,
|
||||
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
|
||||
onOpenAiHub: (() -> Unit)? = null,
|
||||
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
|
||||
onCloudTtsPauseResume: () -> Unit,
|
||||
onCloudTtsStop: () -> Unit,
|
||||
onCloudTtsClearCache: () -> Unit,
|
||||
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
|
||||
onCloudTtsVoiceChange: (String) -> Unit,
|
||||
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||
ttsReplacementBookId: String,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
|
||||
) {
|
||||
val settings = aiByokSettings.sanitized()
|
||||
val autoScroll = extrasState.autoScroll.sanitized()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
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 ->
|
||||
FilterChip(
|
||||
selected = false,
|
||||
enabled = pageText.isNotBlank(),
|
||||
onClick = { onExternalLookup(action, pageText) },
|
||||
label = { Text(action.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(readerString("menu_auto_scroll", "Auto Scroll"), modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = autoScroll.enabled,
|
||||
onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) }
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = autoScroll.speed,
|
||||
onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) },
|
||||
valueRange = 12f..160f
|
||||
)
|
||||
Text(readerString("menu_tts_settings", "TTS"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
if (cloudTtsFeatureAvailable) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -476,40 +395,38 @@ internal fun DesktopPdfExtrasPanel(
|
|||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
enabled = settings.isCloudTtsAvailable || ttsBusy,
|
||||
onClick = {
|
||||
if (ttsBusy) {
|
||||
onCloudTtsStop()
|
||||
} else {
|
||||
onCloudTtsStart(ReaderTtsReadScope.BOOK)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(if (ttsBusy) readerString("action_stop", "Stop") else readerString("action_read", "Read"))
|
||||
}
|
||||
}
|
||||
if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(readerString("desktop_cloud_tts_voice", "Cloud TTS voice"), fontWeight = FontWeight.SemiBold)
|
||||
if (ttsBusy) {
|
||||
Text(
|
||||
readerString("desktop_stop_reading_change_voices", "Stop reading to change voices."),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
TextButton(onClick = onCloudTtsPauseResume) {
|
||||
Text(if (extrasState.cloudTts.isPaused) readerString("tooltip_tts_resume", "Resume") else readerString("tooltip_tts_pause", "Pause"))
|
||||
ReaderCloudTtsVoices.forEach { voice ->
|
||||
FilterChip(
|
||||
selected = settings.ttsSpeakerId == voice.id,
|
||||
enabled = !ttsBusy,
|
||||
onClick = { onCloudTtsVoiceChange(voice.id) },
|
||||
label = {
|
||||
Column {
|
||||
Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
voice.description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
TextButton(
|
||||
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
||||
onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) }
|
||||
) {
|
||||
Text(readerString("desktop_page", "Page"))
|
||||
}
|
||||
TextButton(
|
||||
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
||||
onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) }
|
||||
) {
|
||||
Text(readerString("desktop_from_here", "From here"))
|
||||
}
|
||||
}
|
||||
val cacheSummary = extrasState.cloudTts.cacheSummary
|
||||
if (cacheSummary.hasCachedAudio) {
|
||||
Text(
|
||||
|
|
@ -529,12 +446,5 @@ internal fun DesktopPdfExtrasPanel(
|
|||
bookId = ttsReplacementBookId,
|
||||
onPreferencesChange = onTtsReplacementPreferencesChange
|
||||
)
|
||||
if (settings.areReaderAiFeaturesAvailable && onOpenAiHub != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
TextButton(onClick = onOpenAiHub) {
|
||||
Text(readerString("desktop_ai_hub", "AI hub"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
|
|
@ -10,6 +12,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -17,6 +20,8 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -34,19 +39,23 @@ 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.ReaderLocator
|
||||
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.ReaderTheme
|
||||
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.ReaderPage
|
||||
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.isRightToLeftPaginationEnabled
|
||||
import com.aryan.reader.shared.reader.layoutSignature
|
||||
import com.aryan.reader.shared.reduce
|
||||
import com.aryan.reader.shared.ui.DesktopEpubNativeImage
|
||||
|
|
@ -54,7 +63,12 @@ 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.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.EventQueue
|
||||
import java.awt.Window
|
||||
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||
|
||||
@Composable
|
||||
|
|
@ -64,8 +78,12 @@ internal fun DesktopReaderScreen(
|
|||
onSessionChange: (ReaderSessionState) -> Unit,
|
||||
onReturnToLibrary: (() -> Unit)? = null,
|
||||
onFullscreenChange: (Boolean) -> Unit = {},
|
||||
readerAwtWindow: Window? = null,
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit,
|
||||
appThemeControls: (@Composable () -> Unit)? = null,
|
||||
customReaderThemes: List<ReaderTheme>,
|
||||
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
|
||||
highlightPalette: ReaderHighlightPalette,
|
||||
onHighlightPaletteChange: (ReaderHighlightPalette) -> Unit,
|
||||
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||
|
|
@ -80,13 +98,13 @@ internal fun DesktopReaderScreen(
|
|||
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
|
||||
onAiAction: (ReaderAiFeature, String) -> Unit,
|
||||
onAiResultDismiss: () -> Unit,
|
||||
onCloudTtsToggle: (String) -> Unit,
|
||||
onCloudTtsToggle: (String, ReaderLocator?) -> Unit,
|
||||
onCloudTtsStart: (ReaderTtsReadScope, List<ReaderTtsChunk>) -> Unit,
|
||||
onCloudTtsPauseResume: () -> Unit,
|
||||
onCloudTtsStop: () -> Unit,
|
||||
onCloudTtsClearCache: () -> Unit,
|
||||
onCloudTtsVoiceChange: (String) -> Unit,
|
||||
onOpenAiHub: (() -> Unit)? = null,
|
||||
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
|
||||
onDownloadReaderImage: (ReaderImageReference) -> Unit,
|
||||
readerTextureDataUri: (String) -> String?,
|
||||
readerCustomTextureIds: List<String>,
|
||||
|
|
@ -119,6 +137,18 @@ internal fun DesktopReaderScreen(
|
|||
cacheWriteScope = paginationCacheWriteScope
|
||||
)
|
||||
}
|
||||
LaunchedEffect(session.reader.book.id) {
|
||||
logDesktopReaderOpenTrace {
|
||||
"event=desktop_text_reader_screen_composed bookId=\"${session.reader.book.id.logPreview(120)}\" " +
|
||||
"title=\"${session.reader.book.title.logPreview(120)}\" mode=${session.reader.settings.readingMode} " +
|
||||
"chapters=${session.reader.book.chapters.size} pages=${session.reader.pages.size} " +
|
||||
"currentPage=${session.reader.currentPageIndex + 1} " +
|
||||
"textChars=${session.reader.book.chapters.sumOf { it.plainText.length }} " +
|
||||
"htmlChars=${session.reader.book.chapters.sumOf { it.htmlContent.length }} " +
|
||||
"semanticBlocks=${session.reader.book.chapters.sumOf { it.semanticBlocks.size }} " +
|
||||
"bookmarks=${session.bookmarks.size} highlights=${session.highlights.size}"
|
||||
}
|
||||
}
|
||||
var readerViewport by remember(session.reader.book.id) { mutableStateOf(ReaderViewportSpec(0, 0)) }
|
||||
val paginationLayoutSignature = session.reader.settings.layoutSignature()
|
||||
val paginationContentSignature = remember(session.reader.book) {
|
||||
|
|
@ -152,16 +182,40 @@ internal fun DesktopReaderScreen(
|
|||
var completedMeasuredPaginationRequest by remember(session.reader.book.id) {
|
||||
mutableStateOf<DesktopEpubPaginationRequest?>(null)
|
||||
}
|
||||
var completedMeasuredPaginationPages by remember(session.reader.book.id) {
|
||||
mutableStateOf(emptyList<ReaderPage>())
|
||||
}
|
||||
var warmMeasuredPaginationRequest by remember(session.reader.book.id) {
|
||||
mutableStateOf<DesktopEpubPaginationRequest?>(null)
|
||||
}
|
||||
var warmMeasuredPaginationPages by remember(session.reader.book.id) {
|
||||
mutableStateOf(emptyList<ReaderPage>())
|
||||
}
|
||||
var runningMeasuredPaginationRequest by remember(session.reader.book.id) {
|
||||
mutableStateOf<DesktopEpubPaginationRequest?>(null)
|
||||
}
|
||||
val paginatedLayoutReady = session.reader.settings.readingMode != ReaderReadingMode.PAGINATED ||
|
||||
(measuredPaginationRequest != null && completedMeasuredPaginationRequest == measuredPaginationRequest)
|
||||
val measuredPaginationPagesApplied = desktopMeasuredPaginationReady(
|
||||
request = measuredPaginationRequest,
|
||||
completedRequest = completedMeasuredPaginationRequest,
|
||||
currentPages = session.reader.pages,
|
||||
measuredPages = completedMeasuredPaginationPages
|
||||
)
|
||||
val warmMeasuredPaginationPagesApplied = desktopMeasuredPaginationReady(
|
||||
request = measuredPaginationRequest,
|
||||
completedRequest = warmMeasuredPaginationRequest,
|
||||
currentPages = session.reader.pages,
|
||||
measuredPages = warmMeasuredPaginationPages
|
||||
)
|
||||
val paginatedLayoutReady = desktopPaginatedLayoutReadyForDisplay(
|
||||
readingMode = session.reader.settings.readingMode,
|
||||
measuredPagesApplied = measuredPaginationPagesApplied
|
||||
)
|
||||
val latestSession by rememberUpdatedState(session)
|
||||
val latestOnSessionChange by rememberUpdatedState(onSessionChange)
|
||||
var externalLinkDialogUrl by remember { mutableStateOf<String?>(null) }
|
||||
var lastHandledLink by remember { mutableStateOf<DesktopEpubHandledLink?>(null) }
|
||||
var isFullscreen by remember(session.reader.book.id) { mutableStateOf(false) }
|
||||
val desktopReaderExtrasState = readerExtrasState.copy(autoScroll = ReaderAutoScrollState())
|
||||
val currentReaderFullscreen by rememberUpdatedState(isFullscreen)
|
||||
val currentOnReaderFullscreenChange by rememberUpdatedState(onFullscreenChange)
|
||||
|
||||
|
|
@ -175,9 +229,12 @@ internal fun DesktopReaderScreen(
|
|||
onDismiss = { externalLinkDialogUrl = null }
|
||||
)
|
||||
|
||||
fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean {
|
||||
val action = event.desktopReaderKeyNavigationOrNull(fullscreen = isFullscreen) ?: return false
|
||||
fun handleReaderAwtKeyEvent(event: AwtKeyEvent): Boolean {
|
||||
val currentSession = latestSession
|
||||
val action = event.desktopReaderKeyNavigationOrNull(
|
||||
fullscreen = isFullscreen,
|
||||
rightToLeftPagination = currentSession.reader.settings.isRightToLeftPaginationEnabled()
|
||||
) ?: return false
|
||||
val nextSession = currentSession.reduceDesktopReaderKeyNavigation(action, readerEngine)
|
||||
if (nextSession == null) {
|
||||
if (action == DesktopReaderKeyNavigation.EXIT_FULLSCREEN && isFullscreen) {
|
||||
|
|
@ -188,6 +245,43 @@ internal fun DesktopReaderScreen(
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun handleReaderFullscreenAwtKeyEvent(event: AwtKeyEvent): Boolean {
|
||||
if (latestSession.isSearchActive) {
|
||||
if (event.id == AwtKeyEvent.KEY_PRESSED && isFullscreen && event.keyCode == AwtKeyEvent.VK_ESCAPE) {
|
||||
setReaderFullscreen(false)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return handleReaderAwtKeyEvent(event)
|
||||
}
|
||||
|
||||
fun handleReaderGlobalShortcutAwtKeyEvent(event: AwtKeyEvent): Boolean {
|
||||
if (event.id != AwtKeyEvent.KEY_PRESSED || !event.isControlDown) return false
|
||||
val action = when (event.keyCode) {
|
||||
AwtKeyEvent.VK_F -> DesktopReaderKeyNavigation.SEARCH
|
||||
AwtKeyEvent.VK_G -> DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||
else -> return false
|
||||
}
|
||||
val nextSession = latestSession.reduceDesktopReaderKeyNavigation(action, readerEngine) ?: return false
|
||||
latestOnSessionChange(nextSession)
|
||||
return true
|
||||
}
|
||||
|
||||
DesktopReaderKeyDispatcherEffect(
|
||||
enabled = externalLinkDialogUrl == null,
|
||||
allowChromeModalWindows = true,
|
||||
onKeyPressed = { event -> handleReaderGlobalShortcutAwtKeyEvent(event) }
|
||||
)
|
||||
|
||||
DesktopReaderKeyDispatcherEffect(
|
||||
enabled = externalLinkDialogUrl == null && !session.isSearchActive,
|
||||
allowPanelModalWindows = true,
|
||||
dispatchWhenOwnerWindowActive = false,
|
||||
onKeyPressed = { event -> handleReaderAwtKeyEvent(event) }
|
||||
)
|
||||
|
||||
DesktopReaderFullscreenKeyEffect(
|
||||
enabled = isFullscreen && externalLinkDialogUrl == null,
|
||||
onKeyPressed = { event -> handleReaderFullscreenAwtKeyEvent(event) }
|
||||
|
|
@ -196,6 +290,9 @@ internal fun DesktopReaderScreen(
|
|||
LaunchedEffect(session.reader.settings.readingMode) {
|
||||
if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) {
|
||||
completedMeasuredPaginationRequest = null
|
||||
completedMeasuredPaginationPages = emptyList()
|
||||
warmMeasuredPaginationRequest = null
|
||||
warmMeasuredPaginationPages = emptyList()
|
||||
runningMeasuredPaginationRequest = null
|
||||
}
|
||||
}
|
||||
|
|
@ -220,15 +317,113 @@ internal fun DesktopReaderScreen(
|
|||
)
|
||||
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 cacheProbeStartedAt = System.nanoTime()
|
||||
val cacheProbeSettings = latestSession.reader.settings
|
||||
val cachedPages = if (
|
||||
cacheProbeSettings.readingMode == ReaderReadingMode.PAGINATED &&
|
||||
cacheProbeSettings.layoutSignature() == request.layoutSignature
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
epubPaginationCache.loadMemory(
|
||||
book = session.reader.book,
|
||||
settings = cacheProbeSettings,
|
||||
viewport = request.viewport,
|
||||
density = request.density.density,
|
||||
fontScale = request.density.fontScale
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val settingsAfterCacheProbe = latestSession.reader.settings
|
||||
if (settingsAfterCacheProbe.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
|
||||
if (settingsAfterCacheProbe.layoutSignature() != request.layoutSignature) return@LaunchedEffect
|
||||
if (cachedPages != null) {
|
||||
val cacheLayoutChanged = !latestSession.reader.pages.samePageLayoutAs(cachedPages)
|
||||
logEpubPagination(
|
||||
"cache_warm_result book=\"${session.reader.book.title.logPreview()}\" pages=${cachedPages.size} " +
|
||||
"layoutChanged=$cacheLayoutChanged viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
|
||||
"elapsedMs=${cacheProbeStartedAt.elapsedMillis()}"
|
||||
)
|
||||
if (cacheLayoutChanged) {
|
||||
val cacheApplySession = latestSession
|
||||
latestOnSessionChange(
|
||||
readerEngine.replacePages(
|
||||
state = cacheApplySession,
|
||||
pages = cachedPages,
|
||||
reflowAnchor = readerEngine.reflowAnchorFor(cacheApplySession)
|
||||
)
|
||||
)
|
||||
}
|
||||
completedMeasuredPaginationPages = cachedPages
|
||||
completedMeasuredPaginationRequest = request
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val warmStartSession = latestSession
|
||||
val warmAnchor = readerEngine.reflowAnchorFor(warmStartSession)
|
||||
val warmChapterIndex = warmAnchor?.chapterIndex
|
||||
?: warmStartSession.reader.currentPage?.chapterIndex
|
||||
?: 0
|
||||
val warmFirstPageIndex = warmStartSession.reader.pages.firstPageIndexForChapter(warmChapterIndex) ?: 0
|
||||
val warmStartedAt = System.nanoTime()
|
||||
logEpubPagination(
|
||||
"chapter_warm_start book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " +
|
||||
"firstPage=${warmFirstPageIndex + 1} viewport=${request.viewport.widthPx}x${request.viewport.heightPx}"
|
||||
)
|
||||
val cachedWarmChapterPages = epubPaginationCache.loadChapter(
|
||||
book = session.reader.book,
|
||||
settings = settingsAfterCacheProbe,
|
||||
viewport = request.viewport,
|
||||
chapterIndex = warmChapterIndex,
|
||||
density = request.density.density,
|
||||
fontScale = request.density.fontScale
|
||||
)
|
||||
val warmChapterPages = cachedWarmChapterPages ?: withContext(Dispatchers.Default) {
|
||||
measuredPaginator.paginateChapterWindow(
|
||||
book = session.reader.book,
|
||||
settings = settingsAfterCacheProbe,
|
||||
viewport = request.viewport,
|
||||
chapterIndex = warmChapterIndex,
|
||||
firstPageIndex = warmFirstPageIndex
|
||||
)
|
||||
}
|
||||
val warmPages = desktopPagesWithMeasuredChapter(
|
||||
currentPages = warmStartSession.reader.pages,
|
||||
chapterIndex = warmChapterIndex,
|
||||
measuredChapterPages = warmChapterPages
|
||||
)
|
||||
val warmLayoutChanged = warmPages.isNotEmpty() && !warmStartSession.reader.pages.samePageLayoutAs(warmPages)
|
||||
logEpubPagination(
|
||||
"chapter_warm_result book=\"${session.reader.book.title.logPreview()}\" chapter=$warmChapterIndex " +
|
||||
"source=${if (cachedWarmChapterPages != null) "cache" else "measured"} " +
|
||||
"chapterPages=${warmChapterPages.size} pages=${warmPages.size} layoutChanged=$warmLayoutChanged " +
|
||||
"elapsedMs=${warmStartedAt.elapsedMillis()}"
|
||||
)
|
||||
if (warmLayoutChanged) {
|
||||
logReaderModeSwitch(
|
||||
"pagination_warm_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
|
||||
"chapter=$warmChapterIndex chapterPages=${warmChapterPages.size} currentPages=${warmStartSession.reader.pages.size}"
|
||||
)
|
||||
latestOnSessionChange(
|
||||
readerEngine.replacePages(
|
||||
state = warmStartSession,
|
||||
pages = warmPages,
|
||||
reflowAnchor = warmAnchor
|
||||
)
|
||||
)
|
||||
warmMeasuredPaginationPages = warmPages
|
||||
warmMeasuredPaginationRequest = request
|
||||
}
|
||||
|
||||
val reflowStartSession = latestSession
|
||||
val reflowStartRequestId = reflowStartSession.navigationRequestId
|
||||
val reflowAnchor = readerEngine.reflowAnchorFor(reflowStartSession)
|
||||
val settings = reflowStartSession.reader.settings
|
||||
if (settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
|
||||
if (settings.layoutSignature() != request.layoutSignature) return@LaunchedEffect
|
||||
logEpubPagination(
|
||||
"reflow_start book=\"${session.reader.book.title.logPreview()}\" " +
|
||||
"viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
|
||||
|
|
@ -237,17 +432,35 @@ internal fun DesktopReaderScreen(
|
|||
"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 pages = withContext(Dispatchers.Default) {
|
||||
measuredPaginator.paginate(
|
||||
book = session.reader.book,
|
||||
settings = settings,
|
||||
viewport = request.viewport,
|
||||
readCache = true
|
||||
)
|
||||
}
|
||||
val layoutChanged = pages.isNotEmpty() && !latestSession.reader.pages.samePageLayoutAs(pages)
|
||||
logEpubPagination(
|
||||
"reflow_result book=\"${session.reader.book.title.logPreview()}\" pages=${pages.size} " +
|
||||
"layoutChanged=$layoutChanged currentPages=${latestSession.reader.pages.size}"
|
||||
)
|
||||
val currentVisiblePageDetails = latestSession.reader.visiblePages.map { page ->
|
||||
"${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}"
|
||||
}
|
||||
val measuredCurrentPageDetails = pages.getOrNull(latestSession.reader.currentPageIndex)
|
||||
?.let { page -> "${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}" }
|
||||
?: "none"
|
||||
logReaderModeSwitch(
|
||||
"pagination_result requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
|
||||
"measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size} layoutChanged=$layoutChanged " +
|
||||
"currentVisible=$currentVisiblePageDetails measuredAtCurrent=$measuredCurrentPageDetails"
|
||||
)
|
||||
if (layoutChanged) {
|
||||
logReaderModeSwitch(
|
||||
"pagination_apply_dispatch requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
|
||||
"measuredPages=${pages.size} currentPages=${latestSession.reader.pages.size}"
|
||||
)
|
||||
latestOnSessionChange(
|
||||
readerEngine.replacePages(
|
||||
state = latestSession,
|
||||
|
|
@ -258,8 +471,16 @@ internal fun DesktopReaderScreen(
|
|||
)
|
||||
}
|
||||
if (pages.isNotEmpty()) {
|
||||
completedMeasuredPaginationPages = pages
|
||||
completedMeasuredPaginationRequest = request
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
logEpubPagination(
|
||||
"reflow_failed book=\"${session.reader.book.title.logPreview()}\" " +
|
||||
"viewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
|
||||
"error=\"${error.message.orEmpty().logPreview(300)}\""
|
||||
)
|
||||
} finally {
|
||||
if (runningMeasuredPaginationRequest == request) {
|
||||
runningMeasuredPaginationRequest = null
|
||||
|
|
@ -267,16 +488,45 @@ internal fun DesktopReaderScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String) -> Unit = { action, text ->
|
||||
LaunchedEffect(
|
||||
measuredPaginationRequest,
|
||||
completedMeasuredPaginationRequest,
|
||||
completedMeasuredPaginationPages,
|
||||
session.reader.pages
|
||||
) {
|
||||
val request = measuredPaginationRequest ?: return@LaunchedEffect
|
||||
val measuredPages = completedMeasuredPaginationPages
|
||||
if (session.reader.settings.readingMode != ReaderReadingMode.PAGINATED) return@LaunchedEffect
|
||||
if (completedMeasuredPaginationRequest != request || measuredPages.isEmpty()) return@LaunchedEffect
|
||||
if (session.reader.pages.samePageLayoutAs(measuredPages)) return@LaunchedEffect
|
||||
val currentVisiblePageDetails = session.reader.visiblePages.map { page ->
|
||||
"${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}"
|
||||
}
|
||||
logReaderModeSwitch(
|
||||
"pagination_apply_pending requestViewport=${request.viewport.widthPx}x${request.viewport.heightPx} " +
|
||||
"currentPages=${session.reader.pages.size} measuredPages=${measuredPages.size} " +
|
||||
"currentVisible=$currentVisiblePageDetails"
|
||||
)
|
||||
onSessionChange(
|
||||
readerEngine.replacePages(
|
||||
state = session,
|
||||
pages = measuredPages,
|
||||
reflowAnchor = readerEngine.reflowAnchorFor(session)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val handleDesktopSelectionAction: (DesktopReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator ->
|
||||
val settings = aiByokSettings.sanitized()
|
||||
when (action) {
|
||||
DesktopReaderSelectionAction.DEFINE -> {
|
||||
if (settings.areReaderAiFeaturesAvailable) onAiAction(ReaderAiFeature.DEFINE, text)
|
||||
}
|
||||
DesktopReaderSelectionAction.SPEAK -> {
|
||||
if (settings.isCloudTtsAvailable) onCloudTtsToggle(text)
|
||||
if (settings.isCloudTtsAvailable) onCloudTtsToggle(text, locator)
|
||||
}
|
||||
DesktopReaderSelectionAction.SEARCH -> onExternalLookup(ReaderExternalLookupAction.SEARCH, text)
|
||||
DesktopReaderSelectionAction.PALETTE -> Unit
|
||||
}
|
||||
}
|
||||
val nativeSelectionActions = buildSet {
|
||||
|
|
@ -285,14 +535,14 @@ internal fun DesktopReaderScreen(
|
|||
if (externalLookupAvailable) add(SharedNativeReaderSelectionAction.SEARCH)
|
||||
if (settings.isCloudTtsAvailable) add(SharedNativeReaderSelectionAction.SPEAK)
|
||||
}
|
||||
val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String) -> Unit = { action, text ->
|
||||
val handleNativeSelectionAction: (SharedNativeReaderSelectionAction, String, ReaderLocator?) -> Unit = { action, text, locator ->
|
||||
when (action) {
|
||||
SharedNativeReaderSelectionAction.DEFINE ->
|
||||
handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text)
|
||||
handleDesktopSelectionAction(DesktopReaderSelectionAction.DEFINE, text, locator)
|
||||
SharedNativeReaderSelectionAction.SPEAK ->
|
||||
handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text)
|
||||
handleDesktopSelectionAction(DesktopReaderSelectionAction.SPEAK, text, locator)
|
||||
SharedNativeReaderSelectionAction.SEARCH ->
|
||||
handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text)
|
||||
handleDesktopSelectionAction(DesktopReaderSelectionAction.SEARCH, text, locator)
|
||||
}
|
||||
}
|
||||
val handleDesktopEpubLinkClicked: (DesktopEpubLinkClick) -> Unit = { link ->
|
||||
|
|
@ -340,6 +590,9 @@ internal fun DesktopReaderScreen(
|
|||
onFullscreenChange = ::setReaderFullscreen,
|
||||
toolbarPreferences = toolbarPreferences,
|
||||
onToolbarPreferencesChange = onToolbarPreferencesChange,
|
||||
appThemeControls = appThemeControls,
|
||||
customReaderThemes = customReaderThemes,
|
||||
onCustomReaderThemesChange = onCustomReaderThemesChange,
|
||||
highlightPalette = highlightPalette,
|
||||
onHighlightPaletteChange = onHighlightPaletteChange,
|
||||
ttsReplacementPreferences = ttsReplacementPreferences,
|
||||
|
|
@ -347,7 +600,7 @@ internal fun DesktopReaderScreen(
|
|||
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
customFonts = customFonts,
|
||||
readerExtrasState = readerExtrasState,
|
||||
readerExtrasState = desktopReaderExtrasState,
|
||||
aiByokSettings = aiByokSettings,
|
||||
externalLookupAvailable = externalLookupAvailable,
|
||||
cloudTtsControlsAvailable = cloudTtsControlsAvailable,
|
||||
|
|
@ -359,8 +612,8 @@ internal fun DesktopReaderScreen(
|
|||
onCloudTtsPauseResume = onCloudTtsPauseResume,
|
||||
onCloudTtsStop = onCloudTtsStop,
|
||||
onCloudTtsClearCache = onCloudTtsClearCache,
|
||||
onCloudTtsVoiceChange = onCloudTtsVoiceChange,
|
||||
onOpenAiHub = onOpenAiHub,
|
||||
onAutoScrollChange = onAutoScrollChange,
|
||||
onDownloadReaderImage = onDownloadReaderImage,
|
||||
readerImagePreviewContent = { image, previewModifier ->
|
||||
DesktopEpubNativeImage(
|
||||
|
|
@ -369,35 +622,143 @@ internal fun DesktopReaderScreen(
|
|||
)
|
||||
},
|
||||
readerTextureDataUri = readerTextureDataUri,
|
||||
readerTexturePreviewContent = { textureId, previewModifier ->
|
||||
DesktopReaderTexturePreview(textureId = textureId, modifier = previewModifier)
|
||||
},
|
||||
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)}"
|
||||
) { renderPlan, onVisiblePageChanged, onHighlightSelected, onOpenHighlightPaletteManager, onChromeActivity ->
|
||||
val renderPlanModeKey = renderPlan.desktopReaderSurfaceModeKey()
|
||||
val readerSurfaceKey = renderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady)
|
||||
val readerModeSwitchLayoutModifier =
|
||||
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) {
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
logReaderModeSwitch(
|
||||
"native_surface_layout modeKey=$renderPlanModeKey surfaceKey=$readerSurfaceKey " +
|
||||
"paginatedReady=$paginatedLayoutReady size=${coordinates.size.width}x${coordinates.size.height} " +
|
||||
"windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " +
|
||||
"${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()}"
|
||||
)
|
||||
if (next != readerViewport) {
|
||||
logEpubPagination(
|
||||
"viewport_changed width=${next.widthPx} height=${next.heightPx} " +
|
||||
"previous=${readerViewport.widthPx}x${readerViewport.heightPx}"
|
||||
)
|
||||
readerViewport = next
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val readerSurfaceModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.onSizeChanged { size ->
|
||||
val next = ReaderViewportSpec(size.width, size.height)
|
||||
logReaderGap(
|
||||
"desktop_epub_reader_surface size=${size.width}x${size.height} " +
|
||||
"mode=${session.reader.settings.readingMode} " +
|
||||
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}"
|
||||
)
|
||||
logEpubCutoff(
|
||||
"cutoff_probe layer=desktop_surface size=${size.width}x${size.height} " +
|
||||
"mode=${session.reader.settings.readingMode} spread=${session.reader.settings.pageSpreadMode} " +
|
||||
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " +
|
||||
"margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " +
|
||||
"pageWidthSetting=${session.reader.settings.pageWidth}"
|
||||
)
|
||||
logWebViewLayoutDiag(
|
||||
"compose_reader_surface size=${size.width}x${size.height} " +
|
||||
"renderPlan=${if (renderPlan is ReaderContentRenderPlan.WebDocument) "web" else "native"} " +
|
||||
"mode=${session.reader.settings.readingMode} " +
|
||||
"fullscreen=$isFullscreen margins=${session.reader.settings.resolvedHorizontalMargin}x${session.reader.settings.resolvedVerticalMargin} " +
|
||||
"pageWidth=${session.reader.settings.pageWidth} fontSize=${session.reader.settings.fontSize} " +
|
||||
"lineSpacing=${session.reader.settings.lineSpacing} textAlign=${session.reader.settings.textAlign} " +
|
||||
"paragraphSpacing=${session.reader.settings.paragraphSpacing} imageScale=${session.reader.settings.imageScale}"
|
||||
)
|
||||
logDesktopReaderOpenTrace {
|
||||
"event=desktop_reader_surface_size bookId=\"${session.reader.book.id.logPreview(120)}\" " +
|
||||
"title=\"${session.reader.book.title.logPreview(120)}\" size=${size.width}x${size.height} " +
|
||||
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " +
|
||||
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)}"
|
||||
}
|
||||
if (next != readerViewport) {
|
||||
logEpubPagination(
|
||||
"viewport_changed width=${next.widthPx} height=${next.heightPx} " +
|
||||
"previous=${readerViewport.widthPx}x${readerViewport.heightPx}"
|
||||
)
|
||||
readerViewport = next
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
renderPlanModeKey,
|
||||
session.reader.settings.readingMode,
|
||||
paginatedLayoutReady
|
||||
) {
|
||||
logReaderModeSwitch(
|
||||
"surface_state modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " +
|
||||
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " +
|
||||
"paginatedReady=$paginatedLayoutReady runningPagination=${runningMeasuredPaginationRequest != null} " +
|
||||
"completedPagination=${completedMeasuredPaginationRequest != null} measuredApplied=$measuredPaginationPagesApplied " +
|
||||
"warmApplied=$warmMeasuredPaginationPagesApplied warmPageCount=${warmMeasuredPaginationPages.size} " +
|
||||
"completedMatchesRequest=${completedMeasuredPaginationRequest == measuredPaginationRequest} " +
|
||||
"measuredPageCount=${completedMeasuredPaginationPages.size} " +
|
||||
"currentPage=${session.reader.currentPageIndex + 1} " +
|
||||
"pageCount=${session.reader.pages.size} visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }} " +
|
||||
"fullscreen=$isFullscreen surfaceKey=$readerSurfaceKey"
|
||||
)
|
||||
logDesktopReaderOpenTrace {
|
||||
"event=desktop_render_plan_ready bookId=\"${session.reader.book.id.logPreview(120)}\" " +
|
||||
"title=\"${session.reader.book.title.logPreview(120)}\" " +
|
||||
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()} mode=${session.reader.settings.readingMode} " +
|
||||
"viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " +
|
||||
"page=${session.reader.currentPageIndex + 1}/${session.reader.pages.size.coerceAtLeast(1)} " +
|
||||
"htmlChars=${(renderPlan as? ReaderContentRenderPlan.WebDocument)?.html?.length ?: 0} " +
|
||||
"paginatedReady=$paginatedLayoutReady"
|
||||
}
|
||||
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) {
|
||||
cleanupRetiredDesktopWebView2InteropHosts(
|
||||
readerAwtWindow,
|
||||
"native_surface_state_ready_$paginatedLayoutReady"
|
||||
)
|
||||
logDesktopWebView2ModeSwitchSnapshot("surface_state_${renderPlanModeKey}_after_sweep_request")
|
||||
readerAwtWindow.requestDesktopReaderModeSwitchRepaint(
|
||||
"native_surface_state_ready_$paginatedLayoutReady"
|
||||
)
|
||||
DesktopReaderModeSwitchProbeDelaysMillis.forEach { delayMillis ->
|
||||
delay(delayMillis)
|
||||
cleanupRetiredDesktopWebView2InteropHosts(
|
||||
readerAwtWindow,
|
||||
"native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady"
|
||||
)
|
||||
readerAwtWindow.requestDesktopReaderModeSwitchRepaint(
|
||||
"native_probe_after_${delayMillis}ms_ready_$paginatedLayoutReady"
|
||||
)
|
||||
logReaderModeSwitch(
|
||||
"native_probe_after delayMs=$delayMillis modeKey=$renderPlanModeKey " +
|
||||
"viewport=${readerViewport.widthPx}x${readerViewport.heightPx} " +
|
||||
"paginatedReady=$paginatedLayoutReady currentPage=${session.reader.currentPageIndex + 1} " +
|
||||
"visiblePages=${session.reader.visiblePages.map { it.pageIndex + 1 }}"
|
||||
)
|
||||
logDesktopWebView2ModeSwitchSnapshot("native_probe_after_${delayMillis}ms")
|
||||
}
|
||||
} else {
|
||||
logDesktopWebView2ModeSwitchSnapshot("surface_state_$renderPlanModeKey")
|
||||
}
|
||||
}
|
||||
DisposableEffect(renderPlanModeKey) {
|
||||
logReaderModeSwitch(
|
||||
"surface_enter modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " +
|
||||
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}"
|
||||
)
|
||||
logDesktopWebView2ModeSwitchSnapshot("surface_enter_$renderPlanModeKey")
|
||||
onDispose {
|
||||
logReaderModeSwitch(
|
||||
"surface_exit modeKey=$renderPlanModeKey readingMode=${session.reader.settings.readingMode} " +
|
||||
"renderPlan=${renderPlan.desktopReaderSurfaceModeLabel()}"
|
||||
)
|
||||
logDesktopWebView2ModeSwitchSnapshot("surface_exit_$renderPlanModeKey")
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun ReaderSurfaceContent() {
|
||||
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages && !paginatedLayoutReady) {
|
||||
DesktopEpubPaginationPreparing(
|
||||
active = runningMeasuredPaginationRequest != null,
|
||||
|
|
@ -406,14 +767,41 @@ internal fun DesktopReaderScreen(
|
|||
} else {
|
||||
when (renderPlan) {
|
||||
is ReaderContentRenderPlan.WebDocument -> {
|
||||
if (webViewRuntimeState.initialized) {
|
||||
val canRenderWebDocument = desktopEpubWebViewCanRender(webViewRuntimeState)
|
||||
LaunchedEffect(
|
||||
renderPlan.html,
|
||||
canRenderWebDocument,
|
||||
webViewRuntimeState,
|
||||
webViewNetworkAccessEnabled
|
||||
) {
|
||||
logDesktopWebView2(
|
||||
"reader_screen_web_document canRender=$canRenderWebDocument " +
|
||||
"backend=${desktopEpubWebViewBackend().logName} " +
|
||||
"runtimeInitialized=${webViewRuntimeState.initialized} restart=${webViewRuntimeState.restartRequired} " +
|
||||
"error=${webViewRuntimeState.errorMessage != null} network=$webViewNetworkAccessEnabled " +
|
||||
"htmlChars=${renderPlan.html.length} htmlHash=${renderPlan.html.hashCode()}"
|
||||
)
|
||||
}
|
||||
if (canRenderWebDocument) {
|
||||
DesktopEpubWebView(
|
||||
html = renderPlan.html,
|
||||
appearanceScript = renderPlan.appearanceScript,
|
||||
highlightPaletteScript = renderPlan.highlightPaletteScript,
|
||||
navigationTarget = renderPlan.navigationTarget,
|
||||
highlights = renderPlan.highlights,
|
||||
onHighlightCreated = { highlight ->
|
||||
onSessionChange(session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine))
|
||||
logEpubHighlightFlow(
|
||||
"state_reduce_start id=${highlight.id} before=${session.highlights.size} " +
|
||||
"color=${highlight.color.id} chapter=${highlight.chapterIndex} " +
|
||||
"offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
|
||||
"page=${highlight.locator.pageIndex} textChars=${highlight.text.length}"
|
||||
)
|
||||
val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)
|
||||
logEpubHighlightFlow(
|
||||
"state_reduce_done id=${highlight.id} after=${nextSession.highlights.size} " +
|
||||
"contains=${nextSession.highlights.any { it.id == highlight.id }}"
|
||||
)
|
||||
onSessionChange(nextSession)
|
||||
},
|
||||
onHighlightSelected = onHighlightSelected,
|
||||
isFullscreen = isFullscreen,
|
||||
|
|
@ -427,11 +815,18 @@ internal fun DesktopReaderScreen(
|
|||
onSessionChange(nextSession)
|
||||
}
|
||||
},
|
||||
onSelectionAction = handleDesktopSelectionAction,
|
||||
onSelectionAction = { payload ->
|
||||
if (payload.action == DesktopReaderSelectionAction.PALETTE) {
|
||||
onOpenHighlightPaletteManager()
|
||||
} else {
|
||||
handleDesktopSelectionAction(payload.action, payload.text, payload.locator)
|
||||
}
|
||||
},
|
||||
onLinkClicked = handleDesktopEpubLinkClicked,
|
||||
onVisiblePageChanged = onVisiblePageChanged,
|
||||
onPointerActivity = onChromeActivity,
|
||||
networkAccessEnabled = webViewNetworkAccessEnabled,
|
||||
backgroundColor = renderPlan.background,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
|
|
@ -442,36 +837,157 @@ internal fun DesktopReaderScreen(
|
|||
}
|
||||
}
|
||||
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()
|
||||
)
|
||||
LaunchedEffect(renderPlan.visiblePages, paginatedLayoutReady) {
|
||||
val pageDetails = renderPlan.visiblePages.joinToString(prefix = "[", postfix = "]") { page ->
|
||||
"${page.pageIndex + 1}:text=${page.text.length}:blocks=${page.semanticBlocks.size}"
|
||||
}
|
||||
logReaderModeSwitch(
|
||||
"native_reader_render paginatedReady=$paginatedLayoutReady " +
|
||||
"visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }} " +
|
||||
"pageDetails=$pageDetails " +
|
||||
"background=${renderPlan.background} foreground=${renderPlan.foreground}"
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
logReaderModeSwitch(
|
||||
"native_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " +
|
||||
"windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " +
|
||||
"${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " +
|
||||
"visiblePages=${renderPlan.visiblePages.map { it.pageIndex + 1 }}"
|
||||
)
|
||||
}
|
||||
) {
|
||||
SharedNativePaginatedReader(
|
||||
renderPlan = renderPlan,
|
||||
readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(),
|
||||
searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f),
|
||||
onVisiblePageChanged = onVisiblePageChanged,
|
||||
enabledSelectionActions = nativeSelectionActions,
|
||||
onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) },
|
||||
onSelectionAction = handleNativeSelectionAction,
|
||||
onOpenHighlightPaletteManager = onOpenHighlightPaletteManager,
|
||||
onHighlightCreated = { highlight ->
|
||||
logDesktopHighlightMap(
|
||||
"native_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " +
|
||||
"color=${highlight.color.id} chapter=${highlight.chapterIndex} " +
|
||||
"page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
|
||||
"block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " +
|
||||
"textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\""
|
||||
)
|
||||
val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)
|
||||
logDesktopHighlightMap(
|
||||
"native_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " +
|
||||
"contains=${nextSession.highlights.any { it.id == highlight.id }}"
|
||||
)
|
||||
onSessionChange(nextSession)
|
||||
},
|
||||
onHighlightSelected = onHighlightSelected,
|
||||
onLinkClicked = { link ->
|
||||
handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick())
|
||||
},
|
||||
onReaderTap = onChromeActivity,
|
||||
imageContent = { image, imageModifier ->
|
||||
DesktopEpubNativeImage(
|
||||
image = image,
|
||||
modifier = imageModifier
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key(readerSurfaceKey) {
|
||||
if (renderPlan is ReaderContentRenderPlan.WebDocument) {
|
||||
Box(
|
||||
modifier = readerSurfaceModifier
|
||||
.fillMaxSize()
|
||||
.background(renderPlan.background)
|
||||
) {
|
||||
ReaderSurfaceContent()
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
color = renderPlan.background,
|
||||
shape = RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp),
|
||||
modifier = readerSurfaceModifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(if (isFullscreen) 0.dp else 4.dp))
|
||||
.then(readerModeSwitchLayoutModifier)
|
||||
) {
|
||||
ReaderSurfaceContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderContentRenderPlan.desktopReaderSurfaceModeKey(): String {
|
||||
return when (this) {
|
||||
is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web"
|
||||
is ReaderContentRenderPlan.NativePaginatedPages -> "desktop-reader-native"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderContentRenderPlan.desktopReaderSurfaceModeLabel(): String {
|
||||
return when (this) {
|
||||
is ReaderContentRenderPlan.WebDocument -> "web"
|
||||
is ReaderContentRenderPlan.NativePaginatedPages -> "native"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderContentRenderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady: Boolean): String {
|
||||
return when (this) {
|
||||
is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web"
|
||||
is ReaderContentRenderPlan.NativePaginatedPages ->
|
||||
"desktop-reader-native-${if (paginatedLayoutReady) "ready" else "preparing"}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun Window?.requestDesktopReaderModeSwitchRepaint(reason: String) {
|
||||
val targetWindow = this
|
||||
EventQueue.invokeLater {
|
||||
if (targetWindow == null) {
|
||||
logReaderModeSwitch("awt_repaint_skip reason=$reason window=null")
|
||||
return@invokeLater
|
||||
}
|
||||
if (!targetWindow.isDisplayable) {
|
||||
logReaderModeSwitch(
|
||||
"awt_repaint_skip reason=$reason window=${targetWindow.javaClass.simpleName} " +
|
||||
"displayable=false visible=${targetWindow.isVisible} showing=${targetWindow.isShowing} " +
|
||||
"size=${targetWindow.width}x${targetWindow.height}"
|
||||
)
|
||||
return@invokeLater
|
||||
}
|
||||
targetWindow.invalidate()
|
||||
targetWindow.validate()
|
||||
targetWindow.repaint()
|
||||
(targetWindow as? javax.swing.RootPaneContainer)?.contentPane?.let { contentPane ->
|
||||
contentPane.invalidate()
|
||||
contentPane.validate()
|
||||
contentPane.repaint()
|
||||
}
|
||||
logReaderModeSwitch(
|
||||
"awt_repaint reason=$reason window=${targetWindow.javaClass.simpleName} " +
|
||||
"visible=${targetWindow.isVisible} displayable=${targetWindow.isDisplayable} " +
|
||||
"showing=${targetWindow.isShowing} size=${targetWindow.width}x${targetWindow.height}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val DesktopReaderModeSwitchProbeDelaysMillis = longArrayOf(120L, 350L, 900L)
|
||||
|
||||
private fun Long.elapsedMillis(): Long {
|
||||
return ((System.nanoTime() - this) / 1_000_000L).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun ReaderImageReference.toDesktopPreviewSemanticImage(): SemanticImage {
|
||||
return SemanticImage(
|
||||
path = source,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
@Composable
|
||||
internal fun DesktopReaderTexturePreview(
|
||||
textureId: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val bitmap = remember(textureId) { DesktopReaderTextures.imageBitmapFor(textureId) }
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"Aa",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean
|
|||
left.startOffset == right.startOffset &&
|
||||
left.endOffset == right.endOffset &&
|
||||
left.text.length == right.text.length &&
|
||||
left.semanticBlocks.size == right.semanticBlocks.size
|
||||
left.semanticBlocks == right.semanticBlocks
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,53 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
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.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
internal const val DesktopReaderWindowDefaultWidthDp = 1120f
|
||||
internal const val DesktopReaderWindowDefaultHeightDp = 760f
|
||||
internal val DesktopReaderWindowDefaultSize = DpSize(
|
||||
DesktopReaderWindowDefaultWidthDp.dp,
|
||||
DesktopReaderWindowDefaultHeightDp.dp
|
||||
)
|
||||
|
||||
internal fun DesktopWindowStateSnapshot.toReaderWindowPlacement(): WindowPlacement {
|
||||
return when (placement) {
|
||||
DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Floating
|
||||
else -> toWindowPlacement()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DesktopWindowStateSnapshot.toPersistableReaderWindowSnapshot(): DesktopWindowStateSnapshot? {
|
||||
if (placement == DesktopSavedWindowPlacement.FULLSCREEN) return null
|
||||
return sanitized()
|
||||
}
|
||||
|
||||
internal fun shouldResetDesktopTextReaderWindowSurface(
|
||||
previousMode: ReaderReadingMode,
|
||||
currentMode: ReaderReadingMode,
|
||||
usesNativeWebView: Boolean
|
||||
): Boolean {
|
||||
return usesNativeWebView &&
|
||||
previousMode == ReaderReadingMode.VERTICAL &&
|
||||
currentMode == ReaderReadingMode.PAGINATED
|
||||
}
|
||||
|
||||
internal data class DesktopReaderWindowState(
|
||||
val id: String,
|
||||
val opening: DesktopReaderOpening,
|
||||
val content: DesktopReaderWindowContent = DesktopReaderWindowContent.Opening,
|
||||
val focusRequestId: Long = 0L,
|
||||
val fullscreen: Boolean = false
|
||||
val fullscreen: Boolean = false,
|
||||
val surfaceResetId: Long = 0L
|
||||
) {
|
||||
val bookId: String
|
||||
get() = opening.bookId
|
||||
|
|
@ -57,7 +91,6 @@ internal sealed interface DesktopReaderWindowContent {
|
|||
val isSummaryLoading: Boolean = false,
|
||||
val isRecapLoading: Boolean = false,
|
||||
val recapProgressMessage: String? = null,
|
||||
val showCloudTtsSettings: Boolean = false,
|
||||
val ttsJob: Job? = null
|
||||
) : DesktopReaderWindowContent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,39 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderTtsChunk
|
||||
|
||||
private const val DesktopTtsLogTag = "EpistemeDesktopTts"
|
||||
private const val DesktopTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace"
|
||||
private val DesktopTtsSensitiveQueryRegex = Regex("""(?i)([?&](?:key|token)=)[^&\s"]+""")
|
||||
private val DesktopTtsSensitiveLabelRegex = Regex(
|
||||
"""(?i)\b((?:geminiKey|groqKey|api[_-]?key|authorization|token)\s*[:=]\s*)[^\s,;"]+"""
|
||||
)
|
||||
|
||||
internal fun logDesktopTts(message: String) {
|
||||
logDesktopDiagnostic(DesktopTtsLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logDesktopTtsStartTrace(message: () -> String) {
|
||||
logDesktopDiagnostic(DesktopTtsStartTraceLogTag, message)
|
||||
}
|
||||
|
||||
internal fun ReaderTtsChunk?.desktopTtsStartTraceSummary(maxTextLength: Int = 120): String {
|
||||
if (this == null) return "null"
|
||||
return "index=$index page=${pageIndex + 1} chapter=$chapterIndex " +
|
||||
"offsets=$startOffset..$endOffset sourceCfi=\"${sourceCfi.orEmpty().logPreview(160)}\" " +
|
||||
"textChars=${text.length} spokenChars=${spokenText.length} " +
|
||||
"text=\"${text.logPreview(maxTextLength)}\" spoken=\"${spokenText.logPreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
internal fun Throwable.desktopTtsSummary(): String {
|
||||
val type = this::class.java.simpleName.ifBlank { "Throwable" }
|
||||
return "$type: ${message.orEmpty().desktopTtsPreview(220)}"
|
||||
}
|
||||
|
||||
internal fun String.desktopTtsPreview(maxLength: Int = 120): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
return replace(DesktopTtsSensitiveQueryRegex) { match -> match.groupValues[1] + "<redacted>" }
|
||||
.replace(DesktopTtsSensitiveLabelRegex) { match -> match.groupValues[1] + "<redacted>" }
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
|
|
|
|||
|
|
@ -151,5 +151,9 @@ internal class DesktopWindowStateStore(
|
|||
fun defaultWindowStateFile(): File {
|
||||
return File(desktopUserConfigRoot(), "window_state.json")
|
||||
}
|
||||
|
||||
fun defaultReaderWindowStateFile(): File {
|
||||
return File(desktopUserConfigRoot(), "reader_window_state.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -86,6 +86,54 @@ class DesktopAiByokStoreTest {
|
|||
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save with blank key clears protected secret entry`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-ai-store-clear").resolve("ai-byok.properties")
|
||||
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
|
||||
|
||||
store.save(
|
||||
ReaderAiByokSettings(
|
||||
geminiKey = "gemini_secret",
|
||||
groqKey = "groq_secret",
|
||||
modelForAll = "groq:qwen/qwen3-32b"
|
||||
)
|
||||
)
|
||||
store.save(
|
||||
ReaderAiByokSettings(
|
||||
groqKey = "groq_secret",
|
||||
modelForAll = "groq:qwen/qwen3-32b"
|
||||
)
|
||||
)
|
||||
|
||||
val raw = settingsFile.readText()
|
||||
assertFalse(raw.contains("geminiKeyProtected="))
|
||||
assertTrue(raw.contains("groqKeyProtected="))
|
||||
|
||||
val loaded = store.load()
|
||||
assertEquals("", loaded.geminiKey)
|
||||
assertEquals("groq_secret", loaded.groqKey)
|
||||
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load ignores legacy hidden reader ai preference on desktop`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-ai-store-visible").resolve("ai-byok.properties")
|
||||
settingsFile.writeText(
|
||||
"""
|
||||
hideReaderAiFeatures=true
|
||||
modelForAll=groq:qwen/qwen3-32b
|
||||
useOneModel=true
|
||||
""".trimIndent()
|
||||
)
|
||||
val store = DesktopAiByokStore(settingsFile.toFile(), ReversibleSecretCodec)
|
||||
|
||||
val loaded = store.load()
|
||||
|
||||
assertFalse(loaded.hideReaderAiFeatures)
|
||||
assertTrue(loaded.useOneModel)
|
||||
assertEquals("groq:qwen/qwen3-32b", loaded.modelForAll)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load does not probe secure storage when settings file is missing`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-ai-store-missing").resolve("ai-byok.properties")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.UserData
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopAuthStoreTest {
|
||||
@Test
|
||||
fun `save protects refresh tokens and load restores the account`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-auth-store")
|
||||
.resolve("auth.properties")
|
||||
.toFile()
|
||||
val store = DesktopAuthStore(settingsFile, ReversibleSecretCodec)
|
||||
|
||||
store.save(testSession())
|
||||
|
||||
val raw = settingsFile.readText()
|
||||
assertFalse(raw.contains("firebase_refresh"))
|
||||
assertFalse(raw.contains("google_refresh"))
|
||||
assertTrue(raw.contains("firebaseRefreshTokenProtected="))
|
||||
assertTrue(raw.contains("googleRefreshTokenProtected="))
|
||||
|
||||
val loaded = DesktopAuthStore(settingsFile, ReversibleSecretCodec).load()
|
||||
assertEquals("user-1", loaded?.user?.uid)
|
||||
assertEquals("reader@example.com", loaded?.user?.email)
|
||||
assertEquals("firebase_refresh", loaded?.refreshToken)
|
||||
assertEquals("google_refresh", loaded?.googleRefreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save fails without leaving a partial account file when secure storage is unavailable`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-auth-store-unavailable")
|
||||
.resolve("auth.properties")
|
||||
.toFile()
|
||||
val store = DesktopAuthStore(settingsFile, ThrowingSecretCodec)
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
store.save(testSession())
|
||||
}
|
||||
assertFalse(settingsFile.exists())
|
||||
}
|
||||
|
||||
private fun testSession(): DesktopAuthSession {
|
||||
return DesktopAuthSession(
|
||||
user = UserData(
|
||||
uid = "user-1",
|
||||
displayName = "Reader",
|
||||
photoUrl = null,
|
||||
email = "reader@example.com"
|
||||
),
|
||||
idToken = "id_token",
|
||||
refreshToken = "firebase_refresh",
|
||||
expiresAtEpochMillis = 123L,
|
||||
googleAccessToken = "google_access",
|
||||
googleRefreshToken = "google_refresh",
|
||||
googleAccessTokenExpiresAtEpochMillis = 456L
|
||||
)
|
||||
}
|
||||
|
||||
private object ReversibleSecretCodec : DesktopSecretCodec {
|
||||
override val isAvailable: Boolean = true
|
||||
|
||||
override fun protect(value: String): String {
|
||||
return "test:" + value.reversed()
|
||||
}
|
||||
|
||||
override fun unprotect(value: String): String {
|
||||
return value.removePrefix("test:").reversed()
|
||||
}
|
||||
}
|
||||
|
||||
private object ThrowingSecretCodec : DesktopSecretCodec {
|
||||
override val isAvailable: Boolean = false
|
||||
|
||||
override fun protect(value: String): String {
|
||||
throw IllegalStateException("Secure storage unavailable")
|
||||
}
|
||||
|
||||
override fun unprotect(value: String): String = ""
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
|
@ -18,9 +17,13 @@ class DesktopBuildProfileTest {
|
|||
assertEquals(EpistemeDesktopStandardAppName, profile.appName)
|
||||
assertEquals("Standard edition", profile.buildLabel)
|
||||
assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy)
|
||||
assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/privacy-policy.html"))
|
||||
assertTrue(profile.legalLinks.termsUrl.endsWith("/terms-and-conditions.html"))
|
||||
assertTrue(profile.featurePolicy.networkAccess)
|
||||
assertFalse(profile.featurePolicy.byokAi)
|
||||
assertFalse(profile.byokAiAvailable)
|
||||
assertTrue(profile.aiKeySettingsAvailable)
|
||||
assertTrue(profile.creditBackedCloudTtsControlsAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -31,12 +34,16 @@ class DesktopBuildProfileTest {
|
|||
assertEquals(EpistemeDesktopOssAppName, profile.appName)
|
||||
assertEquals("Offline OSS edition", profile.buildLabel)
|
||||
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
|
||||
assertTrue(profile.legalLinks.privacyPolicyUrl.endsWith("/oss-privacy-policy.html"))
|
||||
assertTrue(profile.legalLinks.termsUrl.endsWith("/oss-terms-of-service.html"))
|
||||
assertFalse(profile.featurePolicy.networkAccess)
|
||||
assertFalse(profile.featurePolicy.aiAndCloud)
|
||||
assertTrue(profile.featurePolicy.byokAi)
|
||||
assertFalse(profile.byokAiAvailable)
|
||||
assertFalse(profile.aiKeySettingsAvailable)
|
||||
assertFalse(profile.featurePolicy.opdsCatalogs)
|
||||
assertFalse(profile.featurePolicy.googleFontsDownload)
|
||||
assertFalse(profile.creditBackedCloudTtsControlsAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -54,23 +61,104 @@ class DesktopBuildProfileTest {
|
|||
geminiKey = "gemini_secret",
|
||||
modelForAll = "gemini:gemini-flash-lite-latest"
|
||||
)
|
||||
val onlineOssPolicy = SharedFeaturePolicy(
|
||||
networkAccess = true,
|
||||
aiAndCloud = true,
|
||||
byokAi = true
|
||||
val onlineOssPolicy = SharedFeaturePolicy.OssOnline
|
||||
|
||||
val onlineOssProfile = DesktopBuildProfile(
|
||||
flavor = "oss-online",
|
||||
appName = "Episteme oss",
|
||||
buildLabel = "OSS edition",
|
||||
featurePolicy = onlineOssPolicy
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
assertTrue(onlineOssProfile.byokAiAvailable)
|
||||
assertFalse(onlineOssProfile.aiKeySettingsAvailable)
|
||||
assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy))
|
||||
assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures)
|
||||
assertFalse(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures)
|
||||
|
||||
val byokCloudTtsSettings = settings.copy(
|
||||
geminiKey = "gemini_secret",
|
||||
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
|
||||
)
|
||||
val desktopByokSettings = byokCloudTtsSettings.withDesktopFeaturePolicy(onlineOssPolicy)
|
||||
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, desktopByokSettings.ttsModel)
|
||||
assertTrue(desktopByokSettings.isCloudTtsAvailable)
|
||||
assertFalse(
|
||||
DesktopBuildProfile(
|
||||
flavor = "oss-online",
|
||||
appName = "Episteme oss",
|
||||
buildLabel = "OSS edition",
|
||||
featurePolicy = onlineOssPolicy
|
||||
).byokAiAvailable
|
||||
).creditBackedCloudTtsControlsAvailable
|
||||
)
|
||||
assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy))
|
||||
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures)
|
||||
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop tts worker requires its own configured endpoint`() {
|
||||
val config = DesktopCloudConfig(
|
||||
aiWorkerUrl = "https://example.com/ai",
|
||||
ttsWorkerUrl = "",
|
||||
firebaseWebApiKey = "",
|
||||
firebaseProjectId = "",
|
||||
googleOAuthClientId = "",
|
||||
googleOAuthClientSecret = ""
|
||||
)
|
||||
|
||||
assertTrue(config.isAiWorkerConfigured)
|
||||
assertFalse(config.isTtsWorkerConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop cloud tts adapter allows byok before credit worker`() {
|
||||
val byokAdapter = DesktopGeminiCloudTtsAdapter(
|
||||
settingsProvider = {
|
||||
ReaderAiByokSettings(
|
||||
geminiKey = "gemini_secret",
|
||||
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
|
||||
)
|
||||
},
|
||||
networkAccess = { true },
|
||||
workerUrlProvider = { "" }
|
||||
)
|
||||
val workerAdapter = DesktopGeminiCloudTtsAdapter(
|
||||
settingsProvider = { ReaderAiByokSettings(serverBackedCloudTts = true) },
|
||||
networkAccess = { true },
|
||||
workerUrlProvider = { "https://example.com/tts" }
|
||||
)
|
||||
val unavailableAdapter = DesktopGeminiCloudTtsAdapter(
|
||||
settingsProvider = { ReaderAiByokSettings() },
|
||||
networkAccess = { true },
|
||||
workerUrlProvider = { "https://example.com/tts" }
|
||||
)
|
||||
|
||||
assertTrue(byokAdapter.isAvailable)
|
||||
assertTrue(workerAdapter.isAvailable)
|
||||
assertFalse(unavailableAdapter.isAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop persisted AI settings keep Android model controls and force visibility`() {
|
||||
val settings = ReaderAiByokSettings(
|
||||
geminiKey = " gemini_secret ",
|
||||
groqKey = " groq_secret ",
|
||||
useOneModel = true,
|
||||
modelForAll = "groq:qwen/qwen3-32b",
|
||||
defineModel = "gemini:gemini-flash-lite-latest",
|
||||
summarizeModel = "groq:llama-3.3-70b-versatile",
|
||||
recapModel = "gemini:gemini-2.5-flash-lite",
|
||||
hideReaderAiFeatures = true
|
||||
)
|
||||
|
||||
val persisted = settings.toDesktopPersistableAiSettings()
|
||||
|
||||
assertEquals("gemini_secret", persisted.geminiKey)
|
||||
assertEquals("groq_secret", persisted.groqKey)
|
||||
assertTrue(persisted.useOneModel)
|
||||
assertEquals("groq:qwen/qwen3-32b", persisted.modelForAll)
|
||||
assertEquals("gemini:gemini-flash-lite-latest", persisted.defineModel)
|
||||
assertEquals("groq:llama-3.3-70b-versatile", persisted.summarizeModel)
|
||||
assertEquals("gemini:gemini-2.5-flash-lite", persisted.recapModel)
|
||||
assertFalse(persisted.hideReaderAiFeatures)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -84,36 +172,4 @@ class DesktopBuildProfileTest {
|
|||
assertTrue(desktopDiagnosticsFlag(" TRUE "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bundled webview detection requires cef binaries`() {
|
||||
val dir = Files.createTempDirectory("episteme-kcef-test").toFile()
|
||||
try {
|
||||
val windowsX64 = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
|
||||
assertFalse(isBundledDesktopWebViewPresent(dir, windowsX64))
|
||||
File(dir, "jcef.dll").writeText("jcef")
|
||||
File(dir, "libcef.dll").writeText("cef")
|
||||
|
||||
assertTrue(isBundledDesktopWebViewPresent(dir, windowsX64))
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linux bundled webview detection requires cef shared library and resources`() {
|
||||
val dir = Files.createTempDirectory("episteme-linux-kcef-test").toFile()
|
||||
try {
|
||||
val linuxX64 = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
|
||||
assertFalse(isBundledDesktopWebViewPresent(dir, linuxX64))
|
||||
|
||||
File(dir, "libcef.so").writeText("cef")
|
||||
File(dir, "chrome-sandbox").writeText("sandbox")
|
||||
File(dir, "icudtl.dat").writeText("icu")
|
||||
File(dir, "locales").mkdir()
|
||||
|
||||
assertTrue(isBundledDesktopWebViewPresent(dir, linuxX64))
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.util.Properties
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopCloudConfigTest {
|
||||
@Test
|
||||
fun `packaged resource config can enable desktop Google sign in`() {
|
||||
val config = desktopCloudConfigFromProperties(
|
||||
resourceProperties = properties(
|
||||
"FIREBASE_WEB_API_KEY" to "firebase-key",
|
||||
"FIREBASE_PROJECT_ID" to "reader-project",
|
||||
"GOOGLE_OAUTH_CLIENT_ID" to "oauth-client",
|
||||
"GOOGLE_OAUTH_CLIENT_SECRET" to "oauth-secret"
|
||||
),
|
||||
systemProperty = { null },
|
||||
environment = { null }
|
||||
)
|
||||
|
||||
assertTrue(config.isAuthConfigured)
|
||||
assertEquals("firebase-key", config.firebaseWebApiKey)
|
||||
assertEquals("reader-project", config.firebaseProjectId)
|
||||
assertEquals("oauth-client", config.googleOAuthClientId)
|
||||
assertEquals("oauth-secret", config.googleOAuthClientSecret)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local desktop keys override packaged resource config`() {
|
||||
val config = desktopCloudConfigFromProperties(
|
||||
resourceProperties = properties(
|
||||
"FIREBASE_WEB_API_KEY" to "packaged-firebase-key",
|
||||
"FIREBASE_PROJECT_ID" to "packaged-project",
|
||||
"GOOGLE_OAUTH_CLIENT_ID" to "packaged-oauth-client",
|
||||
"GOOGLE_OAUTH_CLIENT_SECRET" to "packaged-oauth-secret"
|
||||
),
|
||||
localProperties = properties(
|
||||
"DESKTOP_FIREBASE_WEB_API_KEY" to "local-firebase-key",
|
||||
"DESKTOP_FIREBASE_PROJECT_ID" to "local-project",
|
||||
"DESKTOP_GOOGLE_OAUTH_CLIENT_ID" to "local-oauth-client",
|
||||
"DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET" to "local-oauth-secret"
|
||||
),
|
||||
systemProperty = { null },
|
||||
environment = { null }
|
||||
)
|
||||
|
||||
assertTrue(config.isAuthConfigured)
|
||||
assertEquals("local-firebase-key", config.firebaseWebApiKey)
|
||||
assertEquals("local-project", config.firebaseProjectId)
|
||||
assertEquals("local-oauth-client", config.googleOAuthClientId)
|
||||
assertEquals("local-oauth-secret", config.googleOAuthClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
private fun properties(vararg values: Pair<String, String>): Properties {
|
||||
return Properties().apply {
|
||||
values.forEach { (key, value) -> setProperty(key, value) }
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,16 @@ 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.pdf.SharedPdfAnnotationSerializer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfBookmark
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichDocument
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopCloudSyncMappingTest {
|
||||
|
|
@ -71,6 +77,7 @@ class DesktopCloudSyncMappingTest {
|
|||
assertEquals(2, metadata.lastChapterIndex)
|
||||
assertEquals(4, metadata.lastPage)
|
||||
assertEquals(42f, metadata.progressPercentage)
|
||||
assertEquals(1_000L, metadata.readingPositionModifiedTimestamp)
|
||||
assertTrue(assertNotNull(metadata.bookmarksJson).contains("desktop:2:30:44"))
|
||||
assertTrue(assertNotNull(metadata.highlightsJson).contains("highlighted text"))
|
||||
assertEquals(book.id, restored.id)
|
||||
|
|
@ -88,6 +95,212 @@ class DesktopCloudSyncMappingTest {
|
|||
assertEquals("desktop:2:50:64", restored.readerHighlights.single().locator.cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata only upload can preserve remote content timestamp`() {
|
||||
val book = BookItem(
|
||||
id = "book-1",
|
||||
path = null,
|
||||
type = FileType.PDF,
|
||||
displayName = "Book.pdf",
|
||||
timestamp = 1_000L,
|
||||
fileContentModifiedTimestamp = 111L
|
||||
)
|
||||
|
||||
val metadata = book.toDesktopCloudBookMetadata(
|
||||
hasAnnotations = false,
|
||||
timestamp = 2_000L,
|
||||
contentTimestampOverride = 999L
|
||||
)
|
||||
|
||||
assertEquals(999L, metadata.fileContentModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata upload keeps reading position timestamp separate from upload timestamp`() {
|
||||
val book = BookItem(
|
||||
id = "book-1",
|
||||
path = null,
|
||||
type = FileType.PDF,
|
||||
displayName = "Book.pdf",
|
||||
timestamp = 1_000L,
|
||||
lastPageIndex = 12,
|
||||
progressPercentage = 20f,
|
||||
readingPositionModifiedTimestamp = 1_500L
|
||||
)
|
||||
|
||||
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = true, timestamp = 3_000L)
|
||||
|
||||
assertEquals(3_000L, metadata.lastModifiedTimestamp)
|
||||
assertEquals(1_500L, metadata.readingPositionModifiedTimestamp)
|
||||
assertEquals(0L, metadata.annotationModifiedTimestamp)
|
||||
assertEquals(12, metadata.lastPage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata upload keeps annotation timestamp separate from upload timestamp`() {
|
||||
val book = BookItem(
|
||||
id = "book-1",
|
||||
path = null,
|
||||
type = FileType.PDF,
|
||||
displayName = "Book.pdf",
|
||||
timestamp = 1_000L
|
||||
)
|
||||
|
||||
val metadata = book.toDesktopCloudBookMetadata(
|
||||
hasAnnotations = true,
|
||||
timestamp = 3_000L,
|
||||
annotationModifiedTimestamp = 2_250L
|
||||
)
|
||||
|
||||
assertEquals(3_000L, metadata.lastModifiedTimestamp)
|
||||
assertEquals(2_250L, metadata.annotationModifiedTimestamp)
|
||||
assertEquals(2_250L, metadata.effectiveCloudAnnotationModifiedTimestamp())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation freshness does not fall back to book metadata timestamp`() {
|
||||
val metadata = DesktopCloudBookMetadata(
|
||||
bookId = "book-1",
|
||||
type = FileType.PDF.name,
|
||||
lastModifiedTimestamp = 5_000L,
|
||||
hasAnnotations = true
|
||||
)
|
||||
|
||||
assertEquals(0L, metadata.effectiveCloudAnnotationModifiedTimestamp())
|
||||
assertEquals(3_000L, metadata.effectiveCloudAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop drive file names use shared cloud content extension`() {
|
||||
assertEquals("book-1.epub", desktopCloudBookDriveFileName("book-1", FileType.EPUB))
|
||||
assertEquals("book-1.md", desktopCloudBookDriveFileName("book-1", FileType.MD))
|
||||
assertEquals("book-1.mobi", desktopCloudBookDriveFileName("book-1", FileType.MOBI))
|
||||
assertNull(desktopCloudBookDriveFileName("book-1", FileType.UNKNOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty epub annotations upload as empty arrays`() {
|
||||
val book = BookItem(
|
||||
id = "book-1",
|
||||
path = "C:/books/Book.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "Book.epub",
|
||||
timestamp = 1_000L
|
||||
)
|
||||
|
||||
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false)
|
||||
|
||||
assertEquals("[]", metadata.bookmarksJson)
|
||||
assertEquals("[]", metadata.highlightsJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote pdf metadata moves stale desktop viewport to remote page`() {
|
||||
val existing = BookItem(
|
||||
id = "book-1",
|
||||
path = "C:/books/Book.pdf",
|
||||
type = FileType.PDF,
|
||||
displayName = "Book.pdf",
|
||||
timestamp = 1_000L,
|
||||
lastPageIndex = 264,
|
||||
progressPercentage = 33.125f,
|
||||
readerPosition = ReaderLocator(pageIndex = 264),
|
||||
pdfReaderViewport = SharedPdfReaderViewport(
|
||||
pageIndex = 264,
|
||||
verticalFirstPageIndex = 264,
|
||||
verticalFirstPageScrollOffset = 120
|
||||
)
|
||||
)
|
||||
val remote = DesktopCloudBookMetadata(
|
||||
bookId = "book-1",
|
||||
displayName = "Book.pdf",
|
||||
type = FileType.PDF.name,
|
||||
lastModifiedTimestamp = 2_000L,
|
||||
lastPage = 69,
|
||||
progressPercentage = 8.75f
|
||||
)
|
||||
|
||||
val restored = remote.toDesktopBookItem(existing = existing)
|
||||
|
||||
assertEquals(69, restored.lastPageIndex)
|
||||
assertEquals(8.75f, restored.progressPercentage)
|
||||
assertNull(restored.readerPosition)
|
||||
assertEquals(69, restored.pdfReaderViewport?.pageIndex)
|
||||
assertEquals(69, restored.pdfReaderViewport?.verticalFirstPageIndex)
|
||||
assertEquals(0, restored.pdfReaderViewport?.verticalFirstPageScrollOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote metadata with older reading timestamp preserves newer local pdf position`() {
|
||||
val existing = BookItem(
|
||||
id = "book-1",
|
||||
path = "C:/books/Book.pdf",
|
||||
type = FileType.PDF,
|
||||
displayName = "Book.pdf",
|
||||
timestamp = 4_000L,
|
||||
lastPageIndex = 88,
|
||||
progressPercentage = 44f,
|
||||
pdfReaderViewport = SharedPdfReaderViewport(pageIndex = 88, verticalFirstPageIndex = 88),
|
||||
readingPositionModifiedTimestamp = 4_000L
|
||||
)
|
||||
val remote = DesktopCloudBookMetadata(
|
||||
bookId = "book-1",
|
||||
displayName = "Book.pdf",
|
||||
type = FileType.PDF.name,
|
||||
lastModifiedTimestamp = 6_000L,
|
||||
readingPositionModifiedTimestamp = 3_000L,
|
||||
lastPage = 12,
|
||||
progressPercentage = 6f,
|
||||
hasAnnotations = true
|
||||
)
|
||||
|
||||
val restored = remote.toDesktopBookItem(existing = existing)
|
||||
|
||||
assertEquals(6_000L, restored.timestamp)
|
||||
assertEquals(88, restored.lastPageIndex)
|
||||
assertEquals(44f, restored.progressPercentage)
|
||||
assertEquals(88, restored.pdfReaderViewport?.pageIndex)
|
||||
assertEquals(4_000L, restored.readingPositionModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf metadata upload ignores stale text locator page`() {
|
||||
val book = BookItem(
|
||||
id = "book-1",
|
||||
path = "C:/books/Book.pdf",
|
||||
type = FileType.PDF,
|
||||
displayName = "Book.pdf",
|
||||
timestamp = 1_000L,
|
||||
lastPageIndex = 264,
|
||||
progressPercentage = 33.125f,
|
||||
readerPosition = ReaderLocator(pageIndex = 69)
|
||||
)
|
||||
|
||||
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false)
|
||||
|
||||
assertEquals(264, metadata.lastPage)
|
||||
assertNull(metadata.lastPositionCfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comic metadata upload ignores stale text locator page`() {
|
||||
val book = BookItem(
|
||||
id = "book-1",
|
||||
path = "C:/books/Book.cbt",
|
||||
type = FileType.CBT,
|
||||
displayName = "Book.cbt",
|
||||
timestamp = 1_000L,
|
||||
lastPageIndex = 42,
|
||||
progressPercentage = 33.125f,
|
||||
readerPosition = ReaderLocator(pageIndex = 12)
|
||||
)
|
||||
|
||||
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false)
|
||||
|
||||
assertEquals(42, metadata.lastPage)
|
||||
assertNull(metadata.lastPositionCfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote metadata without annotation json preserves existing desktop annotations`() {
|
||||
val existingBookmark = ReaderBookmark(
|
||||
|
|
@ -127,4 +340,52 @@ class DesktopCloudSyncMappingTest {
|
|||
assertEquals(listOf(existingHighlight), restored.readerHighlights)
|
||||
assertEquals(existing.path, restored.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf bookmarks map to android metadata json`() {
|
||||
val metadataJson = desktopPdfBookmarksMetadataJson(
|
||||
bookmarks = listOf(
|
||||
SharedPdfBookmark(
|
||||
pageIndex = 3,
|
||||
label = "Important page",
|
||||
createdAt = 1_234L
|
||||
)
|
||||
),
|
||||
lastPageIndex = 9
|
||||
)
|
||||
|
||||
val restored = desktopPdfBookmarksFromMetadataJson(metadataJson)
|
||||
|
||||
assertTrue(metadataJson.contains("\"pageIndex\""))
|
||||
assertTrue(metadataJson.contains("\"title\""))
|
||||
assertTrue(metadataJson.contains("\"totalPages\""))
|
||||
assertEquals(1, restored.size)
|
||||
assertEquals(3, restored.single().pageIndex)
|
||||
assertEquals("Important page", restored.single().label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `android pdf bookmark metadata keeps titles on desktop`() {
|
||||
val restored = desktopPdfBookmarksFromMetadataJson(
|
||||
"""[{"pageIndex":2,"title":"Android bookmark","totalPages":8}]"""
|
||||
)
|
||||
|
||||
assertEquals(1, restored.size)
|
||||
assertEquals(2, restored.single().pageIndex)
|
||||
assertEquals("Android bookmark", restored.single().label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty desktop pdf annotations are not exported as cloud annotation data`() {
|
||||
val emptyAnnotationsJson = SharedPdfAnnotationSerializer.encode(emptyList())
|
||||
|
||||
assertNull(desktopPdfAnnotationElementForSync(emptyAnnotationsJson))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty desktop pdf rich text is not exported as cloud annotation data`() {
|
||||
val emptyRichTextJson = SharedPdfRichTextSerializer.encode(SharedPdfRichDocument())
|
||||
|
||||
assertNull(desktopPdfRichTextElementForSync(emptyRichTextJson))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.Base64
|
||||
|
|
@ -35,11 +37,65 @@ class DesktopComicArchiveTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing stale comic document does not close replacement with same path`() = withTempDir { dir ->
|
||||
val cbz = File(dir, "comic.cbz")
|
||||
ZipOutputStream(cbz.outputStream()).use { zip ->
|
||||
zip.putNextEntry(ZipEntry("pages/001.png"))
|
||||
zip.write(onePixelPngBytes())
|
||||
zip.closeEntry()
|
||||
}
|
||||
|
||||
val staleDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ)
|
||||
val activeDocument = DesktopPdfium.loadComic(cbz, FileType.CBZ)
|
||||
try {
|
||||
staleDocument.close()
|
||||
|
||||
val image = DesktopPdfium.renderPageBufferedImage(activeDocument, pageIndex = 0, scale = 4f)
|
||||
|
||||
assertEquals(4, image.width)
|
||||
assertEquals(4, image.height)
|
||||
} finally {
|
||||
staleDocument.close()
|
||||
activeDocument.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cbt archive loads image pages for pdf reader surface`() = withTempDir { dir ->
|
||||
val cbt = File(dir, "comic.cbt")
|
||||
TarArchiveOutputStream(cbt.outputStream()).use { tar ->
|
||||
val bytes = onePixelPngBytes()
|
||||
val entry = TarArchiveEntry("pages/001.png").apply {
|
||||
size = bytes.size.toLong()
|
||||
}
|
||||
tar.putArchiveEntry(entry)
|
||||
tar.write(bytes)
|
||||
tar.closeArchiveEntry()
|
||||
tar.finish()
|
||||
}
|
||||
|
||||
val document = DesktopPdfium.loadComic(cbt, FileType.CBT)
|
||||
try {
|
||||
assertEquals(1, document.pageCount)
|
||||
assertEquals(1f, document.pageSizes.single().width)
|
||||
assertEquals(1f, document.pageSizes.single().height)
|
||||
|
||||
val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 8f)
|
||||
|
||||
assertEquals(8, image.width)
|
||||
assertEquals(8, image.height)
|
||||
} finally {
|
||||
document.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop comic types are routed through shared reader capability map`() {
|
||||
assertTrue(DesktopComicArchive.canLoad(FileType.CBZ))
|
||||
assertTrue(DesktopComicArchive.canLoad(FileType.CBR))
|
||||
assertTrue(DesktopComicArchive.canLoad(FileType.CB7))
|
||||
assertTrue(DesktopComicArchive.canLoad(FileType.CBT))
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ class DesktopComposeInteropTest {
|
|||
@Test
|
||||
fun `desktop enables Compose interop blending before app startup`() {
|
||||
withSystemProperty(ComposeInteropBlendingProperty, null) {
|
||||
configureComposeSwingInterop()
|
||||
configureComposeSwingInterop(nonNativeWebViewPlatform)
|
||||
|
||||
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ class DesktopComposeInteropTest {
|
|||
@Test
|
||||
fun `desktop treats blank Compose interop blending value as unset`() {
|
||||
withSystemProperty(ComposeInteropBlendingProperty, " ") {
|
||||
configureComposeSwingInterop()
|
||||
configureComposeSwingInterop(nonNativeWebViewPlatform)
|
||||
|
||||
assertEquals(ComposeInteropBlendingEnabled, System.getProperty(ComposeInteropBlendingProperty))
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ class DesktopComposeInteropTest {
|
|||
@Test
|
||||
fun `desktop preserves explicit Compose interop blending override`() {
|
||||
withSystemProperty(ComposeInteropBlendingProperty, "false") {
|
||||
configureComposeSwingInterop()
|
||||
configureComposeSwingInterop(nonNativeWebViewPlatform)
|
||||
|
||||
assertEquals("false", System.getProperty(ComposeInteropBlendingProperty))
|
||||
}
|
||||
|
|
@ -52,4 +52,11 @@ class DesktopComposeInteropTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val nonNativeWebViewPlatform = DesktopPlatform(
|
||||
os = DesktopOperatingSystem.OTHER,
|
||||
architecture = DesktopArchitecture.X64
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopEpubBridgeParsingTest {
|
||||
@Test
|
||||
fun `reader position bridge keeps semantic locator fields`() {
|
||||
val position = """
|
||||
{
|
||||
"pageIndex": 12,
|
||||
"chapterIndex": 2,
|
||||
"chapterId": "chap-2",
|
||||
"href": "text/chapter2.xhtml",
|
||||
"startOffset": 140,
|
||||
"endOffset": 140,
|
||||
"blockIndex": 9,
|
||||
"charOffset": 140,
|
||||
"textQuote": "quoted text",
|
||||
"cfi": "desktop-scroll:10:100:/4/2:3"
|
||||
}
|
||||
""".trimIndent().readerPositionOrNull()
|
||||
|
||||
assertEquals(12, position?.pageIndex)
|
||||
assertEquals(2, position?.locator?.chapterIndex)
|
||||
assertEquals("chap-2", position?.locator?.chapterId)
|
||||
assertEquals("text/chapter2.xhtml", position?.locator?.href)
|
||||
assertEquals(9, position?.locator?.blockIndex)
|
||||
assertEquals(140, position?.locator?.charOffset)
|
||||
assertEquals("quoted text", position?.locator?.textQuote)
|
||||
assertEquals("/4/2:3", position?.locator?.cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locator json sent to web view includes semantic position fields`() {
|
||||
val json = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
chapterId = "chap-2",
|
||||
href = "text/chapter2.xhtml",
|
||||
pageIndex = 12,
|
||||
startOffset = 140,
|
||||
endOffset = 155,
|
||||
blockIndex = 9,
|
||||
charOffset = 140,
|
||||
textQuote = "quoted text",
|
||||
cfi = "/4/2:3"
|
||||
).toReaderLocatorJson()
|
||||
|
||||
assertTrue(json.contains("\"chapterId\":\"chap-2\""))
|
||||
assertTrue(json.contains("\"href\":\"text/chapter2.xhtml\""))
|
||||
assertTrue(json.contains("\"blockIndex\":9"))
|
||||
assertTrue(json.contains("\"charOffset\":140"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection action bridge keeps locator fields for selected tts`() {
|
||||
val payload = """
|
||||
{
|
||||
"action": "speak",
|
||||
"text": "selected text",
|
||||
"locator": {
|
||||
"chapterIndex": 3,
|
||||
"chapterId": "chap-3",
|
||||
"href": "text/chapter3.xhtml",
|
||||
"pageIndex": 41,
|
||||
"startOffset": 900,
|
||||
"endOffset": 913,
|
||||
"blockIndex": 7,
|
||||
"charOffset": 900,
|
||||
"textQuote": "selected text",
|
||||
"cfi": "desktop-scroll:10:20:/4/8:12|/4/8:25"
|
||||
}
|
||||
}
|
||||
""".trimIndent().readerSelectionActionOrNull()
|
||||
|
||||
assertEquals(DesktopReaderSelectionAction.SPEAK, payload?.action)
|
||||
assertEquals("selected text", payload?.text)
|
||||
assertEquals(3, payload?.locator?.chapterIndex)
|
||||
assertEquals("chap-3", payload?.locator?.chapterId)
|
||||
assertEquals("text/chapter3.xhtml", payload?.locator?.href)
|
||||
assertEquals(41, payload?.locator?.pageIndex)
|
||||
assertEquals(900, payload?.locator?.startOffset)
|
||||
assertEquals(913, payload?.locator?.endOffset)
|
||||
assertEquals(7, payload?.locator?.blockIndex)
|
||||
assertEquals(900, payload?.locator?.charOffset)
|
||||
assertEquals("selected text", payload?.locator?.textQuote)
|
||||
assertEquals("/4/8:12|/4/8:25", payload?.locator?.cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection action bridge parses highlight palette manager action`() {
|
||||
val payload = """
|
||||
{
|
||||
"action": "palette",
|
||||
"text": "selected text"
|
||||
}
|
||||
""".trimIndent().readerSelectionActionOrNull()
|
||||
|
||||
assertEquals(DesktopReaderSelectionAction.PALETTE, payload?.action)
|
||||
assertEquals("selected text", payload?.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop epub chrome tap script keeps click fallback for pointer-capable webviews`() {
|
||||
assertTrue(DesktopEpubKeyNavigationScript.contains("var lastChromeTapNotifiedAt = 0;"))
|
||||
assertTrue(DesktopEpubKeyNavigationScript.contains("function maybeNotifyChromeTapFromClick(event)"))
|
||||
assertTrue(DesktopEpubKeyNavigationScript.contains("if (window.PointerEvent) {"))
|
||||
assertTrue(DesktopEpubKeyNavigationScript.contains("maybeNotifyChromeTapFromClick(event);"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.ReaderViewportSpec
|
||||
import com.aryan.reader.shared.reader.layoutSignature
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopEpubPaginationTest {
|
||||
@Test
|
||||
fun `measured pagination is not ready until measured pages are applied`() {
|
||||
val request = desktopPaginationRequest()
|
||||
val currentPages = listOf(readerPage(text = "old page"))
|
||||
val measuredPages = listOf(readerPage(text = "measured page"))
|
||||
|
||||
assertFalse(
|
||||
desktopMeasuredPaginationReady(
|
||||
request = request,
|
||||
completedRequest = request,
|
||||
currentPages = currentPages,
|
||||
measuredPages = measuredPages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measured pagination is ready when current pages match measured pages`() {
|
||||
val request = desktopPaginationRequest()
|
||||
val measuredPages = listOf(readerPage(text = "measured page"))
|
||||
|
||||
assertTrue(
|
||||
desktopMeasuredPaginationReady(
|
||||
request = request,
|
||||
completedRequest = request,
|
||||
currentPages = measuredPages,
|
||||
measuredPages = measuredPages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated display waits for completed measured pages`() {
|
||||
assertFalse(
|
||||
desktopPaginatedLayoutReadyForDisplay(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
measuredPagesApplied = false
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
desktopPaginatedLayoutReadyForDisplay(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
measuredPagesApplied = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
desktopPaginatedLayoutReadyForDisplay(
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
measuredPagesApplied = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `measured chapter warm start replaces only that chapter and renumbers pages`() {
|
||||
val currentPages = listOf(
|
||||
readerPage(text = "chapter 0 page", chapterIndex = 0, pageIndex = 0),
|
||||
readerPage(text = "chapter 1 old a", chapterIndex = 1, pageIndex = 1),
|
||||
readerPage(text = "chapter 1 old b", chapterIndex = 1, pageIndex = 2),
|
||||
readerPage(text = "chapter 2 page", chapterIndex = 2, pageIndex = 3)
|
||||
)
|
||||
val measuredChapter = listOf(
|
||||
readerPage(text = "chapter 1 measured", chapterIndex = 1, pageIndex = 1)
|
||||
)
|
||||
|
||||
val pages = desktopPagesWithMeasuredChapter(
|
||||
currentPages = currentPages,
|
||||
chapterIndex = 1,
|
||||
measuredChapterPages = measuredChapter
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 1, 2), pages.map { it.pageIndex })
|
||||
assertEquals(listOf(0, 1, 2), pages.map { it.chapterIndex })
|
||||
assertEquals("chapter 1 measured", pages[1].text)
|
||||
}
|
||||
|
||||
private fun desktopPaginationRequest(): DesktopEpubPaginationRequest {
|
||||
return DesktopEpubPaginationRequest(
|
||||
bookId = "book",
|
||||
chapterSignature = 1,
|
||||
layoutSignature = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.SINGLE
|
||||
).layoutSignature(),
|
||||
viewport = ReaderViewportSpec(widthPx = 1200, heightPx = 900),
|
||||
density = DesktopEpubPaginationDensity(density = 1f, fontScale = 1f),
|
||||
cacheGeneration = 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun readerPage(
|
||||
text: String,
|
||||
chapterIndex: Int = 0,
|
||||
pageIndex: Int = 0
|
||||
): ReaderPage {
|
||||
return ReaderPage(
|
||||
pageIndex = pageIndex,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = "Chapter",
|
||||
text = text,
|
||||
startOffset = 0,
|
||||
endOffset = text.length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopFeatureNoticePlacementTest {
|
||||
@Test
|
||||
fun `main notice renders only in the main window`() {
|
||||
val placement = desktopFeatureNoticePlacement(readerWindowId = null)
|
||||
|
||||
assertTrue(placement.rendersInMainWindow())
|
||||
assertFalse(placement.rendersInReaderWindow("reader-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader notice renders only in the matching reader window`() {
|
||||
val placement = desktopFeatureNoticePlacement(readerWindowId = "reader-1")
|
||||
|
||||
assertFalse(placement.rendersInMainWindow())
|
||||
assertTrue(placement.rendersInReaderWindow("reader-1"))
|
||||
assertFalse(placement.rendersInReaderWindow("reader-2"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.SharedLibrarySnapshot
|
||||
import com.aryan.reader.shared.SharedLibrarySnapshotJson
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopLibraryDatabaseTest {
|
||||
@Test
|
||||
fun `save writes readable library and backup snapshots`() {
|
||||
val databaseFile = Files.createTempDirectory("reader-library-db")
|
||||
.resolve("library.json")
|
||||
.toFile()
|
||||
val database = DesktopLibraryDatabase(databaseFile)
|
||||
val snapshot = SharedLibrarySnapshot(
|
||||
recentFilesLimit = 37,
|
||||
openTabIds = listOf("book-a"),
|
||||
activeTabBookId = "book-a"
|
||||
)
|
||||
|
||||
database.save(snapshot)
|
||||
|
||||
val loaded = database.load()
|
||||
assertEquals(37, loaded.recentFilesLimit)
|
||||
assertEquals(listOf("book-a"), loaded.openTabIds)
|
||||
assertEquals("book-a", loaded.activeTabBookId)
|
||||
assertTrue(databaseFile.isFile)
|
||||
assertTrue(databaseFile.parentFile.resolve("library.json.bak").isFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load falls back to backup when primary library is corrupt`() {
|
||||
val databaseFile = Files.createTempDirectory("reader-library-db-corrupt")
|
||||
.resolve("library.json")
|
||||
.toFile()
|
||||
val backupSnapshot = SharedLibrarySnapshot(
|
||||
recentFilesLimit = 19,
|
||||
openTabIds = listOf("backup-book"),
|
||||
activeTabBookId = "backup-book"
|
||||
)
|
||||
databaseFile.parentFile.mkdirs()
|
||||
databaseFile.writeText("""{"books":[""")
|
||||
databaseFile.parentFile
|
||||
.resolve("library.json.bak")
|
||||
.writeText(SharedLibrarySnapshotJson.encode(backupSnapshot))
|
||||
|
||||
val loaded = DesktopLibraryDatabase(databaseFile).load()
|
||||
|
||||
assertEquals(19, loaded.recentFilesLimit)
|
||||
assertEquals(listOf("backup-book"), loaded.openTabIds)
|
||||
assertEquals("backup-book", loaded.activeTabBookId)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,37 @@ import java.nio.file.Files
|
|||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopLocalFolderSyncTest {
|
||||
@Test
|
||||
fun `target folder sync imports files before desktop metadata extraction`() {
|
||||
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
|
||||
try {
|
||||
val bookFile = File(root, "Notes.txt").apply { writeText("Notes") }
|
||||
|
||||
val result = DesktopLocalFolderSync.sync(
|
||||
state = SharedReaderScreenState(),
|
||||
shelfRefs = emptyList(),
|
||||
targetFolder = root,
|
||||
nowMillis = 3_000L,
|
||||
extractMetadata = false
|
||||
)
|
||||
|
||||
val syncedBook = result.state.rawLibraryBooks.single()
|
||||
assertEquals("local_Notes.txt", syncedBook.id)
|
||||
assertEquals(bookFile.absolutePath, syncedBook.path)
|
||||
assertEquals(root.absolutePath, syncedBook.sourceFolder)
|
||||
assertEquals(listOf(root.absolutePath), result.processedFolderUris)
|
||||
assertEquals(1, result.state.syncedFolders.size)
|
||||
assertEquals(1, result.stats.newBooks)
|
||||
assertEquals(0, result.metadataStats.updatedBooks)
|
||||
assertNull(syncedBook.coverImagePath)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata-only sync imports sidecar metadata without scanning physical files`() {
|
||||
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
|
||||
|
|
@ -61,6 +90,40 @@ class DesktopLocalFolderSyncTest {
|
|||
assertEquals(0, result.stats.newBooks)
|
||||
assertEquals(0, result.stats.removedBooks)
|
||||
assertEquals(1, result.stats.remoteMetadataUpdates)
|
||||
assertTrue(result.processedFolderUris.contains(root.absolutePath))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled folder is not scanned or written`() {
|
||||
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
|
||||
try {
|
||||
File(root, "Notes.txt").writeText("Notes")
|
||||
val existingBook = BookItem(
|
||||
id = "local_Existing.pdf",
|
||||
path = File(root, "Existing.pdf").absolutePath,
|
||||
type = FileType.PDF,
|
||||
displayName = "Existing.pdf",
|
||||
timestamp = 100L,
|
||||
progressPercentage = 50f,
|
||||
sourceFolder = root.absolutePath
|
||||
)
|
||||
|
||||
val result = DesktopLocalFolderSync.sync(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(existingBook),
|
||||
syncedFolders = listOf(syncedFolder(root).copy(localSyncEnabled = false))
|
||||
),
|
||||
shelfRefs = emptyList(),
|
||||
nowMillis = 3_000L
|
||||
)
|
||||
|
||||
assertEquals(listOf(existingBook), result.state.rawLibraryBooks)
|
||||
assertTrue(result.processedFolderUris.isEmpty())
|
||||
assertEquals(0, result.stats.newBooks)
|
||||
assertTrue(!File(root, LOCAL_FOLDER_SYNC_DATA_DIR).exists())
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopPaidAiUsageTest {
|
||||
@Test
|
||||
fun `desktop paid AI usage applies an optimistic integer credit decrement`() {
|
||||
assertEquals(9, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 1.0))
|
||||
assertEquals(7, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 2.2))
|
||||
assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = 0.0))
|
||||
assertEquals(10, desktopCreditsAfterPaidAiUsage(currentCredits = 10, cost = null))
|
||||
assertEquals(0, desktopCreditsAfterPaidAiUsage(currentCredits = 1, cost = 4.0))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopPdfNavigationSidebarTest {
|
||||
@Test
|
||||
fun `sidebar highlights exclude ink and text annotations`() {
|
||||
val result = desktopPdfSidebarHighlights(
|
||||
listOf(
|
||||
annotation(id = "ink", pageIndex = 0, kind = PdfAnnotationKind.INK, createdAt = 1L),
|
||||
annotation(id = "later-highlight", pageIndex = 2, kind = PdfAnnotationKind.HIGHLIGHT, createdAt = 4L),
|
||||
annotation(id = "text", pageIndex = 1, kind = PdfAnnotationKind.TEXT, createdAt = 1L),
|
||||
annotation(
|
||||
id = "first-same-page-highlight",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
createdAt = 3L
|
||||
),
|
||||
annotation(
|
||||
id = "second-same-page-highlight",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
createdAt = 2L
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("first-same-page-highlight", "second-same-page-highlight", "later-highlight"),
|
||||
result.map { it.id }
|
||||
)
|
||||
assertTrue(result.all { it.kind == PdfAnnotationKind.HIGHLIGHT })
|
||||
}
|
||||
|
||||
private fun annotation(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
kind: PdfAnnotationKind,
|
||||
createdAt: Long
|
||||
): SharedPdfAnnotation {
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = kind,
|
||||
tool = if (kind == PdfAnnotationKind.TEXT) PdfInkTool.TEXT else PdfInkTool.HIGHLIGHTER,
|
||||
text = "selected text",
|
||||
colorArgb = 0x55FFEB3B,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ 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 com.aryan.reader.shared.ui.toNonReaderLibraryOrganizationModel
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
|
@ -85,6 +86,7 @@ class DesktopPdfReflowTest {
|
|||
assertEquals(listOf(source.id), projected.libraryBooks.map { it.id })
|
||||
assertTrue(projected.rawLibraryBooks.any { it.id == reflow.id })
|
||||
assertTrue(projected.recentBooks.none { it.id == reflow.id })
|
||||
assertEquals(1, projected.toNonReaderLibraryOrganizationModel().allBooksCount)
|
||||
assertEquals(listOf(reflow.id), projected.openTabIds)
|
||||
assertEquals(reflow.id, projected.activeTabBookId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopPdfScrubbingTest {
|
||||
@Test
|
||||
fun `scrub target clamps to valid page range`() {
|
||||
val settings = ReaderSettings()
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
desktopPdfPageScrubTarget(
|
||||
value = -10f,
|
||||
pageCount = 6,
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
settings = settings
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
5,
|
||||
desktopPdfPageScrubTarget(
|
||||
value = 99f,
|
||||
pageCount = 6,
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
settings = settings
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated scrub target normalizes to spread start`() {
|
||||
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
desktopPdfPageScrubTarget(
|
||||
value = 3f,
|
||||
pageCount = 8,
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
settings = settings
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrub commit prefers preview before page state catches up`() {
|
||||
assertEquals(
|
||||
7,
|
||||
desktopPdfPageScrubCommitTarget(
|
||||
previewPage = 7,
|
||||
currentPage = 2,
|
||||
pageCount = 10
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopPdfSidecarsTest {
|
||||
@Test
|
||||
fun `pdf sidecar keys avoid String hashCode collisions`() {
|
||||
val first = desktopPdfDocumentKey("C:/Books/Aa.pdf")
|
||||
val second = desktopPdfDocumentKey("C:/Books/BB.pdf")
|
||||
|
||||
assertTrue("C:/Books/Aa.pdf".hashCode() == "C:/Books/BB.pdf".hashCode())
|
||||
assertNotEquals(first, second)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderAction
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderState
|
||||
import com.aryan.reader.shared.pdf.reduce
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopPdfTextHighlightStateTest {
|
||||
@Test
|
||||
fun `text selection highlight keeps chosen text selection mode after creation`() {
|
||||
val annotation = textSelectionHighlight()
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
|
||||
|
||||
val next = state.withDesktopPdfTextSelectionHighlightAdded(annotation)
|
||||
|
||||
assertEquals(listOf(annotation), next.annotations)
|
||||
assertTrue(next.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.NONE, next.selectedTool)
|
||||
assertNull(next.selectedAnnotationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing selected text highlight sheet keeps chosen text selection mode`() {
|
||||
val annotation = textSelectionHighlight()
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
|
||||
.copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id)
|
||||
|
||||
val next = state.withDesktopPdfTextHighlightSheetDismissed()
|
||||
|
||||
assertTrue(next.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.NONE, next.selectedTool)
|
||||
assertNull(next.selectedAnnotationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing non text highlight annotation keeps text selection mode unchanged`() {
|
||||
val annotation = textSelectionHighlight().copy(rangeStartIndex = null, rangeEndIndex = null)
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
|
||||
.copy(annotations = listOf(annotation), selectedAnnotationId = annotation.id)
|
||||
|
||||
val next = state.withDesktopPdfTextHighlightSheetDismissed()
|
||||
|
||||
assertTrue(next.isTextSelectionMode)
|
||||
assertNull(next.selectedAnnotationId)
|
||||
}
|
||||
|
||||
private fun textSelectionHighlight(): SharedPdfAnnotation {
|
||||
return SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
text = "selected text",
|
||||
colorArgb = 0x55FFEB3B,
|
||||
rangeStartIndex = 1,
|
||||
rangeEndIndex = 12,
|
||||
createdAt = 1L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,10 @@ import kotlin.test.assertEquals
|
|||
|
||||
class DesktopPdfThemeTest {
|
||||
@Test
|
||||
fun `desktop pdf defaults to vertical display mode`() {
|
||||
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, DesktopDefaultPdfDisplayMode)
|
||||
fun `desktop pdf defaults to paginated display mode`() {
|
||||
assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode)
|
||||
assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap)
|
||||
assertEquals(18.dp, DesktopDefaultPdfSpreadPageGap)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -49,4 +50,35 @@ class DesktopPdfThemeTest {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pagination viewport uses app theme color outside pages`() {
|
||||
val pageBackground = Color.Black
|
||||
val appBackground = Color(0xFFE2E2E2)
|
||||
|
||||
assertEquals(
|
||||
appBackground,
|
||||
desktopPdfViewportBackgroundColor(
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
pageBackgroundColor = pageBackground,
|
||||
appBackgroundColor = appBackground,
|
||||
isVerticalPageGapVisible = false
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
appBackground,
|
||||
desktopPdfViewportBackgroundColor(
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
pageBackgroundColor = pageBackground,
|
||||
appBackgroundColor = appBackground,
|
||||
isVerticalPageGapVisible = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread page gap follows pdf page gap visibility setting`() {
|
||||
assertEquals(18.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = true))
|
||||
assertEquals(0.dp, desktopPdfSpreadPageGapDp(isPageGapVisible = false))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ class DesktopPlatformPathsTest {
|
|||
|
||||
assertEquals(DesktopOperatingSystem.LINUX, platform.os)
|
||||
assertEquals(DesktopArchitecture.X64, platform.architecture)
|
||||
assertEquals("kcef-bundle-linux-x64", platform.kcefBundleDirectoryName)
|
||||
assertEquals("linux-x64-v8", platform.pdfiumDirectoryName)
|
||||
assertEquals("lib", platform.pdfiumLibraryDirectoryName)
|
||||
assertEquals("libpdfium.so", platform.pdfiumLibraryFileName)
|
||||
|
|
@ -22,7 +21,6 @@ class DesktopPlatformPathsTest {
|
|||
|
||||
assertEquals(DesktopOperatingSystem.WINDOWS, platform.os)
|
||||
assertEquals(DesktopArchitecture.X64, platform.architecture)
|
||||
assertEquals("kcef-bundle", platform.kcefBundleDirectoryName)
|
||||
assertEquals("win-x64-v8", platform.pdfiumDirectoryName)
|
||||
assertEquals("bin", platform.pdfiumLibraryDirectoryName)
|
||||
assertEquals("pdfium.dll", platform.pdfiumLibraryFileName)
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ import androidx.compose.ui.unit.IntOffset
|
|||
import androidx.compose.ui.unit.IntSize
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedLibrarySnapshot
|
||||
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopReaderDefaultsTest {
|
||||
|
|
@ -42,6 +46,96 @@ class DesktopReaderDefaultsTest {
|
|||
assertEquals(local, resolvedDesktopReaderSettings(book, defaults))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library defaults migrate untouched reader defaults to two page pagination`() {
|
||||
val migrated = SharedLibrarySnapshot().withDesktopDefaults()
|
||||
|
||||
assertEquals(DesktopReaderDefaultsVersion, migrated.desktopReaderDefaultsVersion)
|
||||
assertEquals(ReaderReadingMode.PAGINATED, migrated.readerDefaultSettings.readingMode)
|
||||
assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.readerDefaultSettings.pageSpreadMode)
|
||||
assertEquals(ReaderReadingMode.PAGINATED, migrated.pdfReaderDefaultSettings.readingMode)
|
||||
assertEquals(ReaderPageSpreadMode.TWO_PAGE, migrated.pdfReaderDefaultSettings.pageSpreadMode)
|
||||
assertEquals("no_theme", migrated.pdfReaderDefaultSettings.themeId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop reader settings engines are separated by shared reader surface`() {
|
||||
assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.EPUB.desktopReaderSettingsEngine())
|
||||
assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.MOBI.desktopReaderSettingsEngine())
|
||||
assertEquals(DesktopReaderSettingsEngine.TEXT, FileType.DOCX.desktopReaderSettingsEngine())
|
||||
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PDF.desktopReaderSettingsEngine())
|
||||
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBZ.desktopReaderSettingsEngine())
|
||||
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.CBT.desktopReaderSettingsEngine())
|
||||
assertEquals(DesktopReaderSettingsEngine.PDF, FileType.PPTX.desktopReaderSettingsEngine())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop engine settings update only matching reader family books`() {
|
||||
val textSettings = ReaderSettings(themeId = "sepia", readingMode = ReaderReadingMode.PAGINATED)
|
||||
val pdfSettings = ReaderSettings(themeId = "reverse", readingMode = ReaderReadingMode.PAGINATED)
|
||||
val books = listOf(
|
||||
bookItem("epub"),
|
||||
bookItem("mobi").copy(path = "C:/Books/mobi.mobi", type = FileType.MOBI, displayName = "mobi.mobi"),
|
||||
bookItem("pdf").copy(path = "C:/Books/pdf.pdf", type = FileType.PDF, displayName = "pdf.pdf")
|
||||
)
|
||||
|
||||
val withTextDefaults = books.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.TEXT, textSettings)
|
||||
assertEquals(textSettings, withTextDefaults[0].readerSettings)
|
||||
assertEquals(textSettings, withTextDefaults[1].readerSettings)
|
||||
assertEquals(null, withTextDefaults[2].readerSettings)
|
||||
|
||||
val withPdfDefaults = withTextDefaults.withDesktopReaderEngineSettings(DesktopReaderSettingsEngine.PDF, pdfSettings)
|
||||
assertEquals(textSettings, withPdfDefaults[0].readerSettings)
|
||||
assertEquals(textSettings, withPdfDefaults[1].readerSettings)
|
||||
assertEquals(pdfSettings, withPdfDefaults[2].readerSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf display mode is carried by pdf reader settings`() {
|
||||
assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfDisplayMode)
|
||||
assertEquals(PdfDisplayMode.PAGINATION, DesktopDefaultPdfReaderSettings.toDesktopPdfDisplayMode())
|
||||
assertEquals(
|
||||
PdfDisplayMode.VERTICAL_SCROLL,
|
||||
ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).toDesktopPdfDisplayMode()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf initial page is normalized before paginated spread display`() {
|
||||
val spreadSettings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
desktopPdfInitialPageIndex(
|
||||
requestedPageIndex = 3,
|
||||
pageCount = 10,
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
settings = spreadSettings
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
desktopPdfInitialPageIndex(
|
||||
requestedPageIndex = 3,
|
||||
pageCount = 10,
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
settings = spreadSettings
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
desktopPdfInitialPageIndex(
|
||||
requestedPageIndex = 3,
|
||||
pageCount = 10,
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
settings = spreadSettings.copy(pageSpreadMode = ReaderPageSpreadMode.SINGLE)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf zoom allows deeper page magnification`() {
|
||||
val sharedDefaultMax = PdfZoomSpec().max
|
||||
|
|
@ -65,6 +159,26 @@ class DesktopReaderDefaultsTest {
|
|||
assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf page navigation commits pending zoom preview position`() {
|
||||
val preview = DesktopPdfZoomPreview(
|
||||
baseZoom = 1f,
|
||||
zoom = 2f,
|
||||
anchor = Offset(100f, 80f),
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
pageIndex = 0
|
||||
)
|
||||
val snapshot = desktopPdfNavigationZoomSnapshot(
|
||||
preview = preview,
|
||||
currentHorizontalScroll = 40,
|
||||
currentVerticalScroll = 20
|
||||
) ?: error("Expected navigation zoom snapshot")
|
||||
|
||||
assertEquals(2f, snapshot.zoom)
|
||||
assertEquals(180, snapshot.horizontalScroll)
|
||||
assertEquals(120, snapshot.verticalScroll)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop paginated pdf page changes avoid high resolution first render`() {
|
||||
assertEquals(
|
||||
|
|
@ -93,6 +207,247 @@ class DesktopReaderDefaultsTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf only displays renders for the requested page`() {
|
||||
assertTrue(desktopPdfRenderBelongsToPage(renderedPageIndex = 0, requestedPageIndex = 0))
|
||||
assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = null, requestedPageIndex = 0))
|
||||
assertFalse(desktopPdfRenderBelongsToPage(renderedPageIndex = 1, requestedPageIndex = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf render scale rerenders only for missing or lower quality renders`() {
|
||||
assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = null, requestedScale = 1f))
|
||||
assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.02f))
|
||||
assertTrue(desktopPdfRenderScaleNeedsUpgrade(renderedScale = Float.NaN, requestedScale = 1f))
|
||||
assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = 1.005f))
|
||||
assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 2f, requestedScale = 1f))
|
||||
assertFalse(desktopPdfRenderScaleNeedsUpgrade(renderedScale = 1f, requestedScale = Float.NaN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf spread zoom anchors to page under cursor`() {
|
||||
val visiblePages = listOf(199, 200)
|
||||
val pageRoots = mapOf(
|
||||
199 to Offset(424f, 30f),
|
||||
200 to Offset(972f, 30f)
|
||||
)
|
||||
val pageSizes = mapOf(
|
||||
199 to IntSize(525, 693),
|
||||
200 to IntSize(525, 693)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
200,
|
||||
desktopPdfSpreadZoomAnchorPageIndex(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
anchor = Offset(1048.75f, 465f),
|
||||
visiblePageIndices = visiblePages,
|
||||
pageRootOffsets = pageRoots,
|
||||
pageSizes = pageSizes,
|
||||
fallbackPageIndex = 199
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
199,
|
||||
desktopPdfSpreadZoomAnchorPageIndex(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
anchor = Offset(500f, 465f),
|
||||
visiblePageIndices = visiblePages,
|
||||
pageRootOffsets = pageRoots,
|
||||
pageSizes = pageSizes,
|
||||
fallbackPageIndex = 199
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
199,
|
||||
desktopPdfSpreadZoomAnchorPageIndex(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
anchor = null,
|
||||
visiblePageIndices = visiblePages,
|
||||
pageRootOffsets = pageRoots,
|
||||
pageSizes = pageSizes,
|
||||
fallbackPageIndex = 199
|
||||
)
|
||||
)
|
||||
val fittedSpread = desktopPdfSpreadLayoutPrediction(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
viewportSize = IntSize(1920, 991),
|
||||
visiblePageIndices = visiblePages,
|
||||
pageCanvasSizes = mapOf(
|
||||
199 to IntSize(667, 881),
|
||||
200 to IntSize(667, 881)
|
||||
),
|
||||
horizontalScroll = 0,
|
||||
verticalScroll = 112,
|
||||
paddingPx = 30f,
|
||||
pageGapPx = 22.5f
|
||||
) ?: error("Expected fitted spread prediction")
|
||||
assertEquals(0, fittedSpread.maxHorizontalScroll)
|
||||
assertEquals(0, fittedSpread.maxVerticalScroll)
|
||||
assertEquals(282f, fittedSpread.pageRootOffsets[199]?.x ?: -1f, 0.5f)
|
||||
assertEquals(972f, fittedSpread.pageRootOffsets[200]?.x ?: -1f, 0.5f)
|
||||
assertEquals(30f, fittedSpread.pageRootOffsets[200]?.y ?: -1f, 0.0001f)
|
||||
|
||||
val scrollableSpread = desktopPdfSpreadLayoutPrediction(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
viewportSize = IntSize(1920, 991),
|
||||
visiblePageIndices = visiblePages,
|
||||
pageCanvasSizes = mapOf(
|
||||
199 to IntSize(1371, 1810),
|
||||
200 to IntSize(1371, 1810)
|
||||
),
|
||||
horizontalScroll = 696,
|
||||
verticalScroll = 575,
|
||||
paddingPx = 30f,
|
||||
pageGapPx = 22.5f
|
||||
) ?: error("Expected scrollable spread prediction")
|
||||
assertEquals(905, scrollableSpread.maxHorizontalScroll)
|
||||
assertEquals(879, scrollableSpread.maxVerticalScroll)
|
||||
assertEquals(-666f, scrollableSpread.pageRootOffsets[199]?.x ?: 0f, 0.5f)
|
||||
assertEquals(728f, scrollableSpread.pageRootOffsets[200]?.x ?: 0f, 0.5f)
|
||||
assertEquals(-545f, scrollableSpread.pageRootOffsets[200]?.y ?: 0f, 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf zoom preview bridges committed anchored zoom`() {
|
||||
val preview = DesktopPdfZoomPreview(
|
||||
baseZoom = 1f,
|
||||
zoom = 2f,
|
||||
anchor = Offset(100f, 100f),
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
pageIndex = 0,
|
||||
viewportRootOffset = Offset.Zero,
|
||||
pageRootOffset = Offset.Zero
|
||||
)
|
||||
|
||||
assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 1f))
|
||||
assertTrue(desktopPdfZoomPreviewMatchesScale(preview, 2f))
|
||||
assertFalse(desktopPdfZoomPreviewMatchesScale(preview, 1.5f))
|
||||
assertEquals(
|
||||
Offset(-100f, -100f),
|
||||
desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentAnchorPageRootOffset = Offset.Zero,
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f,
|
||||
currentZoom = 2f
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
Offset.Zero,
|
||||
desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentAnchorPageRootOffset = Offset(-100f, -100f),
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f,
|
||||
currentZoom = 2f
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentAnchorPageRootOffset = Offset.Zero,
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f,
|
||||
currentZoom = 1f
|
||||
)
|
||||
)
|
||||
assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 0, requestedDelta = 100))
|
||||
assertEquals(0, desktopPdfReachableScrollDelta(currentScroll = 0, maxScroll = 200, requestedDelta = -40))
|
||||
assertEquals(-40, desktopPdfReachableScrollDelta(currentScroll = 80, maxScroll = 200, requestedDelta = -40))
|
||||
assertEquals(
|
||||
Offset.Zero,
|
||||
desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentAnchorPageRootOffset = Offset.Zero,
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f,
|
||||
currentZoom = 2f,
|
||||
scrollBounds = DesktopPdfZoomScrollBounds(
|
||||
currentHorizontalScroll = 0,
|
||||
maxHorizontalScroll = 0,
|
||||
currentVerticalScroll = 0,
|
||||
maxVerticalScroll = 0
|
||||
)
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
Offset(-50f, -25f),
|
||||
desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentAnchorPageRootOffset = Offset.Zero,
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f,
|
||||
currentZoom = 2f,
|
||||
scrollBounds = DesktopPdfZoomScrollBounds(
|
||||
currentHorizontalScroll = 0,
|
||||
maxHorizontalScroll = 50,
|
||||
currentVerticalScroll = 0,
|
||||
maxVerticalScroll = 25
|
||||
)
|
||||
)
|
||||
)
|
||||
val pendingCommitBounds = desktopPdfZoomScrollBoundsWithCommitTargets(
|
||||
preview = preview.copy(
|
||||
commitTargetHorizontalScroll = 300,
|
||||
commitTargetVerticalScroll = 300
|
||||
),
|
||||
currentHorizontalScroll = 0,
|
||||
maxHorizontalScroll = 0,
|
||||
currentVerticalScroll = 0,
|
||||
maxVerticalScroll = 0
|
||||
)
|
||||
assertEquals(300, pendingCommitBounds.maxHorizontalScroll)
|
||||
assertEquals(300, pendingCommitBounds.maxVerticalScroll)
|
||||
assertEquals(
|
||||
Offset(-100f, -100f),
|
||||
desktopPdfZoomCommitPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentAnchorPageRootOffset = Offset.Zero,
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f,
|
||||
currentZoom = 2f,
|
||||
scrollBounds = pendingCommitBounds
|
||||
)
|
||||
)
|
||||
val fittingPagePrediction = desktopPdfSinglePageLayoutPrediction(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
viewportSize = IntSize(1920, 991),
|
||||
pageCanvasSize = IntSize(1216, 1605),
|
||||
horizontalScroll = 0,
|
||||
verticalScroll = 0,
|
||||
paddingPx = 30f
|
||||
) ?: error("Expected fitting page prediction")
|
||||
assertEquals(Offset(352f, 30f), fittingPagePrediction.rootOffset)
|
||||
assertEquals(0, fittingPagePrediction.maxHorizontalScroll)
|
||||
assertEquals(674, fittingPagePrediction.maxVerticalScroll)
|
||||
|
||||
val oversizedPagePrediction = desktopPdfSinglePageLayoutPrediction(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
viewportSize = IntSize(1920, 991),
|
||||
pageCanvasSize = IntSize(2498, 3298),
|
||||
horizontalScroll = 409,
|
||||
verticalScroll = 1122,
|
||||
paddingPx = 30f
|
||||
) ?: error("Expected oversized page prediction")
|
||||
assertEquals(Offset(-379f, -1092f), oversizedPagePrediction.rootOffset)
|
||||
assertEquals(638, oversizedPagePrediction.maxHorizontalScroll)
|
||||
assertEquals(2367, oversizedPagePrediction.maxVerticalScroll)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf anchored zoom keeps cursor content stable`() {
|
||||
assertEquals(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.awt.Canvas
|
||||
import java.awt.event.KeyEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopReaderKeyCommandsTest {
|
||||
|
||||
@Test
|
||||
fun `ctrl f opens epub reader search`() {
|
||||
assertEquals(
|
||||
DesktopReaderKeyNavigation.SEARCH,
|
||||
awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F')
|
||||
.desktopReaderKeyNavigationOrNull(fullscreen = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub right to left pagination swaps physical arrow navigation`() {
|
||||
assertEquals(
|
||||
DesktopReaderKeyNavigation.PREVIOUS,
|
||||
awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED)
|
||||
.desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true)
|
||||
)
|
||||
assertEquals(
|
||||
DesktopReaderKeyNavigation.NEXT,
|
||||
awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED)
|
||||
.desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true)
|
||||
)
|
||||
assertEquals(
|
||||
DesktopReaderKeyNavigation.NEXT,
|
||||
awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED)
|
||||
.desktopReaderKeyNavigationOrNull(fullscreen = false, rightToLeftPagination = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl f opens pdf reader search while reading`() {
|
||||
assertEquals(
|
||||
DesktopPdfKeyCommand.SEARCH,
|
||||
awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F')
|
||||
.desktopPdfKeyCommandOrNull(fullscreen = false, editingText = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl f opens pdf reader search while text editing`() {
|
||||
assertEquals(
|
||||
DesktopPdfKeyCommand.SEARCH,
|
||||
awtKeyEvent(KeyEvent.VK_F, KeyEvent.CTRL_DOWN_MASK, 'F')
|
||||
.desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf text editing keeps unmodified arrows for the editor`() {
|
||||
assertNull(
|
||||
awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED)
|
||||
.desktopPdfKeyCommandOrNull(fullscreen = false, editingText = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf right to left pagination swaps physical arrow navigation`() {
|
||||
assertEquals(
|
||||
DesktopPdfKeyCommand.PREVIOUS_PAGE,
|
||||
awtKeyEvent(KeyEvent.VK_RIGHT, 0, KeyEvent.CHAR_UNDEFINED)
|
||||
.desktopPdfKeyCommandOrNull(
|
||||
fullscreen = false,
|
||||
editingText = false,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
DesktopPdfKeyCommand.NEXT_PAGE,
|
||||
awtKeyEvent(KeyEvent.VK_LEFT, 0, KeyEvent.CHAR_UNDEFINED)
|
||||
.desktopPdfKeyCommandOrNull(
|
||||
fullscreen = false,
|
||||
editingText = false,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
DesktopPdfKeyCommand.NEXT_PAGE,
|
||||
awtKeyEvent(KeyEvent.VK_PAGE_DOWN, 0, KeyEvent.CHAR_UNDEFINED)
|
||||
.desktopPdfKeyCommandOrNull(
|
||||
fullscreen = false,
|
||||
editingText = false,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader side panels can opt into global key dispatch without enabling popups`() {
|
||||
assertEquals(
|
||||
DesktopReaderModalWindowKind.PANEL,
|
||||
desktopReaderModalWindowKind(
|
||||
windowName = "${DesktopReaderModalWindowNamePrefix}PanelLeft",
|
||||
windowTitle = "Reader Navigation"
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||
activeReaderModalKind = DesktopReaderModalWindowKind.PANEL,
|
||||
allowChromeModalWindows = false,
|
||||
allowPanelModalWindows = true,
|
||||
dispatchWhenOwnerWindowActive = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||
activeReaderModalKind = DesktopReaderModalWindowKind.POPUP,
|
||||
allowChromeModalWindows = true,
|
||||
allowPanelModalWindows = true,
|
||||
dispatchWhenOwnerWindowActive = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader chrome and owner window dispatch remain separately gated`() {
|
||||
assertEquals(
|
||||
DesktopReaderModalWindowKind.CHROME,
|
||||
desktopReaderModalWindowKind(
|
||||
windowName = "${DesktopReaderModalWindowNamePrefix}ChromeTop",
|
||||
windowTitle = "Reader Chrome Top"
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
DesktopReaderModalWindowKind.POPUP,
|
||||
desktopReaderModalWindowKind(
|
||||
windowName = "${DesktopReaderModalWindowNamePrefix}Popup",
|
||||
windowTitle = "Reader Popup"
|
||||
)
|
||||
)
|
||||
assertNull(desktopReaderModalWindowKind(windowName = "", windowTitle = "Episteme"))
|
||||
assertFalse(
|
||||
desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||
activeReaderModalKind = null,
|
||||
allowChromeModalWindows = false,
|
||||
allowPanelModalWindows = true,
|
||||
dispatchWhenOwnerWindowActive = false
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
desktopReaderKeyDispatchAllowedForActiveWindowKind(
|
||||
activeReaderModalKind = DesktopReaderModalWindowKind.CHROME,
|
||||
allowChromeModalWindows = true,
|
||||
allowPanelModalWindows = false,
|
||||
dispatchWhenOwnerWindowActive = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun awtKeyEvent(
|
||||
keyCode: Int,
|
||||
modifiers: Int,
|
||||
keyChar: Char
|
||||
): KeyEvent {
|
||||
return KeyEvent(
|
||||
Canvas(),
|
||||
KeyEvent.KEY_PRESSED,
|
||||
0L,
|
||||
modifiers,
|
||||
keyCode,
|
||||
keyChar
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.paginatedreader.CssStyle
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopReaderTypographyTest {
|
||||
|
||||
@Test
|
||||
fun `same page layout includes semantic styling`() {
|
||||
val plain = pageWith(
|
||||
SemanticParagraph(
|
||||
text = "Styled text",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 0
|
||||
)
|
||||
)
|
||||
val styled = pageWith(
|
||||
SemanticParagraph(
|
||||
text = "Styled text",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(fontSize = 24.sp),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 0
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(listOf(plain).samePageLayoutAs(listOf(styled)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same page layout still matches identical semantic pages`() {
|
||||
val page = pageWith(
|
||||
SemanticParagraph(
|
||||
text = "Styled text",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(fontSize = 24.sp),
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 0
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(listOf(page).samePageLayoutAs(listOf(page.copy())))
|
||||
}
|
||||
|
||||
private fun pageWith(block: SemanticParagraph): ReaderPage {
|
||||
return ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = block.text,
|
||||
startOffset = 0,
|
||||
endOffset = block.text.length,
|
||||
semanticBlocks = listOf(block)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,23 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.ui.SharedAppTab
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopReaderWindowStateTest {
|
||||
|
||||
@Test
|
||||
fun `desktop starts on library instead of home`() {
|
||||
assertEquals(SharedAppTab.LIBRARY, DesktopInitialAppTab)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opening a new reader creates a window`() {
|
||||
val opening = readerOpening("book-1", requestId = 1)
|
||||
|
|
@ -59,6 +68,67 @@ class DesktopReaderWindowStateTest {
|
|||
assertEquals(2L, decision.windows.single().focusRequestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader window uses persisted size instead of hardcoded fallback`() {
|
||||
val snapshot = DesktopWindowStateSnapshot(
|
||||
placement = DesktopSavedWindowPlacement.FLOATING,
|
||||
widthDp = 1340f,
|
||||
heightDp = 840f
|
||||
)
|
||||
|
||||
val size = snapshot.toWindowSize(DesktopReaderWindowDefaultSize)
|
||||
|
||||
assertEquals(1340.dp, size.width)
|
||||
assertEquals(840.dp, size.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader window defaults preserve previous detached reader size`() {
|
||||
assertEquals(1120.dp, DesktopReaderWindowDefaultSize.width)
|
||||
assertEquals(760.dp, DesktopReaderWindowDefaultSize.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader window persistence ignores fullscreen snapshots`() {
|
||||
val snapshot = DesktopWindowStateSnapshot(
|
||||
placement = DesktopSavedWindowPlacement.FULLSCREEN,
|
||||
widthDp = 1920f,
|
||||
heightDp = 1080f
|
||||
)
|
||||
|
||||
assertEquals(WindowPlacement.Floating, snapshot.toReaderWindowPlacement())
|
||||
assertNull(snapshot.toPersistableReaderWindowSnapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native webview text reader resets surface when switching from vertical to paginated`() {
|
||||
assertTrue(
|
||||
shouldResetDesktopTextReaderWindowSurface(
|
||||
previousMode = ReaderReadingMode.VERTICAL,
|
||||
currentMode = ReaderReadingMode.PAGINATED,
|
||||
usesNativeWebView = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text reader surface reset is limited to native webview vertical to paginated switches`() {
|
||||
assertFalse(
|
||||
shouldResetDesktopTextReaderWindowSurface(
|
||||
previousMode = ReaderReadingMode.PAGINATED,
|
||||
currentMode = ReaderReadingMode.VERTICAL,
|
||||
usesNativeWebView = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldResetDesktopTextReaderWindowSurface(
|
||||
previousMode = ReaderReadingMode.VERTICAL,
|
||||
currentMode = ReaderReadingMode.PAGINATED,
|
||||
usesNativeWebView = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening {
|
||||
return DesktopReaderOpening(
|
||||
requestId = requestId,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class DesktopStartupTest {
|
||||
@Test
|
||||
|
|
@ -25,34 +28,174 @@ class DesktopStartupTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `embedded webview starts only for epub backed reader surfaces`() {
|
||||
assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.EPUB_READER))
|
||||
assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.TEXT_READER))
|
||||
assertFalse(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.PDF_VIEWER))
|
||||
assertFalse(shouldRequestDesktopWebViewRuntime(null))
|
||||
fun `desktop epub webview uses native browser backends without bundled runtime`() {
|
||||
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
|
||||
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
|
||||
val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64)
|
||||
val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64)
|
||||
|
||||
assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(linux))
|
||||
assertEquals(DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2, desktopEpubWebViewBackend(windows))
|
||||
assertEquals(DesktopEpubWebViewBackend.WEBKIT, desktopEpubWebViewBackend(macos))
|
||||
assertEquals(DesktopEpubWebViewBackend.UNSUPPORTED, desktopEpubWebViewBackend(other))
|
||||
|
||||
assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(linux))
|
||||
assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(windows))
|
||||
assertTrue(desktopEpubWebViewUsesNativeSwtBrowser(macos))
|
||||
assertFalse(desktopEpubWebViewUsesNativeSwtBrowser(other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `embedded webview startup skips terminal runtime states`() {
|
||||
assertFalse(shouldStartDesktopWebViewRuntime(requested = false, state = DesktopWebViewRuntimeState()))
|
||||
assertTrue(shouldStartDesktopWebViewRuntime(requested = true, state = DesktopWebViewRuntimeState()))
|
||||
assertFalse(
|
||||
shouldStartDesktopWebViewRuntime(
|
||||
requested = true,
|
||||
state = DesktopWebViewRuntimeState(initialized = true)
|
||||
)
|
||||
fun `native webviews can render without bundled runtime state`() {
|
||||
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
|
||||
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
|
||||
val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64)
|
||||
val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64)
|
||||
|
||||
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), windows))
|
||||
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), linux))
|
||||
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(), macos))
|
||||
assertTrue(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), linux))
|
||||
assertFalse(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native webview unavailable messages point to the platform runtime`() {
|
||||
val windowsMessage = desktopNativeWebViewUnavailableMessage(
|
||||
backend = DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2,
|
||||
detail = "missing runtime"
|
||||
)
|
||||
assertFalse(
|
||||
shouldStartDesktopWebViewRuntime(
|
||||
requested = true,
|
||||
state = DesktopWebViewRuntimeState(restartRequired = true)
|
||||
)
|
||||
val linuxMessage = desktopNativeWebViewUnavailableMessage(
|
||||
backend = DesktopEpubWebViewBackend.WEBKIT,
|
||||
detail = "missing library"
|
||||
)
|
||||
assertFalse(
|
||||
shouldStartDesktopWebViewRuntime(
|
||||
requested = true,
|
||||
state = DesktopWebViewRuntimeState(errorMessage = "missing bundle")
|
||||
|
||||
assertTrue(windowsMessage.contains("WebView2 Runtime"))
|
||||
assertTrue(windowsMessage.contains("missing runtime"))
|
||||
assertTrue(linuxMessage.contains("WebKitGTK"))
|
||||
assertTrue(linuxMessage.contains("Linux distribution packages"))
|
||||
assertTrue(linuxMessage.contains("missing library"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compose interop blending stays off by default for native swt webviews`() {
|
||||
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
|
||||
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
|
||||
val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64)
|
||||
|
||||
assertNull(composeInteropBlendingDefault(windows))
|
||||
assertNull(composeInteropBlendingDefault(linux))
|
||||
assertEquals(ComposeInteropBlendingEnabled, composeInteropBlendingDefault(other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silent startup folder sync does not surface missing folder banner`() {
|
||||
val completed = desktopFolderSyncCompletedState(
|
||||
state = SharedReaderScreenState(),
|
||||
message = "Folder sync failed for 1 folder.",
|
||||
failedFolderCount = 1,
|
||||
showBanner = false
|
||||
)
|
||||
|
||||
assertNull(completed.bannerMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual folder sync still surfaces missing folder banner`() {
|
||||
val completed = desktopFolderSyncCompletedState(
|
||||
state = SharedReaderScreenState(),
|
||||
message = "Folder sync failed for 1 folder.",
|
||||
failedFolderCount = 1,
|
||||
showBanner = true
|
||||
)
|
||||
|
||||
assertEquals("Folder sync failed for 1 folder.", completed.bannerMessage?.message)
|
||||
assertTrue(completed.bannerMessage?.isError == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop account profile store restores cached profile for matching user`() {
|
||||
val directory = Files.createTempDirectory("episteme-account-profile-test").toFile()
|
||||
try {
|
||||
val store = DesktopAccountProfileStore(File(directory, "account_profile.properties"))
|
||||
store.save("user-1", DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L))
|
||||
|
||||
assertEquals(
|
||||
DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 123L),
|
||||
store.load("user-1")
|
||||
)
|
||||
assertNull(store.load("user-2"))
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop account profile freshness uses fetched timestamp`() {
|
||||
val now = 10_000L
|
||||
val ttl = 1_000L
|
||||
|
||||
assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl).isFresh(now, ttl))
|
||||
assertTrue(DesktopAccountProfile(fetchedAtEpochMillis = now - 1L).isFresh(now, ttl))
|
||||
assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now - ttl - 1L).isFresh(now, ttl))
|
||||
assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = now + 1L).isFresh(now, ttl))
|
||||
assertFalse(DesktopAccountProfile(fetchedAtEpochMillis = 0L).isFresh(now, ttl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop account profile repository ignores stale startup cache`() {
|
||||
val directory = Files.createTempDirectory("episteme-account-profile-policy-test").toFile()
|
||||
try {
|
||||
val store = DesktopAccountProfileStore(File(directory, "account_profile.properties"))
|
||||
val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store)
|
||||
val now = DesktopAccountProfileCacheTtlMillis + 10_000L
|
||||
val freshProfile = DesktopAccountProfile(
|
||||
isProUser = true,
|
||||
credits = 42,
|
||||
fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis + 1L
|
||||
)
|
||||
|
||||
repository.saveFetchedProfile("user-1", freshProfile)
|
||||
assertEquals(freshProfile, repository.cachedProfile("user-1", now))
|
||||
|
||||
repository.saveFetchedProfile(
|
||||
"user-1",
|
||||
freshProfile.copy(fetchedAtEpochMillis = now - DesktopAccountProfileCacheTtlMillis - 1L)
|
||||
)
|
||||
assertNull(repository.cachedProfile("user-1", now))
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop account profile repository clear removes sign out cache`() {
|
||||
val directory = Files.createTempDirectory("episteme-account-profile-clear-test").toFile()
|
||||
try {
|
||||
val store = DesktopAccountProfileStore(File(directory, "account_profile.properties"))
|
||||
val repository = DesktopAccountProfileRepository(testDesktopCloudConfig(), store)
|
||||
|
||||
repository.saveFetchedProfile(
|
||||
"user-1",
|
||||
DesktopAccountProfile(isProUser = true, credits = 42, fetchedAtEpochMillis = 10_000L)
|
||||
)
|
||||
repository.clearCachedProfiles()
|
||||
|
||||
assertNull(repository.cachedProfile("user-1", 10_001L))
|
||||
assertNull(store.load("user-1"))
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testDesktopCloudConfig(): DesktopCloudConfig {
|
||||
return DesktopCloudConfig(
|
||||
aiWorkerUrl = "",
|
||||
ttsWorkerUrl = "",
|
||||
firebaseWebApiKey = "",
|
||||
firebaseProjectId = "reader-test",
|
||||
googleOAuthClientId = "",
|
||||
googleOAuthClientSecret = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,24 @@ class DesktopStringResourcesTest {
|
|||
assertEquals("Don't skip %1${'$'}d file", parsed["quoted_count"]?.get("one"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadsAndroidToolbarTooltipDescriptionsForDesktop() {
|
||||
val resources = DesktopAndroidStringResources.load(
|
||||
locale = Locale.ENGLISH,
|
||||
classLoader = Thread.currentThread().contextClassLoader
|
||||
?: DesktopStringResourcesTest::class.java.classLoader
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Exit search and go back to the reader",
|
||||
resources.stringOrNull("tooltip_close_search_desc")
|
||||
)
|
||||
assertEquals(
|
||||
"Jump to the next search match in the document",
|
||||
resources.stringOrNull("tooltip_next_result_desc")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun choosesDesktopPluralQuantityForSupportedLanguages() {
|
||||
val slavicQuantities = setOf("one", "few", "many", "other")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopTtsLogTest {
|
||||
@Test
|
||||
fun `desktop tts preview redacts key and token query values`() {
|
||||
val preview = "wss://example.test/live?key=gemini_secret&token=firebase_secret"
|
||||
.desktopTtsPreview(300)
|
||||
|
||||
assertFalse(preview.contains("gemini_secret"))
|
||||
assertFalse(preview.contains("firebase_secret"))
|
||||
assertTrue(preview.contains("key=<redacted>"))
|
||||
assertTrue(preview.contains("token=<redacted>"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopWebView2LayoutTest {
|
||||
@Test
|
||||
fun `webview2 host bounds match the awt canvas logical size`() {
|
||||
val bounds = desktopWebView2TargetBoundsForCanvas(width = 1440, height = 900)
|
||||
|
||||
assertEquals(DesktopWebView2TargetBounds(x = 0, y = 0, width = 1440, height = 900), bounds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview2 host bounds are unavailable before the canvas has size`() {
|
||||
assertNull(desktopWebView2TargetBoundsForCanvas(width = 0, height = 900))
|
||||
assertNull(desktopWebView2TargetBoundsForCanvas(width = 1440, height = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview2 awt canvas is not retired while host window is closing`() {
|
||||
assertFalse(
|
||||
desktopWebView2ShouldRetireAwtCanvas(
|
||||
hostWindowClosing = true,
|
||||
hostWindowDisplayable = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
desktopWebView2ShouldRetireAwtCanvas(
|
||||
hostWindowClosing = false,
|
||||
hostWindowDisplayable = false
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
desktopWebView2ShouldRetireAwtCanvas(
|
||||
hostWindowClosing = false,
|
||||
hostWindowDisplayable = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,4 +34,10 @@ class DesktopWindowStateStoreTest {
|
|||
assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp)
|
||||
assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader window state uses a separate config file`() {
|
||||
assertEquals("window_state.json", DesktopWindowStateStore.defaultWindowStateFile().name)
|
||||
assertEquals("reader_window_state.json", DesktopWindowStateStore.defaultReaderWindowStateFile().name)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LinuxSecretToolCodecTest {
|
||||
@Test
|
||||
fun `secret tool codec stores looks up and clears secrets by key`() {
|
||||
val runner = FakeSecretCommandRunner()
|
||||
val codec = LinuxSecretToolCodec(runner)
|
||||
|
||||
assertTrue(codec.isAvailable)
|
||||
val reference = codec.protect("firebaseRefreshTokenProtected", "linux_refresh")
|
||||
|
||||
assertEquals("linux_refresh", codec.unprotect("firebaseRefreshTokenProtected", reference))
|
||||
codec.delete("firebaseRefreshTokenProtected")
|
||||
assertTrue(runner.storedSecrets.isEmpty())
|
||||
}
|
||||
|
||||
private class FakeSecretCommandRunner : DesktopSecretCommandRunner {
|
||||
val storedSecrets = linkedMapOf<String, String>()
|
||||
|
||||
override fun isExecutableAvailable(command: String): Boolean {
|
||||
return command == "secret-tool"
|
||||
}
|
||||
|
||||
override fun run(
|
||||
command: List<String>,
|
||||
input: String?,
|
||||
timeoutMillis: Long
|
||||
): DesktopSecretCommandResult {
|
||||
return when (command.getOrNull(1)) {
|
||||
"--help" -> DesktopSecretCommandResult(0, "usage", "")
|
||||
"store" -> {
|
||||
storedSecrets[command.last()] = input.orEmpty()
|
||||
DesktopSecretCommandResult(0, "", "")
|
||||
}
|
||||
"lookup" -> {
|
||||
val secret = storedSecrets[command.last()]
|
||||
if (secret == null) {
|
||||
DesktopSecretCommandResult(1, "", "not found")
|
||||
} else {
|
||||
DesktopSecretCommandResult(0, "$secret\n", "")
|
||||
}
|
||||
}
|
||||
"clear" -> {
|
||||
storedSecrets.remove(command.last())
|
||||
DesktopSecretCommandResult(0, "", "")
|
||||
}
|
||||
else -> DesktopSecretCommandResult(1, "", "unexpected command")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue