Desktop app (#308)

* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -6,7 +6,10 @@ import android.provider.OpenableColumns
import android.util.Xml
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.WorkManager
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import io.legere.pdfiumandroid.PdfiumCore
@ -28,7 +31,17 @@ class MetadataExtractionWorker(
const val WORK_NAME = "MetadataExtractionWorker"
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
private const val METADATA_DB_BATCH_SIZE = 100
private const val METADATA_WORKER_BOOK_BATCH_SIZE = 300
private const val METADATA_PROGRESS_LOG_EVERY = 250
private val TEXT_METADATA_TYPES = setOf(
FileType.PDF,
FileType.EPUB,
FileType.MOBI,
FileType.FB2,
FileType.ODT,
FileType.FODT,
FileType.DOCX
)
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
@ -45,20 +58,25 @@ class MetadataExtractionWorker(
}
try {
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
sourceFolderUri = sourceFolderUri,
limit = METADATA_WORKER_BOOK_BATCH_SIZE
)
if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
return@withContext Result.success()
}
ReaderPerfLog.i(
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}"
"MetadataWorker start mode=metadata books=${filesToProcess.size} " +
"batchLimit=$METADATA_WORKER_BOOK_BATCH_SIZE folder=${sourceFolderUri ?: "ALL"}"
)
val pendingUpdates = mutableListOf<RecentFileItem>()
var processed = 0
var updated = 0
var coversUpdated = 0
var failed = 0
suspend fun flushUpdates() {
@ -76,36 +94,80 @@ class MetadataExtractionWorker(
if (item.sourceFolderUri == null) return@forEach
var needsTextMetadata = item.type in TEXT_METADATA_TYPES && !item.folderTextMetadataParsed
var needsEmbeddedCover = false
try {
val uri = item.uriString?.toUri() ?: return@forEach
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
val existingCoverIsAvailable = item.coverImagePath?.let { File(it).isFile } == true
needsEmbeddedCover = EmbeddedEbookMetadataExtractor.canExtractEmbeddedCover(item.type) &&
!item.folderCoverMetadataParsed &&
!existingCoverIsAvailable
val metadata = when (item.type) {
FileType.EPUB -> parseEpubTextMetadata(uri)
FileType.EPUB,
FileType.MOBI,
FileType.FB2 -> {
if (needsTextMetadata || needsEmbeddedCover) {
EmbeddedEbookMetadataExtractor.extract(
type = item.type,
displayName = item.displayName,
openStream = { appContext.contentResolver.openInputStream(uri) },
extractCover = needsEmbeddedCover
).toTextMetadata()
} else {
TextMetadata()
}
}
FileType.PDF -> parsePdfTextMetadata(uri)
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
FileType.FODT -> parseFlatXmlTextMetadata(uri)
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
FileType.PPTX -> parseZipTextMetadata(uri, "docProps/core.xml")
else -> TextMetadata()
}
val title = sanitizeTitle(metadata.title)
val author = sanitizeAuthor(metadata.author)
val description = metadata.description?.trim()?.takeIf { it.isNotBlank() }
val seriesName = metadata.seriesName?.trim()?.takeIf { it.isNotBlank() }
val seriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 }
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
val titleChanged = title != null && title != item.title
val authorChanged = author != null && author != item.author
val descriptionChanged = description != null && description != item.description
val seriesChanged = seriesName != null && seriesName != item.seriesName
val seriesIndexChanged = seriesIndex != null && seriesIndex != item.seriesIndex
val coverPath = if (needsEmbeddedCover) {
metadata.cover?.let { cover ->
recentFilesRepository.saveEmbeddedCoverToCache(cover.bytes, uri, cover.extension)
}
} else {
null
}
val coverChanged = coverPath != null && coverPath != item.coverImagePath
val coverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
val textMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata
if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) {
if (needsTextMetadata || needsEmbeddedCover || sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
pendingUpdates.add(
item.copy(
coverImagePath = coverPath ?: item.coverImagePath,
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
description = description ?: item.description,
seriesName = seriesName ?: item.seriesName,
seriesIndex = seriesIndex ?: item.seriesIndex,
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
folderTextMetadataParsed = true
folderTextMetadataParsed = textMetadataParsed,
folderCoverMetadataParsed = coverMetadataParsed
)
)
if (sizeChanged || titleChanged || authorChanged) {
if (sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
updated++
}
if (coverChanged) coversUpdated++
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
@ -114,29 +176,64 @@ class MetadataExtractionWorker(
processed++
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
ReaderPerfLog.d(
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed"
"MetadataWorker progress mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed"
)
}
} catch (e: Exception) {
failed++
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}")
Timber.tag("MetadataWorker").e(e, "Failed metadata extraction for ${item.displayName}")
if (needsTextMetadata || needsEmbeddedCover) {
pendingUpdates.add(
item.copy(
folderTextMetadataParsed = item.folderTextMetadataParsed || needsTextMetadata,
folderCoverMetadataParsed = item.folderCoverMetadataParsed || needsEmbeddedCover
)
)
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
}
}
}
flushUpdates()
val nextBatchEnqueued = !isStopped &&
filesToProcess.size >= METADATA_WORKER_BOOK_BATCH_SIZE &&
recentFilesRepository.hasFolderBooksNeedingTextMetadata(sourceFolderUri)
if (nextBatchEnqueued) {
enqueueNextBatch(sourceFolderUri)
}
ReaderPerfLog.i(
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
"MetadataWorker finished mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed " +
"nextBatch=$nextBatchEnqueued elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
)
return@withContext Result.success()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
return@withContext Result.failure()
}
}
private fun enqueueNextBatch(sourceFolderUri: String?) {
val data = androidx.work.Data.Builder().apply {
if (!sourceFolderUri.isNullOrBlank()) {
putString(KEY_SOURCE_FOLDER_URI, sourceFolderUri)
}
}.build()
val request = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
.setInputData(data)
.build()
WorkManager.getInstance(appContext).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE,
request
)
ReaderPerfLog.d("MetadataWorker enqueued next metadata batch folder=${sourceFolderUri ?: "ALL"}")
}
private fun queryFileSize(uri: android.net.Uri): Long {
return try {
if (uri.scheme == "file") {
@ -157,30 +254,6 @@ class MetadataExtractionWorker(
}
}
private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
val opfEntries = linkedMapOf<String, String>()
var containerXml: String? = null
appContext.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.isDirectory) continue
val name = entry.name
when {
name == "META-INF/container.xml" -> containerXml = zip.readTextEntry()
name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry()
}
zip.closeEntry()
}
}
}
val opfPath = containerXml?.let { parseEpubRootfilePath(it) }
val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull()
return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata()
}
private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata {
appContext.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input.buffered()).use { zip ->
@ -222,21 +295,6 @@ class MetadataExtractionWorker(
}
}
private fun parseEpubRootfilePath(containerXml: String): String? {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
parser.setInput(containerXml.reader())
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) {
return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() }
}
event = parser.next()
}
return null
}
private fun parseXmlTextMetadata(xml: String): TextMetadata {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
@ -286,8 +344,23 @@ class MetadataExtractionWorker(
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
}
private fun EmbeddedEbookMetadata.toTextMetadata(): TextMetadata {
return TextMetadata(
title = title,
author = author,
description = description,
seriesName = seriesName,
seriesIndex = seriesIndex,
cover = cover
)
}
private data class TextMetadata(
val title: String? = null,
val author: String? = null
val author: String? = null,
val description: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val cover: EmbeddedEbookCover? = null
)
}