* Add performance and stylus debugging logs

* Refactor and decouple UI models from `MainViewModel`

* Refactor library state management and projection logic

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Introduce ReaderEngine and enhance EPUB reader features in windows app

* Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support.

* Implement PDF rendering and text extraction for desktop using Pdfium

* Add `NonReaderScreens.kt` and UI dependencies

* Refactor and centralize library state management and models to improve cross-platform consistency

* Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing

* Implement PDF annotation system and enhanced zoom controls for the desktop viewer

* Implement WebView-based EPUB rendering for desktop using CEF and embedded resources

* Optimize UI state projection, navigation state handling, and main screen pager performance

* Implement Bring Your Own Key (BYOK) support for AI features in OSS version

* Support Gemini-based Cloud TTS with BYOK support for OSS builds

* Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling.

* crash fixes

* Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6

* Implement dynamic bottom padding for the page info bar to account for device rounded corners

* Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar`

* Optimize PDF tiling performance and refine pan-and-fling gesture handling

* Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers

* Updated UI for customize toolbar

* Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls

* restructure PDF viewer UI component hierarchy to fix verifier crash

* Implement separate text dimming factors for light and dark themes

* Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers

* Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning

* Centralize file type resolution logic and implement HTML sanitization during import

* Introduce vertical margin customization and configurable progress bar positioning

* texture support in epub reader

* Enhance TTS session management, progress tracking, and diagnostic logging

* Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic.

* Refine TTS page mapping for PDF and overhaul TTS control UI

* Implement natural session completion logic in `TtsPlaybackManager` for cloud tts

* Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen`

* Refine TTS playback continuity across PDF pages and improve state management for session transitions

* Implement global texture transparency and enhance textured theme support across PDF and EPUB readers.

* Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode

* Add Support Project screen

* Optimize library performance via projection caching, batch database updates, and scoped folder synchronization.

* Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic

* Bump version to 1.0.47 (51)
This commit is contained in:
Aryan 2026-05-04 21:55:38 +05:30 committed by GitHub
parent f42de6b462
commit d7a9cae9e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 15287 additions and 3154 deletions

View file

@ -2,19 +2,20 @@
package com.aryan.reader
import android.content.Context
import android.provider.OpenableColumns
import android.util.Xml
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.ImportedFileCache
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.io.File
import java.util.zip.ZipInputStream
class MetadataExtractionWorker(
private val appContext: Context,
@ -22,179 +23,271 @@ class MetadataExtractionWorker(
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
companion object {
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_PROGRESS_LOG_EVERY = 250
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workerStart = ReaderPerfLog.nowNanos()
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val hasLegacy = prefs.contains("synced_folder_uri")
val hasNew = prefs.contains("synced_folders_list_json")
if (!hasLegacy && !hasNew) {
Timber.tag("MetadataWorker").w("No folders linked. Stopping.")
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
return@withContext Result.success()
}
try {
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers()
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
return@withContext Result.success()
}
Timber.tag("MetadataWorker").i("Starting background metadata extraction for ${filesToProcess.size} books.")
ReaderPerfLog.i(
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}"
)
val pendingUpdates = mutableListOf<RecentFileItem>()
var processed = 0
var updated = 0
var failed = 0
suspend fun flushUpdates() {
if (pendingUpdates.isEmpty()) return
val flushStart = ReaderPerfLog.nowNanos()
recentFilesRepository.updateExtractedMetadata(pendingUpdates)
ReaderPerfLog.d(
"MetadataWorker DB flush rows=${pendingUpdates.size} elapsed=${ReaderPerfLog.elapsedMs(flushStart)}ms"
)
pendingUpdates.clear()
}
filesToProcess.forEach { item ->
if (isStopped) return@forEach
if (item.sourceFolderUri == null) return@forEach
val tempExtractionDir =
if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) {
ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata")
} else {
null
}
try {
val uri = item.uriString?.toUri() ?: return@forEach
val type = item.type
var coverPath: String? = null
var title: String? = null
var author: String? = null
val fileSize = try {
if (uri.scheme == "file") {
uri.path?.let { File(it).length() } ?: 0L
} else {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
} else 0L
} ?: 0L
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}")
0L
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
val metadata = when (item.type) {
FileType.EPUB -> parseEpubTextMetadata(uri)
FileType.PDF -> parsePdfTextMetadata(uri)
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
FileType.FODT -> parseFlatXmlTextMetadata(uri)
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
else -> TextMetadata()
}
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
when (type) {
FileType.EPUB -> {
val book = epubParser.createEpubBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
FileType.MOBI -> {
val book = mobiParser.createMobiBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
book?.let {
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
}
}
FileType.PDF -> {
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
title = item.displayName
val title = sanitizeTitle(metadata.title)
val author = sanitizeAuthor(metadata.author)
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
val titleChanged = title != null && title != item.title
val authorChanged = author != null && author != item.author
try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd)
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
val extractedTitle = meta.title
if (!extractedTitle.isNullOrBlank()) {
title = extractedTitle
}
val extractedAuthor = meta.author
if (!extractedAuthor.isNullOrBlank()) {
author = extractedAuthor
}
pdfiumCore.closeDocument(pdfDocument)
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore")
}
}
FileType.ODT, FileType.FODT -> {
val book = odtParser.createOdtBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
isFlat = type == FileType.FODT,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
else -> {
title = item.displayName
}
}
}
if (coverPath != null || title != null || author != null || fileSize > 0L) {
val updatedItem = item.copy(
coverImagePath = coverPath ?: item.coverImagePath,
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
fileSize = if (fileSize > 0L) fileSize else item.fileSize
if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) {
pendingUpdates.add(
item.copy(
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
folderTextMetadataParsed = true
)
)
recentFilesRepository.addRecentFile(updatedItem)
Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)")
if (sizeChanged || titleChanged || authorChanged) {
updated++
}
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
} finally {
try {
if (tempExtractionDir?.exists() == true) {
val deleted = tempExtractionDir.deleteRecursively()
if (deleted) {
Timber.tag("MetadataWorker")
.d("Cleaned up temporary extraction cache for ${item.bookId}")
}
}
} catch (e: Exception) {
Timber.tag("MetadataWorker")
.e(e, "Failed to clean up temporary extraction cache for ${item.bookId}")
processed++
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
ReaderPerfLog.d(
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed"
)
}
} catch (e: Exception) {
failed++
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}")
}
}
flushUpdates()
ReaderPerfLog.i(
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
)
return@withContext Result.success()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
return@withContext Result.failure()
}
}
private fun queryFileSize(uri: android.net.Uri): Long {
return try {
if (uri.scheme == "file") {
uri.path?.let { File(it).length() } ?: 0L
} else {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L
} else {
0L
}
} ?: 0L
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to query file size for $uri")
0L
}
}
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 ->
while (true) {
val entry = zip.nextEntry ?: break
if (!entry.isDirectory && entry.name == targetEntryName) {
val xml = zip.readTextEntry()
return parseXmlTextMetadata(xml)
}
zip.closeEntry()
}
}
}
return TextMetadata()
}
private fun parseFlatXmlTextMetadata(uri: android.net.Uri): TextMetadata {
val xml = appContext.contentResolver.openInputStream(uri)?.use { input ->
input.bufferedReader(Charsets.UTF_8).use { it.readText() }
} ?: return TextMetadata()
return parseXmlTextMetadata(xml)
}
private fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
return try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd)
try {
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
TextMetadata(title = meta.title, author = meta.author)
} finally {
pdfiumCore.closeDocument(pdfDocument)
}
} ?: TextMetadata()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF text metadata")
TextMetadata()
}
}
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)
parser.setInput(xml.reader())
var title: String? = null
var author: String? = null
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG) {
val name = parser.name.substringAfter(':').lowercase()
when {
title == null && name == "title" -> title = parser.nextTextOrNull()
author == null && (name == "creator" || name == "initial-creator") -> {
author = parser.nextTextOrNull()
}
}
}
event = parser.next()
}
return TextMetadata(title = title, author = author)
}
private fun XmlPullParser.nextTextOrNull(): String? {
return try {
nextText()?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
private fun ZipInputStream.readTextEntry(): String {
return String(readBytes(), Charsets.UTF_8)
}
private fun sanitizeTitle(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
}
private fun sanitizeAuthor(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
}
private data class TextMetadata(
val title: String? = null,
val author: String? = null
)
}