book-reader/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
Aryan 9510293ac3
Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

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

* Implemented EPUB image gallery

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

* Standardized UI styling and refactored app navigation layout

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

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

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

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

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

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

* Implement cloud synchronization for desktop using Firestore and Google Drive

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

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

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

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

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

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

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

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

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

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

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

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

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

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

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

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

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
2026-05-20 22:14:01 +05:30

365 lines
16 KiB
Kotlin

// MetadataExtractionWorker.kt
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.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 com.aryan.reader.pdf.PdfiumCoreProvider
import com.aryan.reader.pdf.PdfiumEngineProvider
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,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(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_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) {
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) {
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
return@withContext Result.success()
}
try {
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
sourceFolderUri = sourceFolderUri,
limit = METADATA_WORKER_BOOK_BATCH_SIZE
)
if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
return@withContext Result.success()
}
ReaderPerfLog.i(
"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() {
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
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,
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 (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 = textMetadataParsed,
folderCoverMetadataParsed = coverMetadataParsed
)
)
if (sizeChanged || titleChanged || authorChanged || descriptionChanged || seriesChanged || seriesIndexChanged || coverChanged) {
updated++
}
if (coverChanged) coversUpdated++
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
}
processed++
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
ReaderPerfLog.d(
"MetadataWorker progress mode=metadata processed=$processed updated=$updated covers=$coversUpdated failed=$failed"
)
}
} catch (e: Exception) {
failed++
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=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, "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") {
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 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 suspend fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
return try {
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
PdfiumEngineProvider.withPdfium {
PdfiumCoreProvider.core.newDocument(pfd).use { pdfDocument ->
val meta = pdfDocument.getDocumentMeta()
TextMetadata(title = meta.title, author = meta.author)
}
}
} ?: TextMetadata()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF text metadata")
TextMetadata()
}
}
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 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 description: String? = null,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val cover: EmbeddedEbookCover? = null
)
}