v1.0.47 (#279)
* 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:
parent
f42de6b462
commit
d7a9cae9e1
126 changed files with 15287 additions and 3154 deletions
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue