Merge remote-tracking branch 'origin/main'

This commit is contained in:
Hosted Weblate 2026-05-10 06:37:43 +02:00
commit e01fa07dda
No known key found for this signature in database
GPG key ID: A3FAAA06E6569B4C
214 changed files with 53372 additions and 4702 deletions

View file

@ -9,6 +9,7 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
alias(libs.plugins.kotlin.ksp)
id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover)
}
val localProperties = Properties()
@ -150,6 +151,22 @@ android {
}
}
}
kover {
reports {
filters {
excludes {
classes(
"*.BuildConfig",
"*.ComposableSingletons*",
"*_Impl",
"*Database_Impl",
"*Dao_Impl"
)
}
}
}
}
//noinspection UseTomlInstead
dependencies {
@ -220,7 +237,6 @@ dependencies {
implementation("com.jakewharton.timber:timber:5.0.1")
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
implementation("me.zhanghai.android.libarchive:library:1.1.6")
implementation("androidx.paging:paging-runtime-ktx:3.3.6")
@ -252,6 +268,8 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk-android:1.14.9")
testImplementation(libs.kotlinx.coroutines.test)
testImplementation("org.json:json:20251224")
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("org.slf4j:slf4j-nop:2.0.17")
}

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,26 @@ private val codeOrDataExtensions = setOf(
"go"
)
private val manualOnlyReaderMimeTypes = setOf(
"text/csv",
"text/comma-separated-values",
"text/tab-separated-values",
"application/json",
"application/xml",
"text/xml",
"text/x-java-source",
"text/x-python",
"text/x-kotlin",
"text/javascript",
"application/javascript",
"text/x-c",
"text/x-c++",
"text/x-csharp",
"text/x-ruby",
"text/x-go",
"text/x-log"
)
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()
@ -44,6 +64,22 @@ internal fun isCodeOrDataFileName(fileName: String): Boolean {
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
}
internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false
return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
}
internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
val normalized = mimeType?.lowercase() ?: return false
return normalized in manualOnlyReaderMimeTypes
}
internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
if (isManualOnlyReaderFileName(name)) return false
if (resolveFileTypeFromName(name) != null) return true
return !isManualOnlyReaderMimeType(mimeType)
}
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()

View file

@ -40,7 +40,6 @@ import com.aryan.reader.data.LocalSyncUtils
import com.aryan.reader.data.FolderBookMetadata
import java.io.File
import android.provider.DocumentsContract
import java.security.MessageDigest
class FolderSyncWorker(
private val appContext: Context,
@ -316,7 +315,13 @@ class FolderSyncWorker(
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
val type = getFileType(name, mimeType)
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
if (
type != null &&
type in allowedFileTypes &&
isLocalFolderSyncEligibleFile(name, mimeType) &&
!name.endsWith(".json") &&
!name.startsWith(".")
) {
supportedBooksSeen++
val stableId = buildStableBookId(name, rootDocId, docId)
foundBookIds.add(stableId)
@ -554,11 +559,14 @@ class FolderSyncWorker(
val sidecarData = preloadedSidecars[book.bookId] ?: continue
val (remoteTs, jsonPayload) = sidecarData
val safeSlashBookId = book.bookId.replace("/", "_")
val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
@ -607,10 +615,7 @@ class FolderSyncWorker(
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
val relativePath = buildRelativePath(rootDocId, docId, name)
if (relativePath.equals(name, ignoreCase = true)) {
return "local_$name"
}
return "local_${name}_${shortHash(relativePath.lowercase())}"
return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath)
}
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
@ -625,11 +630,6 @@ class FolderSyncWorker(
return relative.ifBlank { fallbackName }
}
private fun shortHash(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }.take(12)
}
private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
val uriString = item.uriString ?: return null
return try {

View file

@ -3147,11 +3147,12 @@ fun OpdsBookDetailsSheet(
}
}
if (!entry.summary.isNullOrBlank()) {
val summary = entry.summary
if (!summary.isNullOrBlank()) {
Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
val cleanSummary = remember(entry.summary) {
val preProcessed = entry.summary
val cleanSummary = remember(summary) {
val preProcessed = summary
.replace("<br>", "\n")
.replace("</p>", "\n\n")
Jsoup.parse(preProcessed).text().trim()

View file

@ -4,8 +4,8 @@ import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.SmartCollectionEngine
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.SmartCollectionEngine
fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List<String>
@ -183,7 +183,7 @@ class LibraryStateProjector(
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
if (rules != null) {
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it.toSharedBookItem(), rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}

View file

@ -53,6 +53,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.aryan.reader.data.BookMetadata
import com.aryan.reader.data.CloudflareRepository
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.data.FeedbackRepository
@ -83,8 +84,8 @@ import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.pdf.PdfCoverGenerator
import com.aryan.reader.pdf.PdfExporter
import com.aryan.reader.pdf.PdfUserHighlight
import com.aryan.reader.pdf.PdfiumAnnotationExporter
import com.aryan.reader.pdf.ReflowWorker
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@ -94,7 +95,9 @@ import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.VirtualPage
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.aryan.reader.shared.SharedLibraryEditor
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@ -709,27 +712,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun createAndAssignTag(name: String, bookIds: Set<String>) {
val trimmedName = name.trim()
if (trimmedName.isBlank() || bookIds.isEmpty()) return
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (sanitizedBookIds.isEmpty()) return
viewModelScope.launch {
val tagId = UUID.randomUUID().toString()
val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE)
val color = colors.random().toInt()
val tag = TagEntity(tagId, trimmedName, color, System.currentTimeMillis())
val now = System.currentTimeMillis()
val tag = SharedLibraryEditor.createTag(name, tagId, color)?.toTagEntity(now) ?: return@launch
recentFilesRepository.createTag(tag)
bookIds.forEach { bookId ->
sanitizedBookIds.forEach { bookId ->
recentFilesRepository.assignTagToBook(bookId, tagId)
}
}
}
fun toggleTagForBooks(tagId: String, bookIds: Set<String>, assign: Boolean) {
if (tagId.isBlank() || bookIds.isEmpty()) return
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (tagId.isBlank() || sanitizedBookIds.isEmpty()) return
viewModelScope.launch {
bookIds.forEach { bookId ->
sanitizedBookIds.forEach { bookId ->
if (assign) {
recentFilesRepository.assignTagToBook(bookId, tagId)
} else {
@ -1094,9 +1098,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) }
}
}
viewModelScope.launch(Dispatchers.IO) {
PDFBoxResourceLoader.init(getApplication())
}
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) }
@ -1779,7 +1780,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val virtualPages = pageLayoutRepository.getLayoutOrNull(bookId)
val outputStream = appContext.contentResolver.openOutputStream(destUri)
if (outputStream != null) {
PdfExporter.exportAnnotatedPdf(
PdfiumAnnotationExporter.exportAnnotatedPdf(
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
@ -1889,12 +1890,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
val destFile = File(shareDir, filename)
val outputStream = FileOutputStream(destFile)
FileOutputStream(destFile).use { outputStream ->
if (includeAnnotations) {
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
PdfExporter.exportAnnotatedPdf(
PdfiumAnnotationExporter.exportAnnotatedPdf(
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
@ -1909,6 +1909,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
input.copyTo(outputStream)
}
}
}
val authority = "${appContext.packageName}.provider"
val contentUri = androidx.core.content.FileProvider.getUriForFile(
@ -1953,6 +1954,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
}
if (book.isManualOnlyReaderFile()) {
Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}")
return
}
val currentUser = uiState.value.currentUser ?: return
viewModelScope.launch {
@ -1972,6 +1978,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val hasTextBoxes = textBoxFile.exists()
val hasHighlights = highlightFile.exists()
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " +
"richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData"
)
if (hasAnyData) {
if (googleDriveRepository.hasDrivePermissions(appContext)) {
@ -1985,12 +1995,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (file == null || !file.exists()) return
try {
val content = file.readText().trim()
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.export.readRichText book=${book.bookId} rawLen=${content.length} " +
"file=${file.absolutePath}"
)
}
if (content.startsWith("[")) {
bundleJson.put(key, JSONArray(content))
} else if (content.startsWith("{")) {
bundleJson.put(key, JSONObject(content))
}
} catch (e: Exception) {
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}")
}
Timber.e(e, "Failed to parse local $key file")
}
}
@ -2003,7 +2023,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val bundleFile =
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
bundleFile.writeText(bundleJson.toString())
val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
bundleFile.writeText(canonicalBundle)
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " +
"bundleFile=${bundleFile.absolutePath}"
)
}
val uploaded = googleDriveRepository.uploadAnnotationFile(
accessToken, book.bookId, bundleFile
@ -2011,9 +2038,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bundleFile.delete()
if (uploaded != null) {
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}")
}
Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else {
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.e("android.cloud.export.uploadFailed book=${book.bookId}")
}
Timber.tag("AnnotationSync")
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
return@launch
@ -2204,7 +2239,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
while (nextIdx < totalChapters) {
Timber.tag("TTS_BG_ADVANCE").d("Trying chapter $nextIdx natively.")
val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx)
val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx, bookId)
if (!nativeChunks.isNullOrEmpty()) {
val token = getAuthToken()
@ -2231,7 +2266,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
// Save reading position locally
val cfi = nativeChunks.firstOrNull()?.sourceCfi
if (cfi != null) {
val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi)
val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi, bookId)
if (locator != null) {
recentFilesRepository.getFileByBookId(bookId)?.uriString?.let { uriString ->
recentFilesRepository.updateEpubReadingPosition(uriString, locator, cfi, 0f)
@ -2872,25 +2907,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else {
allFiles.filter { it.sourceFolderUri == null }
}
filtered.filterNot { it.uriString?.startsWith("opds-pse") == true }
filtered
.filterNot { it.uriString?.startsWith("opds-pse") == true }
.filterNot { it.isManualOnlyReaderFile() }
}
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
val remoteBooks = remoteBooksDeferred.await()
.filterNot { it.isManualOnlyReaderFile() }
val remoteShelves = remoteShelvesDeferred.await()
val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet()
val allKnownShelfNames =
(localShelfNames + remoteShelvesDeferred.await().map { it.name }).toSet()
(localShelfNames + remoteShelves.map { it.name }).toSet()
val localShelves = allKnownShelfNames.mapNotNull { name ->
val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", 0L)
if (timestamp == 0L && name !in localShelfNames) return@mapNotNull null
val bookIds = prefs.getStringSet(
"$KEY_SHELF_CONTENT_PREFIX$name", emptySet()
).orEmpty().toList()
).orEmpty().filter { it in syncableBookIds }
val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$name", false)
ShelfMetadata(name, bookIds, timestamp, isDeleted)
}
val remoteBooks = remoteBooksDeferred.await()
val remoteShelves = remoteShelvesDeferred.await()
// 3. Merge Books
val localBooksMap = localBooks.associateBy { it.bookId }
val remoteBooksMap = remoteBooks.associateBy { it.bookId }
@ -2985,7 +3023,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentShelves.add(remote.name)
putStringSet(
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
remote.bookIds.toSet()
remote.bookIds.filter { it in syncableBookIds }.toSet()
)
}
putStringSet(KEY_SHELVES, currentShelves)
@ -3014,7 +3052,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentShelves.add(remote.name)
putStringSet(
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
remote.bookIds.toSet()
remote.bookIds.filter { it in syncableBookIds }.toSet()
)
}
putStringSet(KEY_SHELVES, currentShelves)
@ -3033,7 +3071,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val finalMergedBooks = withContext(Dispatchers.IO) {
recentFilesRepository.getAllFilesForSync()
}
}.filterNot { it.isManualOnlyReaderFile() }
val remoteFiles = withContext(Dispatchers.IO) {
googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
}
@ -3100,11 +3138,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
try {
val jsonString = tempDownloadFile.readText()
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}"
)
// Determine format
val isBundle = try {
val obj = JSONObject(jsonString)
obj.has("version") || obj.has("ink") || obj.has("text") || obj.has("layout")
obj.has("version") ||
obj.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
obj.has("ink") ||
obj.has("text") ||
obj.has("layout") ||
obj.has("textBoxes") ||
obj.has("highlights")
} catch (_: Exception) {
false
}
@ -3124,13 +3171,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
highlightFile.parentFile?.mkdirs()
if (isBundle) {
val bundle = JSONObject(jsonString)
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
)
fun writeSafe(key: String, file: File) {
if (bundle.has(key)) {
file.parentFile?.mkdirs()
file.writeText(bundle.get(key).toString())
val content = bundle.get(key).toString()
file.writeText(content)
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
)
}
} else {
if (key == "text" && file.exists()) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}"
)
}
if (file.exists()) file.delete()
}
}
@ -4592,23 +4655,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun createShelf(name: String) {
if (name.isNotBlank()) {
viewModelScope.launch {
val shelfId = UUID.randomUUID().toString()
val shelf = com.aryan.reader.data.ShelfEntity(
id = shelfId,
name = name,
isSmart = false,
smartRulesJson = null,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
val now = System.currentTimeMillis()
val shelf = SharedLibraryEditor.createShelfRecord(name, shelfId)?.toShelfEntity(now) ?: return
viewModelScope.launch {
recentFilesRepository.addShelf(shelf)
dismissCreateShelfDialog()
syncShelfChangeToFirestore(shelfId)
}
}
}
fun setMainScreenPage(page: Int) {
val sanitizedPage = page.coerceIn(0, 1)
@ -4651,12 +4706,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun renameShelf(shelfId: String, newName: String) {
if (shelfId.isBlank() || newName.isBlank()) {
val cleanName = SharedLibraryEditor.cleanShelfName(newName)
if (!SharedLibraryEditor.canMutateShelf(shelfId) || cleanName == null) {
dismissRenameShelfDialog()
return
}
viewModelScope.launch {
recentFilesRepository.renameShelf(shelfId, newName)
recentFilesRepository.renameShelf(shelfId, cleanName)
syncShelfChangeToFirestore(shelfId)
_internalState.update { it.copy(viewingShelfId = shelfId) }
persistLibraryLandingState()
@ -4665,7 +4721,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun deleteShelf(shelfId: String) {
if (shelfId.isBlank() || shelfId == "unshelved") {
if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
dismissDeleteShelfDialog()
return
}
@ -4697,21 +4753,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun removeContextualItemsFromShelf() {
val shelfId = _internalState.value.viewingShelfId
if (shelfId.isNullOrBlank() || shelfId == "unshelved") {
if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
clearContextualAction()
return
}
val targetShelfId = shelfId ?: return
val bookIdsToRemove = _internalState.value.contextualActionItems.map { it.bookId }
val bookIdsToRemove = SharedLibraryEditor.cleanBookIds(_internalState.value.contextualActionItems.map { it.bookId })
if (bookIdsToRemove.isEmpty()) {
clearContextualAction()
return
}
viewModelScope.launch {
recentFilesRepository.removeBooksFromShelf(shelfId, bookIdsToRemove)
recentFilesRepository.removeBooksFromShelf(targetShelfId, bookIdsToRemove.toList())
clearContextualAction()
syncShelfChangeToFirestore(shelfId)
syncShelfChangeToFirestore(targetShelfId)
}
}
@ -4755,6 +4812,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun deleteSelectedShelves() {
val shelvesToDelete = _internalState.value.contextualActionShelfIds
.filterTo(mutableSetOf()) { SharedLibraryEditor.canMutateShelf(it) }
if (shelvesToDelete.isEmpty()) {
clearShelfContextualAction()
return
@ -4788,7 +4846,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext)
val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch
val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId)
val manualOnlyBookIds = recentFilesRepository.getAllFilesForSync()
.filter { it.isManualOnlyReaderFile() }
.mapTo(mutableSetOf()) { it.bookId }
val bookIds = crossRefs.map { it.bookId }
.filterNot { it in manualOnlyBookIds }
val shelfMetadata = ShelfMetadata(
name = shelf.name,
@ -4814,8 +4876,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun addBooksToShelf(shelfId: String) {
val bookIdsToAdd = _internalState.value.booksSelectedForAdding
if (bookIdsToAdd.isEmpty()) {
val bookIdsToAdd = SharedLibraryEditor.cleanBookIds(_internalState.value.booksSelectedForAdding)
if (!SharedLibraryEditor.canMutateShelf(shelfId) || bookIdsToAdd.isEmpty()) {
dismissAddBooksToShelf()
return
}
@ -4943,6 +5005,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
.associateBy { it.name }
for (item in managedBooks) {
if (item.isManualOnlyReaderFile()) {
cleanupBookDataLocally(item.bookId)
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
continue
}
recentFilesRepository.markAsDeleted(listOf(item.bookId))
cleanupBookDataLocally(item.bookId)
@ -5462,3 +5530,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
}
private fun RecentFileItem.isManualOnlyReaderFile(): Boolean {
return isManualOnlyReaderFileName(displayName)
}
private fun BookMetadata.isManualOnlyReaderFile(): Boolean {
return isManualOnlyReaderFileName(displayName)
}

View file

@ -12,6 +12,7 @@ import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
@ -30,15 +31,18 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
type = type.toSharedFileType(),
displayName = customName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
sourceFolder = sourceFolderUri,
folderTextMetadataParsed = folderTextMetadataParsed,
seriesName = seriesName,
seriesIndex = seriesIndex,
tags = tags.map { it.toSharedTag() }
tags = tags.map { it.toSharedTag() },
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
)
}
@ -50,6 +54,15 @@ fun TagEntity.toSharedTag(): SharedTag {
)
}
fun SharedTag.toTagEntity(createdAt: Long): TagEntity {
return TagEntity(
id = id,
name = name,
color = color,
createdAt = createdAt
)
}
fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
return ShelfRecord(
id = id,
@ -59,6 +72,17 @@ fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
)
}
fun ShelfRecord.toShelfEntity(createdAt: Long, updatedAt: Long = createdAt): ShelfEntity {
return ShelfEntity(
id = id,
name = name,
isSmart = isSmart,
smartRulesJson = smartRulesJson,
createdAt = createdAt,
updatedAt = updatedAt
)
}
fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
return SharedBookShelfRef(
bookId = bookId,

View file

@ -0,0 +1,43 @@
package com.aryan.reader
import android.content.Context
import androidx.core.content.edit
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.shared.ReaderTtsReplacementEngine
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson
private const val READER_PREFS_NAME = "reader_prefs"
private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json"
fun loadTtsReplacementPreferences(context: Context): ReaderTtsReplacementPreferences {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
return ReaderTtsReplacementPreferencesJson.decodeOrEmpty(prefs.getString(TTS_REPLACEMENTS_KEY, null))
}
fun saveTtsReplacementPreferences(
context: Context,
preferences: ReaderTtsReplacementPreferences,
) {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putString(TTS_REPLACEMENTS_KEY, ReaderTtsReplacementPreferencesJson.encode(preferences))
}
}
fun TtsChunk.withTtsReplacements(
preferences: ReaderTtsReplacementPreferences,
bookId: String?,
): TtsChunk {
val spoken = ReaderTtsReplacementEngine.apply(
text = text,
preferences = preferences,
bookId = bookId,
).text
return copy(spokenText = spoken.ifBlank { text })
}
fun List<TtsChunk>.withTtsReplacements(
preferences: ReaderTtsReplacementPreferences,
bookId: String?,
): List<TtsChunk> = map { it.withTtsReplacements(preferences, bookId) }

View file

@ -0,0 +1,667 @@
package com.aryan.reader
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.ReaderTtsReplacementBookSettings
import com.aryan.reader.shared.ReaderTtsReplacementEngine
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementRule
import com.aryan.reader.shared.ReaderTtsReplacementSuggestions
private enum class TtsReplacementScope {
Global,
Book
}
private data class RuleEditTarget(
val scope: TtsReplacementScope,
val ruleId: String? = null,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsWordReplacementsSheet(
isVisible: Boolean,
bookId: String,
bookTitle: String?,
preferences: ReaderTtsReplacementPreferences,
onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
onDismiss: () -> Unit,
) {
if (!isVisible) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var selectedTab by remember { mutableIntStateOf(0) }
var editTarget by remember { mutableStateOf<RuleEditTarget?>(null) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 720.dp)
.imePadding()
.padding(horizontal = 20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "TTS Word Replacements",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = bookTitle?.takeIf { it.isNotBlank() } ?: "Current book",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
Spacer(modifier = Modifier.height(12.dp))
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = {
selectedTab = 0
editTarget = null
},
text = { Text("Global") },
)
Tab(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
editTarget = null
},
text = { Text("This book") },
)
}
Spacer(modifier = Modifier.height(12.dp))
when (selectedTab) {
0 -> GlobalReplacementTab(
preferences = preferences,
editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Global },
onEditTargetChange = { editTarget = it },
onPreferencesChange = onPreferencesChange,
)
else -> BookReplacementTab(
bookId = bookId,
preferences = preferences,
editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Book },
onEditTargetChange = { editTarget = it },
onPreferencesChange = onPreferencesChange,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun GlobalReplacementTab(
preferences: ReaderTtsReplacementPreferences,
editTarget: RuleEditTarget?,
onEditTargetChange: (RuleEditTarget?) -> Unit,
onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
) {
val editingRule = editTarget?.ruleId?.let { id -> preferences.globalRules.firstOrNull { it.id == id } }
LazyColumn(
modifier = Modifier.heightIn(max = 560.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
ListItem(
headlineContent = { Text("Enable replacements") },
supportingContent = { Text("Rules here apply to every book unless disabled for a specific title.") },
trailingContent = {
Switch(
checked = preferences.isEnabled,
onCheckedChange = { onPreferencesChange(preferences.copy(isEnabled = it)) },
)
},
)
}
item {
SuggestionChips(
onSuggestionClick = { suggestion ->
onPreferencesChange(
preferences.copy(
globalRules = preferences.globalRules + suggestion.asEditableRule("global"),
),
)
},
)
}
item {
TextButton(
onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global)) },
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add rule")
}
}
if (editTarget != null) {
item {
RuleEditorCard(
seedRule = editingRule,
onCancel = { onEditTargetChange(null) },
onSave = { rule ->
val updatedRules = if (editingRule == null) {
preferences.globalRules + rule
} else {
preferences.globalRules.map { if (it.id == editingRule.id) rule else it }
}
onPreferencesChange(preferences.copy(globalRules = updatedRules))
onEditTargetChange(null)
},
)
}
}
item {
ReplacementRuleList(
rules = preferences.globalRules,
emptyText = "No global replacement rules yet.",
onToggle = { rule, enabled ->
onPreferencesChange(
preferences.copy(
globalRules = preferences.globalRules.map {
if (it.id == rule.id) it.copy(enabled = enabled) else it
},
),
)
},
onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global, it.id)) },
onDelete = { rule ->
onPreferencesChange(
preferences.copy(globalRules = preferences.globalRules.filterNot { it.id == rule.id }),
)
},
)
}
}
}
@Composable
private fun BookReplacementTab(
bookId: String,
preferences: ReaderTtsReplacementPreferences,
editTarget: RuleEditTarget?,
onEditTargetChange: (RuleEditTarget?) -> Unit,
onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
) {
val settings = preferences.settingsForBook(bookId)
val localRules = preferences.rulesForBook(bookId)
val editingRule = editTarget?.ruleId?.let { id -> localRules.firstOrNull { it.id == id } }
LazyColumn(
modifier = Modifier.heightIn(max = 560.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
BookSettingsSwitches(
settings = settings,
onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) },
)
}
item {
InheritedGlobalRules(
globalRules = preferences.globalRules,
settings = settings,
onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) },
)
}
item {
SuggestionChips(
onSuggestionClick = { suggestion ->
onPreferencesChange(
preferences.withBookRules(
bookId,
localRules + suggestion.asEditableRule("book"),
),
)
},
)
}
item {
TextButton(
onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book)) },
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add book rule")
}
}
if (editTarget != null) {
item {
RuleEditorCard(
seedRule = editingRule,
onCancel = { onEditTargetChange(null) },
onSave = { rule ->
val updatedRules = if (editingRule == null) {
localRules + rule
} else {
localRules.map { if (it.id == editingRule.id) rule else it }
}
onPreferencesChange(preferences.withBookRules(bookId, updatedRules))
onEditTargetChange(null)
},
)
}
}
item {
ReplacementRuleList(
rules = localRules,
emptyText = "No book-specific rules yet.",
onToggle = { rule, enabled ->
onPreferencesChange(
preferences.withBookRules(
bookId,
localRules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
),
)
},
onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book, it.id)) },
onDelete = { rule ->
onPreferencesChange(preferences.withBookRules(bookId, localRules.filterNot { it.id == rule.id }))
},
)
}
}
}
@Composable
private fun BookSettingsSwitches(
settings: ReaderTtsReplacementBookSettings,
onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit,
) {
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
Column(modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Use global rules here") },
supportingContent = { Text("Turn this off when a book needs its own pronunciation choices.") },
trailingContent = {
Switch(
checked = settings.globalRulesEnabled,
onCheckedChange = { onSettingsChange(settings.copy(globalRulesEnabled = it)) },
)
},
)
HorizontalDivider()
ListItem(
headlineContent = { Text("Enable book rules") },
supportingContent = { Text("Local rules run after global rules.") },
trailingContent = {
Switch(
checked = settings.localRulesEnabled,
onCheckedChange = { onSettingsChange(settings.copy(localRulesEnabled = it)) },
)
},
)
}
}
}
@Composable
private fun InheritedGlobalRules(
globalRules: List<ReaderTtsReplacementRule>,
settings: ReaderTtsReplacementBookSettings,
onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Inherited global rules",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (globalRules.isEmpty()) {
Text(
text = "No global rules to inherit.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
globalRules.forEach { rule ->
val enabledHere = rule.id !in settings.disabledGlobalRuleIds
ListItem(
headlineContent = { Text(rule.summaryText()) },
supportingContent = { Text(if (enabledHere) "Allowed in this book" else "Disabled for this book") },
trailingContent = {
Switch(
checked = enabledHere,
onCheckedChange = { checked ->
val disabledIds = if (checked) {
settings.disabledGlobalRuleIds - rule.id
} else {
settings.disabledGlobalRuleIds + rule.id
}
onSettingsChange(settings.copy(disabledGlobalRuleIds = disabledIds))
},
)
},
)
}
}
}
@Composable
private fun SuggestionChips(
onSuggestionClick: (ReaderTtsReplacementRule) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Suggestions",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(ReaderTtsReplacementSuggestions.presets) { suggestion ->
AssistChip(
onClick = { onSuggestionClick(suggestion) },
label = { Text(suggestion.summaryText(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) },
)
}
}
}
}
@Composable
private fun RuleEditorCard(
seedRule: ReaderTtsReplacementRule?,
onCancel: () -> Unit,
onSave: (ReaderTtsReplacementRule) -> Unit,
) {
val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newReplacementRuleId() }
val initial = seedRule ?: ReaderTtsReplacementRule(
id = draftRuleId,
from = "",
to = "",
)
var from by remember(initial.id) { mutableStateOf(initial.from) }
var to by remember(initial.id) { mutableStateOf(initial.to) }
var enabled by remember(initial.id) { mutableStateOf(initial.enabled) }
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
var previewInput by remember(initial.id) {
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA at 5 p.m.")
}
val draft = ReaderTtsReplacementRule(
id = initial.id,
from = from,
to = to,
enabled = enabled,
isRegex = isRegex,
matchCase = matchCase,
wholeWord = wholeWord,
)
val validation = ReaderTtsReplacementEngine.validate(draft)
val previewOutput = if (validation.isValid) {
ReaderTtsReplacementEngine.apply(
text = previewInput,
preferences = ReaderTtsReplacementPreferences(globalRules = listOf(draft.copy(enabled = true))),
).text
} else {
previewInput
}
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = if (seedRule == null) "New replacement" else "Edit replacement",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
OutlinedTextField(
value = from,
onValueChange = { from = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Replace") },
singleLine = !isRegex,
isError = !validation.isValid,
supportingText = if (validation.message != null) {
{ Text(validation.message.orEmpty()) }
} else {
null
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Text,
),
)
OutlinedTextField(
value = to,
onValueChange = { to = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Speak as") },
singleLine = !isRegex,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
selected = enabled,
onClick = { enabled = !enabled },
label = { Text("Enabled") },
leadingIcon = if (enabled) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else {
null
},
)
}
item {
FilterChip(
selected = isRegex,
onClick = { isRegex = !isRegex },
label = { Text("Regex") },
)
}
item {
FilterChip(
selected = wholeWord,
onClick = { wholeWord = !wholeWord },
label = { Text("Whole word") },
)
}
item {
FilterChip(
selected = matchCase,
onClick = { matchCase = !matchCase },
label = { Text("Match case") },
)
}
}
OutlinedTextField(
value = previewInput,
onValueChange = { previewInput = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Preview input") },
minLines = 2,
)
Text(
text = previewOutput,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onCancel) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { onSave(draft) },
enabled = validation.isValid,
) {
Text("Save")
}
}
}
}
}
@Composable
private fun ReplacementRuleList(
rules: List<ReaderTtsReplacementRule>,
emptyText: String,
onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit,
onEdit: (ReaderTtsReplacementRule) -> Unit,
onDelete: (ReaderTtsReplacementRule) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Rules",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (rules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = emptyText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
rules.forEach { rule ->
ListItem(
headlineContent = {
Text(
text = rule.summaryText(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(rule.optionSummary())
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = rule.enabled,
onCheckedChange = { onToggle(rule, it) },
)
IconButton(onClick = { onEdit(rule) }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = { onDelete(rule) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
},
)
}
}
}
private fun ReaderTtsReplacementRule.asEditableRule(scope: String): ReaderTtsReplacementRule {
return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true)
}
private fun ReaderTtsReplacementRule.summaryText(): String {
val replacement = to.ifBlank { "silence" }
return "$from -> $replacement"
}
private fun ReaderTtsReplacementRule.optionSummary(): String {
val parts = buildList {
add(if (isRegex) "Regex" else "Plain text")
if (wholeWord) add("whole word")
if (matchCase) add("case-sensitive")
}
return parts.joinToString(" - ")
}
private fun newReplacementRuleId(): String {
return "rule_${System.currentTimeMillis()}"
}

View file

@ -40,6 +40,8 @@ import java.io.FileOutputStream
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import org.json.JSONObject
import org.json.JSONArray
import java.util.UUID
@ -273,6 +275,10 @@ class RecentFilesRepository(private val context: Context) {
val hasHighlights = highlightFile.exists()
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.export candidates book=$bookId hasRichText=$hasRichText " +
"richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
)
if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes && !hasHighlights) {
Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.")
@ -284,12 +290,21 @@ class RecentFilesRepository(private val context: Context) {
fun putJsonSafe(key: String, file: File) {
try {
val content = file.readText().trim()
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
)
}
if (content.startsWith("[")) {
bundleJson.put(key, JSONArray(content))
} else if (content.startsWith("{")) {
bundleJson.put(key, JSONObject(content))
}
} catch (e: Exception) {
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.e(e, "android.folder.export.richTextParseFailed book=$bookId")
}
Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file")
}
}
@ -311,11 +326,18 @@ class RecentFilesRepository(private val context: Context) {
Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs")
val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
)
}
LocalSyncUtils.saveAnnotationSidecar(
context = context,
sourceFolderUri = folderUriString.toUri(),
bookId = bookId,
jsonPayload = bundleJson.toString(),
jsonPayload = canonicalBundleJson,
timestamp = finalTs
)
}
@ -323,13 +345,24 @@ class RecentFilesRepository(private val context: Context) {
suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
try {
val bundle = JSONObject(jsonString)
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
)
fun writeSafe(key: String, file: File?) {
if (file != null && bundle.has(key)) {
file.parentFile?.mkdirs()
val contentStr = bundle.get(key).toString()
file.writeText(contentStr)
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
)
}
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
}
}

View file

@ -1,81 +1,20 @@
package com.aryan.reader.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import com.aryan.reader.toSharedBookItem
import com.aryan.reader.shared.SmartCollectionEngine as SharedSmartCollectionEngine
@Serializable
enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG }
@Serializable
enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN }
@Serializable
data class SmartRule(
val field: SmartField,
val operator: SmartOperator,
val value: String
)
@Serializable
data class SmartCollectionDefinition(
val matchAll: Boolean = true,
val rules: List<SmartRule> = emptyList()
)
typealias SmartField = com.aryan.reader.shared.SmartField
typealias SmartOperator = com.aryan.reader.shared.SmartOperator
typealias SmartRule = com.aryan.reader.shared.SmartRule
typealias SmartCollectionDefinition = com.aryan.reader.shared.SmartCollectionDefinition
object SmartCollectionEngine {
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
fun toJson(definition: SmartCollectionDefinition): String =
SharedSmartCollectionEngine.toJson(definition)
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
fun fromJson(json: String?): SmartCollectionDefinition? =
SharedSmartCollectionEngine.fromJson(json)
fun fromJson(json: String?): SmartCollectionDefinition? {
if (json.isNullOrBlank()) return null
return try {
this.json.decodeFromString<SmartCollectionDefinition>(json)
} catch (_: Exception) { null }
}
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean {
if (definition.rules.isEmpty()) return false
val results = definition.rules.map { rule ->
when (rule.field) {
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
SmartField.AUTHOR -> evaluateString(book.author ?: "", rule)
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
SmartField.FOLDER -> evaluateString(book.sourceFolderUri ?: "", rule)
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
}
}
return if (definition.matchAll) results.all { it } else results.any { it }
}
private fun evaluateString(target: String, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
else -> false
}
}
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
val ruleValue = rule.value.toFloatOrNull() ?: return false
return when (rule.operator) {
SmartOperator.EQUALS -> target == ruleValue
SmartOperator.GREATER_THAN -> target > ruleValue
SmartOperator.LESS_THAN -> target < ruleValue
else -> false
}
}
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
else -> false
}
}
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean =
SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition)
}

View file

@ -26,6 +26,9 @@ import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import org.w3c.dom.Element
import org.w3c.dom.Node
@ -42,6 +45,8 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class EpubParser(private val context: Context) {
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
data class EpubDocument(
val metadata: Node, val manifest: Node, val spine: Node, val opfFilePath: String
)
@ -67,6 +72,15 @@ class EpubParser(private val context: Context) {
val depth: Int
)
@Serializable
private data class EpubExtractionCacheManifest(
val bookId: String,
val originalBookNameHint: String,
val parserVersion: Int,
val parseContent: Boolean,
val shouldUseToc: Boolean
)
// EpubFile can still represent in-memory file data during initial parsing before extraction
data class EpubFile(val absPath: String, val data: ByteArray) {
override fun equals(other: Any?): Boolean {
@ -92,6 +106,9 @@ class EpubParser(private val context: Context) {
companion object {
const val TAG = "EpubParser"
private const val BOOK_METADATA_FILE = "book_metadata.json"
private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
private const val EPUB_EXTRACTION_CACHE_VERSION = 1
}
internal val String.decodedURL: String
@ -173,8 +190,24 @@ class EpubParser(private val context: Context) {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId")
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
val shouldDeleteExtractionDir = !parseContent && extractionDirOverride == null
val extractionDir = if (extractionDirOverride != null) {
ImportedFileCache.prepareDirectory(extractionDirOverride)
} else if (!parseContent) {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
} else {
val activeDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
readCachedEpubBook(
extractionDir = activeDir,
bookId = bookId,
originalBookNameHint = originalBookNameHint,
shouldUseToc = shouldUseToc
)?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
}
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
val filesMap: Map<String, EpubFile>
@ -190,10 +223,80 @@ class EpubParser(private val context: Context) {
val document = createEpubDocument(filesMap)
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
originalBookNameHint, parseContent)
if (parseContent && extractionDirOverride == null) {
writeCachedEpubBook(
extractionDir = extractionDir,
bookId = bookId,
originalBookNameHint = originalBookNameHint,
shouldUseToc = shouldUseToc,
book = book
)
}
if (shouldDeleteExtractionDir) {
extractionDir.deleteRecursively()
}
return@withContext book
}
}
private fun readCachedEpubBook(
extractionDir: File,
bookId: String,
originalBookNameHint: String,
shouldUseToc: Boolean
): EpubBook? {
val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
if (!metadataFile.isFile || !manifestFile.isFile) return null
return try {
val manifest = jsonSerializer.decodeFromString<EpubExtractionCacheManifest>(manifestFile.readText())
val isCompatible = manifest.bookId == bookId &&
manifest.originalBookNameHint == originalBookNameHint &&
manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
manifest.parseContent &&
manifest.shouldUseToc == shouldUseToc
if (!isCompatible) {
Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
return null
}
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
cachedBook.takeIf { it.hasReadableExtractedContent() }
} catch (e: Exception) {
Timber.e(e, "Failed to read EPUB extraction cache for bookId=$bookId")
null
}
}
private fun writeCachedEpubBook(
extractionDir: File,
bookId: String,
originalBookNameHint: String,
shouldUseToc: Boolean,
book: EpubBook
) {
try {
File(extractionDir, BOOK_METADATA_FILE).writeText(jsonSerializer.encodeToString(book))
File(extractionDir, CACHE_MANIFEST_FILE).writeText(
jsonSerializer.encodeToString(
EpubExtractionCacheManifest(
bookId = bookId,
originalBookNameHint = originalBookNameHint,
parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
parseContent = true,
shouldUseToc = shouldUseToc
)
)
)
} catch (e: Exception) {
Timber.e(e, "Failed to write EPUB extraction cache for bookId=$bookId")
}
}
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> {
val filesMap = mutableMapOf<String, EpubFile>()
zipFile.use { zf ->

View file

@ -24,7 +24,11 @@ class Fb2Parser(private val context: Context) {
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
?: if (parseContent) {
ImportedFileCache.prepareActiveBookDir(context, bookId)
} else {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
}
var streamToParse = inputStream
try {

View file

@ -17,10 +17,18 @@ object ImportedFileCache {
return File(context.cacheDir, activeBookDirName(bookId))
}
fun prepareActiveBookDir(context: Context, bookId: String): File {
fun ensureActiveBookDir(context: Context, bookId: String): File {
return activeBookDir(context, bookId).also { it.mkdirs() }
}
fun resetActiveBookDir(context: Context, bookId: String): File {
return prepareDirectory(activeBookDir(context, bookId))
}
fun prepareActiveBookDir(context: Context, bookId: String): File {
return resetActiveBookDir(context, bookId)
}
fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File {
val dirName = buildString {
append(TEMP_PREFIX)

View file

@ -169,7 +169,11 @@ class MobiParser(private val context: Context) {
val bookAuthor = parsedData.author ?: "Unknown Author"
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
?: if (parseContent) {
ImportedFileCache.prepareActiveBookDir(context, bookId)
} else {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
}
val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") }

View file

@ -33,7 +33,11 @@ class OdtParser(private val context: Context) {
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
?: if (parseContent) {
ImportedFileCache.prepareActiveBookDir(context, bookId)
} else {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
}
val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName)

View file

@ -101,7 +101,7 @@ class SingleFileImporter(private val context: Context) {
try {
FileOutputStream(tempFile).bufferedWriter().use { writer ->
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<title>${originalBookNameHint}</title>\n")
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<title>${generatedHtmlTitle(originalBookNameHint)}</title>\n")
if (isCsv) {
writer.write("<style>\ntable { border-collapse: collapse; width: 100%; font-family: sans-serif; }\nth, td { border: 1px solid currentColor; padding: 8px; }\n</style>\n")
@ -189,18 +189,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached MD, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown START | file=$originalBookNameHint")
@ -323,18 +325,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached TXT, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[TXT] parsePlainText START | file=$originalBookNameHint")
@ -484,18 +488,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached HTML, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint")
@ -511,6 +517,7 @@ class SingleFileImporter(private val context: Context) {
var inStyle = false
var inBody = false
var pageNum = 1
val headBuilder = java.lang.StringBuilder()
val currentChapterBuilder = java.lang.StringBuilder()
var line: String?
@ -531,19 +538,9 @@ class SingleFileImporter(private val context: Context) {
}
if (!inBody) {
if (trimmed.startsWith("<title", ignoreCase = true)) {
val t = trimmed.substringAfter(">").substringBefore("</title>")
if (t.isNotBlank()) title = t
}
val authorMatch = Regex("<meta[^>]+name=\"author\"[^>]+content=\"([^\"]+)\"").find(
line
)
?: Regex("<meta[^>]+property=\"article:author\"[^>]+content=\"([^\"]+)\"").find(
line
)
if (authorMatch != null) {
author = authorMatch.groupValues[1]
}
headBuilder.append(line).append('\n')
extractHtmlTitle(headBuilder.toString())?.let { title = it }
extractHtmlAuthor(headBuilder.toString())?.let { author = it }
if (trimmed.startsWith("<style", ignoreCase = true)) {
inStyle = true
@ -576,7 +573,7 @@ class SingleFileImporter(private val context: Context) {
}
if (trimmed.startsWith("<p") || trimmed.startsWith("<div") ||
trimmed.startsWith("<h") || trimmed.startsWith("<section") ||
trimmed.startsWithHtmlHeadingTag() || trimmed.startsWith("<section") ||
trimmed.contains("<page-break>") ||
(trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("<!"))) {
inBody = true
@ -646,6 +643,54 @@ class SingleFileImporter(private val context: Context) {
return@withContext book
}
private fun String.startsWithHtmlHeadingTag(): Boolean {
return length >= 3 &&
this[0] == '<' &&
this[1].lowercaseChar() == 'h' &&
this[2] in '1'..'6'
}
private fun generatedHtmlTitle(originalBookNameHint: String): String {
if (!originalBookNameHint.endsWith(".txt", ignoreCase = true)) return originalBookNameHint
val innerName = originalBookNameHint.dropLast(4)
return if (innerName.contains('.') && com.aryan.reader.isCodeOrDataFileName(innerName)) {
innerName
} else {
originalBookNameHint
}
}
private fun extractHtmlTitle(line: String): String? {
val match = Regex(
pattern = "<\\s*title\\b[^>]*>(.*?)<\\s*/\\s*title\\s*>",
options = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
).find(line) ?: return null
return Jsoup.parse(match.groupValues[1]).text().takeIf { it.isNotBlank() }
}
private fun extractHtmlAuthor(line: String): String? {
val metaTag = Regex(
pattern = "<\\s*meta\\b[^>]*>",
options = setOf(RegexOption.IGNORE_CASE)
).find(line)?.value ?: return null
val name = Regex(
pattern = "\\b(?:name|property)\\s*=\\s*['\"]([^'\"]+)['\"]",
options = setOf(RegexOption.IGNORE_CASE)
).find(metaTag)?.groupValues?.get(1) ?: return null
if (!name.equals("author", ignoreCase = true) && !name.equals("article:author", ignoreCase = true)) {
return null
}
return Regex(
pattern = "\\bcontent\\s*=\\s*['\"]([^'\"]+)['\"]",
options = setOf(RegexOption.IGNORE_CASE)
).find(metaTag)?.groupValues?.get(1)?.takeIf { it.isNotBlank() }
}
private fun sanitizeHtmlFragment(html: String): String {
return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
}
@ -672,18 +717,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached DOCX, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")

View file

@ -70,58 +70,16 @@ import androidx.core.content.edit
import androidx.core.text.HtmlCompat
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import com.aryan.reader.shared.EpubAnnotationSerializer
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
data class Bookmark(
val cfi: String,
val chapterTitle: String,
val label: String? = null,
val snippet: String,
val pageInChapter: Int?,
val totalPagesInChapter: Int?,
val chapterIndex: Int
)
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
RED("red", Color(0xFFD32F2F), "user-highlight-red"),
PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"),
ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"),
CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"),
MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"),
LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"),
PINK("pink", Color(0xFFE91E63), "user-highlight-pink"),
TEAL("teal", Color(0xFF00796B), "user-highlight-teal"),
INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"),
BLACK("black", Color(0xFF424242), "user-highlight-black"),
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white");
}
data class UserHighlight(
val id: String = UUID.randomUUID().toString(),
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int,
val note: String? = null
)
typealias Bookmark = com.aryan.reader.shared.EpubBookmark
typealias HighlightColor = com.aryan.reader.shared.HighlightColor
typealias UserHighlight = com.aryan.reader.shared.UserHighlight
fun escapeJsString(value: String): String {
return value
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
return com.aryan.reader.shared.escapeJsString(value)
}
fun saveHighlightPalette(context: Context, palette: List<HighlightColor>) {
@ -146,60 +104,21 @@ fun loadHighlightPalette(context: Context): List<HighlightColor> {
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
val stringSetToParse: Collection<String> = if (bookmarksJson != null) {
try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).map { jsonArray.getString(it) }
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from ViewModel")
emptyList()
}
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson, chapters.map { it.title })
} else {
val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE)
val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}"
prefs.getStringSet(key, emptySet()) ?: emptySet()
}
return stringSetToParse.mapNotNull { jsonString ->
try {
val json = JSONObject(jsonString)
val chapterIndex = if (json.has("chapterIndex")) {
json.getInt("chapterIndex")
} else {
val chapterTitle = json.getString("chapterTitle")
chapters.indexOfFirst { it.title == chapterTitle }.coerceAtLeast(0)
}
Bookmark(
cfi = json.getString("cfi"),
chapterTitle = json.getString("chapterTitle"),
label = if (json.has("label")) json.getString("label") else null,
snippet = json.getString("snippet"),
pageInChapter = if (json.has("pageInChapter")) json.optInt("pageInChapter") else null,
totalPagesInChapter = if (json.has("totalPagesInChapter")) json.optInt("totalPagesInChapter") else null,
chapterIndex = chapterIndex
)
} catch (_: Exception) {
null
}
}.toSet()
return EpubAnnotationSerializer.parseBookmarkEntries(stringSetToParse, chapters.map { it.title })
}
fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<UserHighlight>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
prefs.edit { putString(key, jsonArray.toString()) }
prefs.edit { putString(key, EpubAnnotationSerializer.highlightsToJson(highlights)) }
}
fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighlight> {
@ -207,72 +126,19 @@ fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighl
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonString = prefs.getString(key, "[]") ?: "[]"
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error loading highlights")
}
return list
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
}
fun parseHighlightsJson(jsonString: String?): List<UserHighlight> {
if (jsonString.isNullOrBlank()) return emptyList()
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error parsing highlights JSON")
}
return list
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
}
fun highlightsToJson(highlights: List<UserHighlight>): String {
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
return jsonArray.toString()
return EpubAnnotationSerializer.highlightsToJson(highlights)
}
fun bookmarksToJson(bookmarks: Collection<Bookmark>): String {
return EpubAnnotationSerializer.bookmarksToJson(bookmarks)
}
fun clearHighlightsFromPrefs(context: Context, bookTitle: String) {
@ -291,28 +157,13 @@ fun processAndAddHighlight(
chapterIndex: Int,
currentList: MutableList<UserHighlight>
): String {
// Scenario: Exact match -> Update color and text instead of stacking identical spans
val exactMatchIndex = currentList.indexOfFirst {
it.chapterIndex == chapterIndex && it.cfi == newCfi
}
if (exactMatchIndex != -1) {
val existing = currentList[exactMatchIndex]
currentList[exactMatchIndex] = existing.copy(color = newColor, text = newText)
return existing.cfi
}
// Scenarios: Partial overlap or subsumption -> Add independently
currentList.add(
UserHighlight(
cfi = newCfi,
text = newText,
color = newColor,
return EpubAnnotationSerializer.processAndAddHighlight(
newCfi = newCfi,
newText = newText,
newColor = newColor,
chapterIndex = chapterIndex,
note = null
currentList = currentList
)
)
return newCfi
}
// --- UI Components ---

View file

@ -167,7 +167,8 @@ enum class ReaderTool(val title: String, val category: String) {
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
}
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@ -282,6 +283,7 @@ fun EpubReaderTopBar(
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
@ -678,6 +680,23 @@ fun EpubReaderTopBar(
)
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = {
showMoreMenu = false
onOpenTtsReplacements()
},
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
}
}

View file

@ -791,7 +791,8 @@ private fun HighlightsList(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!highlight.note.isNullOrBlank()) {
val note = highlight.note
if (!note.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
@ -799,7 +800,7 @@ private fun HighlightsList(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = highlight.note,
text = note,
style = MaterialTheme.typography.bodySmall.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant

View file

@ -166,6 +166,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.TtsWordReplacementsSheet
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.countWords
import com.aryan.reader.isByokCloudTtsAvailable
@ -177,6 +178,7 @@ import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
@ -195,12 +197,16 @@ import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
@ -570,6 +576,7 @@ fun EpubReaderScreen(
}
}
} else null,
stableBookId = uiState.selectedBookId,
viewModel = viewModel
)
}
@ -600,6 +607,7 @@ fun EpubReaderHost(
onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
stableBookId: String? = null,
viewModel: MainViewModel
) {
val view = LocalView.current
@ -668,11 +676,16 @@ fun EpubReaderHost(
)
}
val locatorConverter = remember(context) {
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val locatorConverter = remember(context, readerCacheBookId) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
proto = ProtoBuf { serializersModule = semanticBlockModule },
context = context
context = context,
stableBookId = readerCacheBookId
)
}
@ -698,9 +711,7 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
val bookId = remember(epubBook.title, epubBook.fileName) {
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val bookId = readerCacheBookId
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) {
@ -895,6 +906,8 @@ fun EpubReaderHost(
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
var paginatedReconfigurationAnchor by remember { mutableStateOf<Locator?>(null) }
var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
@ -910,18 +923,7 @@ fun EpubReaderHost(
LaunchedEffect(bookmarks) {
Timber.d("Bookmarks changed, saving...")
val stringSet = bookmarks.map { bookmark ->
JSONObject().apply {
put("cfi", bookmark.cfi)
put("chapterTitle", bookmark.chapterTitle)
put("label", bookmark.label)
put("snippet", bookmark.snippet)
bookmark.pageInChapter?.let { put("pageInChapter", it) }
bookmark.totalPagesInChapter?.let { put("totalPagesInChapter", it) }
put("chapterIndex", bookmark.chapterIndex)
}.toString()
}
onBookmarksChanged(JSONArray(stringSet).toString())
onBookmarksChanged(bookmarksToJson(bookmarks))
}
var activeBookmarkInVerticalView by remember { mutableStateOf<Bookmark?>(null) }
@ -1209,9 +1211,15 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsReplacementsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) }
var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) }
val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next ->
ttsReplacementPreferences = next
saveTtsReplacementPreferences(context, next)
}
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
@ -1350,7 +1358,7 @@ fun EpubReaderHost(
}
Timber.tag("TTS_LOCATE")
.d("Saving locator from TTS. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress")
.d("Saving resolved locator position. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress")
onSavePosition(locator, cfiForWebView, progress)
}
@ -1535,7 +1543,7 @@ fun EpubReaderHost(
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsChapterIndex = chapterIndex
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -1588,7 +1596,11 @@ fun EpubReaderHost(
val relativeOffset = startOffset - target.startOffsetInSource
val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length)
val slicedText = target.text.substring(safeRelativeOffset)
val newChunk = target.copy(text = slicedText, startOffsetInSource = startOffset)
val newChunk = target.copy(
text = slicedText,
startOffsetInSource = startOffset,
spokenText = slicedText,
)
val remainingChunks = mutableListOf(newChunk)
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
@ -1599,7 +1611,7 @@ fun EpubReaderHost(
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start(
chunks = remainingChunks,
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -1662,7 +1674,9 @@ fun EpubReaderHost(
currentTtsMode = currentTtsMode,
getAuthToken = { viewModel.getAuthToken() },
locatorConverter = locatorConverter,
epubBook = epubBook
epubBook = epubBook,
ttsReplacementPreferences = ttsReplacementPreferences,
ttsReplacementBookId = bookId
)
TtsHighlightHandler(
@ -2045,8 +2059,26 @@ fun EpubReaderHost(
}
}
LaunchedEffect(paginatedPagerState.currentPage, paginator) {
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
LaunchedEffect(paginatedPagerState, paginator, currentRenderMode, isPagerInitialized, isPaginatedReconfigurationRestoring) {
if (currentRenderMode != RenderMode.PAGINATED || paginator == null || !isPagerInitialized) {
return@LaunchedEffect
}
snapshotFlow { paginatedPagerState.currentPage }
.collectLatest { page ->
if (!isPaginatedReconfigurationRestoring) {
(paginator as? BookPaginator)?.getLocatorForPage(page)?.let { locator ->
lastKnownLocator = locator
}
}
}
}
LaunchedEffect(paginatedPagerState.currentPage, paginator, isPaginatedReconfigurationRestoring) {
if (currentRenderMode == RenderMode.PAGINATED &&
paginator != null &&
isPagerInitialized &&
!isPaginatedReconfigurationRestoring
) {
delay(1500L)
val pageToSave = paginatedPagerState.currentPage
@ -2164,12 +2196,21 @@ fun EpubReaderHost(
RenderMode.PAGINATED -> {
scope.launch {
val pageToSave = paginatedPagerState.currentPage
val locator = (paginator as? BookPaginator)?.getLocatorForPage(pageToSave)
val pageLocator = if (isPaginatedReconfigurationRestoring) {
null
} else {
(paginator as? BookPaginator)?.getLocatorForPage(pageToSave)
}
val locator = pageLocator ?: paginatedReconfigurationAnchor ?: lastKnownLocator
val chapterIndex = paginator?.findChapterIndexForPage(pageToSave)
if (locator != null && chapterIndex != null) {
if (locator != null) {
val bookPaginator = paginator as? BookPaginator
val progress = if (totalBookLengthChars > 0 && bookPaginator != null) {
val progress = if (pageLocator == null || chapterIndex == null) {
saveResolvedLocatorPosition(locator, null)
onNavigateBack()
return@launch
} else if (totalBookLengthChars > 0 && bookPaginator != null) {
val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() }
val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it }
val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter)
@ -2186,7 +2227,7 @@ fun EpubReaderHost(
)
onSavePosition(locator, null, progress)
} else {
Timber.w("Final save for paginated view failed. Locator or chapter index is null."
Timber.w("Final save for paginated view failed. Locator is null."
)
}
onNavigateBack()
@ -3570,7 +3611,7 @@ fun EpubReaderHost(
ttsChapterIndex = targetChapterIndex
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -3886,6 +3927,7 @@ fun EpubReaderHost(
) {
PaginatedReaderScreen(
book = epubBook,
bookId = readerCacheBookId,
isDarkTheme = isDarkTheme,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
@ -3910,7 +3952,18 @@ fun EpubReaderHost(
activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
fallbackLocatorForReconfiguration = paginatedReconfigurationAnchor ?: lastKnownLocator,
onReconfigurationAnchorCaptured = { locator ->
paginatedReconfigurationAnchor = locator
lastKnownLocator = locator
},
onReconfigurationRestoreActiveChanged = { isActive ->
isPaginatedReconfigurationRestoring = isActive
if (!isActive) {
paginatedReconfigurationAnchor = null
}
},
modifier = Modifier.alpha(if (isPagerInitialized && !isPaginatedReconfigurationRestoring) 1f else 0f),
onPaginatorReady = { newPaginator ->
paginator = newPaginator
},
@ -4624,6 +4677,7 @@ fun EpubReaderHost(
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenTtsReplacements = { showTtsReplacementsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
@ -5259,6 +5313,15 @@ fun EpubReaderHost(
)
}
TtsWordReplacementsSheet(
isVisible = showTtsReplacementsSheet,
bookId = bookId,
bookTitle = epubBook.title,
preferences = ttsReplacementPreferences,
onPreferencesChange = updateTtsReplacementPreferences,
onDismiss = { showTtsReplacementsSheet = false },
)
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,

View file

@ -37,9 +37,11 @@ import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import com.aryan.reader.withTtsReplacements
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -115,7 +117,9 @@ fun TtsSessionObserver(
currentTtsMode: TtsMode,
getAuthToken: suspend () -> String?,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW
epubBook: com.aryan.reader.epub.EpubBook // NEW
epubBook: com.aryan.reader.epub.EpubBook, // NEW
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
val currentRenderModeState = rememberUpdatedState(currentRenderMode)
val loadedChunkCountState = rememberUpdatedState(loadedChunkCount)
@ -131,6 +135,8 @@ fun TtsSessionObserver(
val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange)
val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW
val epubBookState = rememberUpdatedState(epubBook) // NEW
val ttsReplacementPreferencesState = rememberUpdatedState(ttsReplacementPreferences)
val ttsReplacementBookIdState = rememberUpdatedState(ttsReplacementBookId)
DisposableEffect(ttsController) {
val job = scope.launch {
@ -168,7 +174,9 @@ fun TtsSessionObserver(
ttsController = ttsController,
scope = this,
locatorConverter = locatorConverterState.value,
epubBook = epubBookState.value
epubBook = epubBookState.value,
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
ttsReplacementBookId = ttsReplacementBookIdState.value
)
} else if (currentRenderModeState.value == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
@ -182,7 +190,9 @@ fun TtsSessionObserver(
onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
scope = this,
ttsMode = currentTtsMode,
getAuthToken = getAuthToken
getAuthToken = getAuthToken,
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
ttsReplacementBookId = ttsReplacementBookIdState.value
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
@ -303,7 +313,9 @@ private fun handleVerticalAutoAdvance(
ttsController: TtsController,
scope: CoroutineScope,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter,
epubBook: com.aryan.reader.epub.EpubBook
epubBook: com.aryan.reader.epub.EpubBook,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
if (currentTtsChapterIndex == null) return
@ -323,7 +335,7 @@ private fun handleVerticalAutoAdvance(
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
val token = getAuthToken()
ttsController.start(
chunks = remainingChunks,
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
@ -355,7 +367,7 @@ private fun handleVerticalAutoAdvance(
onUpdateTtsChapter(nextIdx)
ttsController.start(
chunks = nativeChunks,
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
@ -399,7 +411,9 @@ private fun handlePaginatedAutoAdvance(
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope,
ttsMode: TtsMode,
getAuthToken: suspend () -> String?
getAuthToken: suspend () -> String?,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...")
@ -432,7 +446,7 @@ private fun handlePaginatedAutoAdvance(
val token = getAuthToken()
ttsController.start(
chunks = nextChapterChunks,
chunks = nextChapterChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,

View file

@ -1,96 +1,9 @@
// OpdsModels.kt
package com.aryan.reader.opds
data class OpdsCatalog(
val id: String,
val title: String,
val url: String,
val isDefault: Boolean = false,
val username: String? = null,
val password: String? = null
)
data class OpdsFacet(
val title: String,
val group: String,
val url: String,
val isActive: Boolean
)
data class OpdsFeed(
val title: String,
val entries: List<OpdsEntry>,
val nextUrl: String?,
val searchUrl: String? = null,
val facets: List<OpdsFacet> = emptyList()
)
data class OpdsAuthor(
val name: String,
val url: String?
)
data class OpdsAcquisition(
val url: String,
val mimeType: String
) {
val formatName: String
get() = when {
mimeType.contains("epub") -> "EPUB"
mimeType.contains("pdf") -> "PDF"
mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD"
mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML"
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
mimeType.contains("cbr") || mimeType.contains("rar") -> "CBR"
mimeType.contains("txt") || mimeType.contains("text/plain") -> "TXT"
else -> mimeType.substringAfterLast("/").uppercase()
}
val priority: Int
get() = when (formatName) {
"EPUB" -> 5
"PDF" -> 4
"MOBI" -> 3
"FB2" -> 2
"MD", "HTML" -> 2
"CBZ" -> 1
"TXT" -> 0
else -> -1
}
}
data class OpdsEntry(
val id: String,
val title: String,
val summary: String?,
val authors: List<OpdsAuthor> = emptyList(),
val coverUrl: String?,
val acquisitions: List<OpdsAcquisition> = emptyList(),
val navigationUrl: String?,
val publisher: String? = null,
val published: String? = null,
val language: String? = null,
val series: String? = null,
val seriesIndex: String? = null,
val categories: List<String> = emptyList(),
// ADD THESE:
val pseCount: Int? = null,
val pseUrlTemplate: String? = null
) {
val author: String?
get() = authors.firstOrNull()?.name
val bestAcquisition: OpdsAcquisition?
get() = acquisitions.maxByOrNull { it.priority }
val isAcquisition: Boolean
get() = acquisitions.isNotEmpty()
val isNavigation: Boolean
get() = navigationUrl != null && acquisitions.isEmpty()
val isStreamable: Boolean
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
}
typealias OpdsCatalog = com.aryan.reader.shared.opds.OpdsCatalog
typealias OpdsFacet = com.aryan.reader.shared.opds.OpdsFacet
typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed
typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor
typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition
typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry
typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState

View file

@ -1,486 +1,3 @@
// OpdsParser.kt
package com.aryan.reader.opds
import android.util.Xml
import org.json.JSONArray
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.io.InputStream
import java.util.UUID
class OpdsParser {
fun parse(bodyString: String, baseUrl: String): OpdsFeed {
val trimmed = bodyString.trimStart()
return if (trimmed.startsWith("{")) {
Timber.tag("OpdsDebug").d("Detected OPDS 2.0 (JSON) feed")
parseOpds2(trimmed, baseUrl)
} else {
Timber.tag("OpdsDebug").d("Detected OPDS 1.x (XML) feed")
parseOpds1(trimmed.byteInputStream(), baseUrl)
}
}
// --- OPDS 2.0 (JSON) Parsing ---
private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed {
val root = JSONObject(jsonString)
val metadata = root.optJSONObject("metadata")
val title = metadata?.optString("title") ?: "OPDS 2.0 Feed"
var nextUrl: String? = null
var searchUrl: String? = null
val facets = mutableListOf<OpdsFacet>()
// Root Links
val links = root.optJSONArray("links")
if (links != null) {
for (i in 0 until links.length()) {
val link = links.getJSONObject(i)
val relArray = link.optJSONArray("rel")
val rels = mutableListOf<String>()
if (relArray != null) {
for (j in 0 until relArray.length()) rels.add(relArray.getString(j))
} else if (link.has("rel")) {
val rel = link.optString("rel")
if (rel.isNotBlank()) rels.add(rel)
}
val href = link.optString("href")
if (href.isNotEmpty()) {
val resolvedHref = resolveUrl(baseUrl, href)
if (rels.contains("next")) {
nextUrl = resolvedHref
} else if (rels.contains("search")) {
searchUrl = resolvedHref
}
}
}
}
// Facets
val facetsArray = root.optJSONArray("facets")
if (facetsArray != null) {
for (i in 0 until facetsArray.length()) {
val facetObj = facetsArray.getJSONObject(i)
val group = facetObj.optJSONObject("metadata")?.optString("title") ?: "Filter"
val facetLinks = facetObj.optJSONArray("links")
if (facetLinks != null) {
for (j in 0 until facetLinks.length()) {
val link = facetLinks.getJSONObject(j)
val href = link.optString("href")
if (href.isNotEmpty()) {
val titleFacet = link.optString("title", "Facet")
val properties = link.optJSONObject("properties")
val active = properties?.optBoolean("active", false) ?: false
facets.add(OpdsFacet(titleFacet, group, resolveUrl(baseUrl, href), active))
}
}
}
}
}
val entries = mutableListOf<OpdsEntry>()
// Publications
val publications = root.optJSONArray("publications")
if (publications != null) {
for (i in 0 until publications.length()) {
entries.add(parseOpds2Publication(publications.getJSONObject(i), baseUrl))
}
}
// Navigation
val navigation = root.optJSONArray("navigation")
if (navigation != null) {
for (i in 0 until navigation.length()) {
entries.add(parseOpds2Navigation(navigation.getJSONObject(i), baseUrl))
}
}
// Groups (Collections containing sub-navigation or sub-publications)
val groups = root.optJSONArray("groups")
if (groups != null) {
for (i in 0 until groups.length()) {
val group = groups.getJSONObject(i)
val groupTitle = group.optJSONObject("metadata")?.optString("title") ?: ""
val groupNav = group.optJSONArray("navigation")
if (groupNav != null) {
for (j in 0 until groupNav.length()) {
entries.add(parseOpds2Navigation(groupNav.getJSONObject(j), baseUrl))
}
}
val groupPubs = group.optJSONArray("publications")
if (groupPubs != null) {
for (j in 0 until groupPubs.length()) {
entries.add(parseOpds2Publication(groupPubs.getJSONObject(j), baseUrl))
}
}
val groupLinks = group.optJSONArray("links")
if (groupLinks != null) {
for (j in 0 until groupLinks.length()) {
val link = groupLinks.getJSONObject(j)
val href = link.optString("href")
if (href.isNotEmpty()) {
val linkTitle = link.optString("title", groupTitle)
entries.add(OpdsEntry(
id = href,
title = linkTitle,
summary = null,
authors = emptyList(),
coverUrl = null,
acquisitions = emptyList(),
navigationUrl = resolveUrl(baseUrl, href)
))
}
}
}
}
}
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
}
private fun parseOpds2Publication(pub: JSONObject, baseUrl: String): OpdsEntry {
val metadata = pub.optJSONObject("metadata")
val title = metadata?.optString("title") ?: "Unknown Title"
val id = metadata?.optString("identifier") ?: pub.optString("id", UUID.randomUUID().toString())
val summary = metadata?.optString("description") ?: metadata?.optString("summary")
val language = metadata?.optString("language")
val publisher = metadata?.optString("publisher")
val published = metadata?.optString("published")
val authors = mutableListOf<OpdsAuthor>()
val authorObj = metadata?.opt("author")
if (authorObj is String) {
authors.add(OpdsAuthor(authorObj, null))
} else if (authorObj is JSONArray) {
for (i in 0 until authorObj.length()) {
val item = authorObj.get(i)
if (item is String) authors.add(OpdsAuthor(item, null))
else if (item is JSONObject) {
val name = item.optString("name")
var uri: String? = null
val links = item.optJSONArray("links")
if (links != null && links.length() > 0) {
uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
}
if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
}
}
} else if (authorObj is JSONObject) {
val name = authorObj.optString("name")
var uri: String? = null
val links = authorObj.optJSONArray("links")
if (links != null && links.length() > 0) {
uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
}
if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
}
val categories = mutableListOf<String>()
when (val subjectObj = metadata?.opt("subject")) {
is String -> categories.add(subjectObj)
is JSONArray -> {
for (i in 0 until subjectObj.length()) {
val subj = subjectObj.get(i)
if (subj is String) categories.add(subj)
else if (subj is JSONObject) categories.add(subj.optString("name"))
}
}
is JSONObject -> {
categories.add(subjectObj.optString("name"))
}
}
var series: String? = null
var seriesIndex: String? = null
val belongsTo = metadata?.optJSONObject("belongsTo")
if (belongsTo != null) {
val seriesObj = belongsTo.opt("series")
if (seriesObj is String) {
series = seriesObj
} else if (seriesObj is JSONObject) {
series = seriesObj.optString("name")
if (seriesObj.has("position")) {
seriesIndex = seriesObj.optDouble("position").toString().removeSuffix(".0")
}
} else if (seriesObj is JSONArray && seriesObj.length() > 0) {
val firstSeries = seriesObj.get(0)
if (firstSeries is String) {
series = firstSeries
} else if (firstSeries is JSONObject) {
series = firstSeries.optString("name")
if (firstSeries.has("position")) {
seriesIndex = firstSeries.optDouble("position").toString().removeSuffix(".0")
}
}
}
}
var coverUrl: String? = null
val images = pub.optJSONArray("images")
if (images != null && images.length() > 0) {
for (i in 0 until images.length()) {
val image = images.getJSONObject(i)
val href = image.optString("href")
if (href.isNotEmpty()) {
val resolvedHref = resolveUrl(baseUrl, href)
if (coverUrl == null) coverUrl = resolvedHref
val rels = image.opt("rel")
var isCover = false
if (rels is String && rels == "cover") isCover = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j) == "cover") isCover = true
}
if (isCover) {
coverUrl = resolvedHref
break
}
}
}
}
val acquisitions = mutableListOf<OpdsAcquisition>()
var pseCount: Int? = null
var pseUrlTemplate: String? = null
val links = pub.optJSONArray("links")
if (links != null) {
for (i in 0 until links.length()) {
val link = links.getJSONObject(i)
val href = link.optString("href")
if (href.isNotEmpty()) {
val rels = link.opt("rel")
var isStream = false
if (rels is String && rels == "http://vaemendis.net/opds-pse/stream") isStream = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j) == "http://vaemendis.net/opds-pse/stream") isStream = true
}
if (isStream) {
pseUrlTemplate = resolveUrl(baseUrl, href)
val properties = link.optJSONObject("properties")
pseCount = properties?.optInt("numberOfItems")?.takeIf { it > 0 }
}
var isAcquisition = false
if (rels is String && rels.contains("acquisition")) isAcquisition = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j).contains("acquisition")) isAcquisition = true
}
if (isAcquisition) {
val type = link.optString("type") ?: ""
acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), type))
}
}
}
}
return OpdsEntry(
id = id, title = title, summary = summary, authors = authors,
coverUrl = coverUrl, acquisitions = acquisitions,
navigationUrl = null, publisher = publisher, published = published,
language = language, series = series, seriesIndex = seriesIndex, categories = categories,
pseCount = pseCount, pseUrlTemplate = pseUrlTemplate
)
}
private fun parseOpds2Navigation(nav: JSONObject, baseUrl: String): OpdsEntry {
val title = nav.optString("title", "Unknown")
val href = nav.optString("href")
val summary = nav.optString("description")
val navigationUrl = if (href.isNotEmpty()) resolveUrl(baseUrl, href) else null
return OpdsEntry(
id = href, title = title, summary = summary, authors = emptyList(),
coverUrl = null, acquisitions = emptyList(),
navigationUrl = navigationUrl
)
}
// --- OPDS 1.x (XML) Parsing ---
private fun parseOpds1(inputStream: InputStream, baseUrl: String): OpdsFeed {
return inputStream.use {
val parser: XmlPullParser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(it, null)
parser.nextTag()
Timber.tag("OpdsDebug").d($$"Parser started at root tag: <${parser.name}>")
readFeed(parser, baseUrl)
}
}
private fun readFeed(parser: XmlPullParser, baseUrl: String): OpdsFeed {
var title = ""
var nextUrl: String? = null
var searchUrl: String? = null
val entries = mutableListOf<OpdsEntry>()
val facets = mutableListOf<OpdsFacet>()
parser.require(XmlPullParser.START_TAG, null, "feed")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name.substringAfter(":")) {
"title" -> title = readText(parser)
"entry" -> entries.add(readEntry(parser, baseUrl))
"link" -> {
val rel = parser.getAttributeValue(null, "rel")
val href = parser.getAttributeValue(null, "href")
val linkTitle = parser.getAttributeValue(null, "title")
val facetGroup = parser.getAttributeValue(null, "opds:facetGroup") ?: "Filter"
val activeFacet = parser.getAttributeValue(null, "opds:activeFacet") == "true"
if (rel == "next") {
nextUrl = resolveUrl(baseUrl, href ?: "")
} else if (rel == "search") {
searchUrl = resolveUrl(baseUrl, href ?: "")
} else if (rel == "facet" || rel == "http://opds-spec.org/facet") {
if (href != null && linkTitle != null) {
facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet))
}
}
skip(parser)
}
else -> skip(parser)
}
}
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
}
private fun readEntry(parser: XmlPullParser, baseUrl: String): OpdsEntry {
parser.require(XmlPullParser.START_TAG, null, "entry")
var id = ""; var title = ""; var summary: String? = null
var coverUrl: String? = null; var navigationUrl: String? = null
var publisher: String? = null; var published: String? = null; var language: String? = null
var series: String? = null; var seriesIndex: String? = null
var pseCount: Int? = null
var pseUrlTemplate: String? = null
val authors = mutableListOf<OpdsAuthor>()
val categories = mutableListOf<String>()
val acquisitions = mutableListOf<OpdsAcquisition>()
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (val tagName = parser.name.substringAfter(":")) {
"id" -> id = readText(parser)
"title" -> title = readText(parser)
"summary", "content" -> summary = readText(parser)
"author" -> authors.add(readAuthor(parser, baseUrl))
"publisher" -> publisher = readText(parser)
"language" -> language = language ?: readText(parser)
"issued", "published", "updated" -> {
val date = readText(parser)
if (published == null || tagName != "updated") published = date
}
"category" -> {
val label = parser.getAttributeValue(null, "label")
val term = parser.getAttributeValue(null, "term")
val cat = label ?: term
if (!cat.isNullOrBlank()) categories.add(cat)
skip(parser)
}
"meta" -> {
val property = parser.getAttributeValue(null, "property") ?: parser.getAttributeValue(null, "name")
val content = parser.getAttributeValue(null, "content")
val textContent = readText(parser)
if (property == "calibre:series") series = content ?: textContent.takeIf { it.isNotBlank() }
else if (property == "calibre:series_index") seriesIndex = content ?: textContent.takeIf { it.isNotBlank() }
}
"link" -> {
val rel = parser.getAttributeValue(null, "rel") ?: ""
val href = parser.getAttributeValue(null, "href") ?: ""
val type = parser.getAttributeValue(null, "type") ?: ""
val linkTitle = parser.getAttributeValue(null, "title")
if (rel == "http://vaemendis.net/opds-pse/stream") {
pseUrlTemplate = resolveUrl(baseUrl, href)
val countStr = parser.getAttributeValue(null, "pse:count")
pseCount = countStr?.toIntOrNull()
}
if (rel == "http://calibre-ebook.com/opds/series") {
if (series == null) series = linkTitle
}
if (href.isNotEmpty()) {
val absoluteUrl = resolveUrl(baseUrl, href)
if (rel.contains("http://opds-spec.org/image")) {
if (coverUrl == null || rel.contains("thumbnail")) coverUrl = absoluteUrl
} else if (rel.contains("http://opds-spec.org/acquisition")) {
acquisitions.add(OpdsAcquisition(absoluteUrl, type))
} else if (type.contains("profile=opds-catalog") || type.contains("application/atom+xml")) {
if (navigationUrl == null) navigationUrl = absoluteUrl
} else if (rel == "subsection" || rel == "collection" || rel == "start") {
if (navigationUrl == null) navigationUrl = absoluteUrl
}
}
skip(parser)
}
else -> skip(parser)
}
}
return OpdsEntry(id, title, summary, authors, coverUrl, acquisitions, navigationUrl, publisher, published, language, series, seriesIndex, categories, pseCount, pseUrlTemplate)
}
private fun readAuthor(parser: XmlPullParser, baseUrl: String): OpdsAuthor {
var name = ""
var uri: String? = null
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name.substringAfter(":")) {
"name" -> name = readText(parser)
"uri" -> uri = resolveUrl(baseUrl, readText(parser))
else -> skip(parser)
}
}
return OpdsAuthor(name, uri)
}
private fun readText(parser: XmlPullParser): String {
val result = StringBuilder()
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.TEXT, XmlPullParser.CDSECT, XmlPullParser.ENTITY_REF -> {
result.append(parser.text)
}
XmlPullParser.START_TAG -> depth++
XmlPullParser.END_TAG -> depth--
}
}
return result.toString().trim()
}
private fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) throw java.lang.IllegalStateException()
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
private fun resolveUrl(baseUrl: String, href: String): String {
return try {
val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
} catch (_: Exception) {
href
}
}
}
typealias OpdsParser = com.aryan.reader.shared.opds.SharedOpdsParser

View file

@ -3,12 +3,11 @@ package com.aryan.reader.opds
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.aryan.reader.shared.opds.SharedOpdsCatalogs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.security.MessageDigest
import java.util.UUID
@ -37,76 +36,26 @@ class OpdsRepository(context: Context) {
fun getCatalogs(): List<OpdsCatalog> {
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
val catalogs = mutableListOf<OpdsCatalog>()
if (jsonString != null) {
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
catalogs.add(
OpdsCatalog(
id = obj.getString("id"),
title = obj.getString("title"),
url = obj.getString("url"),
isDefault = obj.optBoolean("isDefault", false),
username = obj.optString("username", "").takeIf { it.isNotBlank() },
password = obj.optString("password", "").takeIf { it.isNotBlank() }
)
)
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
val catalogs = decodedCatalogs.ifEmpty {
SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
}
} catch (e: Exception) {
e.printStackTrace()
}
}
if (catalogs.isEmpty()) {
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true))
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Standard Ebooks", "https://standardebooks.org/feeds/opds", isDefault = true))
if (decodedCatalogs.isEmpty()) {
saveCatalogs(catalogs)
}
return catalogs
}
private fun resolveUrl(baseUrl: String, href: String): String {
return try {
val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
} catch (_: Exception) {
href
}
}
suspend fun getSearchTemplate(openSearchUrl: String): String? = withContext(Dispatchers.IO) {
suspend fun getSearchTemplate(
openSearchUrl: String,
username: String? = null,
password: String? = null
): String? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(openSearchUrl).build()
val response = httpClient.newCall(request).execute()
val response = getAuthenticatedClient(username, password).newCall(request).execute()
val body = response.body?.string() ?: return@withContext null
val parser = android.util.Xml.newPullParser()
parser.setFeature(org.xmlpull.v1.XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(body.byteInputStream(), null)
var eventType = parser.eventType
while (eventType != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) {
if (eventType == org.xmlpull.v1.XmlPullParser.START_TAG && parser.name.equals("Url", ignoreCase = true)) {
val type = parser.getAttributeValue(null, "type")
if (type != null && (type.contains("atom+xml") || type.contains("opds+xml"))) {
val template = parser.getAttributeValue(null, "template")
if (template != null) {
val resolvedTemplate = resolveUrl(openSearchUrl, template)
Timber.tag("OpdsDebug").d("Resolved search template: $resolvedTemplate")
return@withContext resolvedTemplate
}
}
}
eventType = parser.next()
}
null
parser.extractOpenSearchTemplate(body, openSearchUrl)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch OpenSearch template")
null
@ -114,48 +63,28 @@ class OpdsRepository(context: Context) {
}
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
val current = getCatalogs().toMutableList()
current.add(OpdsCatalog(UUID.randomUUID().toString(), title, url, username = username, password = password))
saveCatalogs(current)
saveCatalogs(
SharedOpdsCatalogs.addCatalog(
catalogs = getCatalogs(),
title = title,
url = url,
username = username,
password = password,
idFactory = { UUID.randomUUID().toString() }
)
)
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
val current = getCatalogs().toMutableList()
val index = current.indexOfFirst { it.id == id }
if (index != -1 && !current[index].isDefault) {
current[index] = current[index].copy(
title = title.trim(),
url = url.trim(),
username = username?.trim().takeIf { !it.isNullOrBlank() },
password = password?.trim().takeIf { !it.isNullOrBlank() }
)
saveCatalogs(current)
}
saveCatalogs(SharedOpdsCatalogs.updateCatalog(getCatalogs(), id, title, url, username, password))
}
fun removeCatalog(id: String) {
val current = getCatalogs().toMutableList()
val toRemove = current.find { it.id == id }
if (toRemove?.isDefault == true) {
return
}
current.removeAll { it.id == id }
saveCatalogs(current)
saveCatalogs(SharedOpdsCatalogs.removeCatalog(getCatalogs(), id))
}
private fun saveCatalogs(catalogs: List<OpdsCatalog>) {
val jsonArray = JSONArray()
catalogs.forEach { catalog ->
val obj = JSONObject()
obj.put("id", catalog.id)
obj.put("title", catalog.title)
obj.put("url", catalog.url)
obj.put("isDefault", catalog.isDefault)
if (catalog.username != null) obj.put("username", catalog.username)
if (catalog.password != null) obj.put("password", catalog.password)
jsonArray.put(obj)
}
prefs.edit { putString(KEY_CATALOGS_JSON, jsonArray.toString()) }
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
}
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {

View file

@ -5,7 +5,8 @@ import android.content.Context
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.aryan.reader.resolveFileExtensionSuffixFromName
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
import com.aryan.reader.shared.opds.SharedOpdsSearch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -18,16 +19,6 @@ import okhttp3.Request
import timber.log.Timber
import java.io.File
data class OpdsScreenState(
val catalogs: List<OpdsCatalog> = emptyList(),
val currentCatalog: OpdsCatalog? = null,
val currentFeed: OpdsFeed? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isViewingCatalog: Boolean = false,
val searchUrlTemplate: String? = null
)
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = OpdsRepository(application)
@ -142,42 +133,11 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
}
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
val candidates = listOfNotNull(
response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
Uri.parse(acquisition.url).lastPathSegment
return SharedOpdsDownloadNamer.resolveExtension(
acquisition = acquisition,
contentDisposition = response.header("Content-Disposition"),
urlPathSegment = Uri.parse(acquisition.url).lastPathSegment
)
candidates.forEach { candidate ->
resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
}
return when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"MD" -> ".md"
"HTML" -> ".html"
"TXT" -> ".txt"
else -> ".epub"
}
}
private fun extractContentDispositionFilename(contentDisposition: String): String? {
val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
?.trim()
?.trim('"')
}
init {
@ -233,17 +193,9 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val template = if (!searchLink.contains("{searchTerms}")) {
repository.getSearchTemplate(searchLink) ?: searchLink
} else {
searchLink
}
val finalUrl = if (template.contains("{searchTerms}")) {
template.replace("{searchTerms}", Uri.encode(query))
} else {
val separator = if (template.contains("?")) "&" else "?"
"$template${separator}query=${Uri.encode(query)}"
val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
val catalog = _uiState.value.currentCatalog
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
}
openFeedUrl(finalUrl)

View file

@ -62,7 +62,8 @@ fun androidHtmlToSemanticBlocks(
fontFamilyMap: Map<String, FontFamily>,
constraints: androidx.compose.ui.unit.Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap()
mathSvgCache: Map<String, String> = emptyMap(),
adaptThemeColors: Boolean = false
): List<SemanticBlock> {
return htmlToSemanticBlocks(
html = html,
@ -76,6 +77,7 @@ fun androidHtmlToSemanticBlocks(
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = mathSvgCache,
resourceResolver = AndroidHtmlResourceResolver,
fontFamilyLoader = AndroidHtmlFontFamilyLoader
fontFamilyLoader = AndroidHtmlFontFamilyLoader,
adaptThemeColors = adaptThemeColors
)
}

View file

@ -42,7 +42,10 @@ import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookProcessingInput
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.paginatedreader.data.ConfigurationCache
import com.aryan.reader.paginatedreader.data.LATEST_PAGE_CACHE_VERSION
import com.aryan.reader.paginatedreader.data.LATEST_PROCESSING_VERSION
import com.aryan.reader.paginatedreader.data.PageCacheEntry
import com.aryan.reader.paginatedreader.data.PageIndexEntry
import com.aryan.reader.paginatedreader.data.ProcessedBook
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
@ -80,7 +83,8 @@ data class TtsChunk(
val text: String,
val sourceCfi: String,
val startOffsetInSource: Int,
val timedWords: List<TimedWord> = emptyList()
val timedWords: List<TimedWord> = emptyList(),
val spokenText: String = text
)
private data class PaginationRequest(val chapterIndex: Int, val priority: Int) : Comparable<PaginationRequest> {
@ -94,6 +98,26 @@ private data class PaginationRequest(val chapterIndex: Int, val priority: Int) :
}
}
private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F"
private data class TextRangeIndex(
val pageInChapter: Int,
val blockIndex: Int,
val startOffset: Int,
val endOffset: Int
)
private data class PageNavigationEntry(
val pageInChapter: Int,
val firstBlockIndex: Int,
val lastBlockIndex: Int,
val firstTextBlockIndex: Int?,
val firstTextCharOffset: Int,
val firstTextEndOffset: Int,
val firstCfi: String?,
val anchors: Set<String>
)
@OptIn(ExperimentalSerializationApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Stable
@ -138,7 +162,7 @@ class BookPaginator(
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
val chapterStartPageIndices = ConcurrentHashMap<Int, Int>()
private val pageCache = object : LruCache<Int, List<Page>>(6) {
private val pageCache = object : LruCache<Int, List<Page>>(12) {
override fun entryRemoved(evicted: Boolean, key: Int, oldValue: List<Page>, newValue: List<Page>?) {
Timber.d("Chapter $key pages removed from cache. Evicted: $evicted")
}
@ -151,10 +175,15 @@ class BookPaginator(
}
private val chapterCharacterIndex = ConcurrentHashMap<Int, List<PageCharacterRange>>()
private val chapterCumulativeChars = ConcurrentHashMap<Int, List<Long>>()
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
private var pageCountsAreAccurate by mutableStateOf(false)
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
private var currentConfigHash: Int = 0
@Volatile
private var chapterStartSnapshot: IntArray = IntArray(0)
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
@ -198,6 +227,7 @@ class BookPaginator(
val bookRecord = bookCacheDao.getProcessedBook(bookId)
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
Timber.i("Book cache is new or stale. Creating initial record.")
bookCacheDao.deleteEntireBookCache(bookId)
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
bookCacheDao.insertProcessedBook(initialBook)
enqueueBookProcessingWork()
@ -229,8 +259,10 @@ class BookPaginator(
triggerPagination(startChapter, PRIORITY_HIGHEST)
// Queue neighbors with lower priority
if (startChapter + 1 < chapters.size) triggerPagination(startChapter + 1, PRIORITY_LOW)
if (startChapter - 1 >= 0) triggerPagination(startChapter - 1, PRIORITY_LOW)
for (offset in 1..2) {
if (startChapter + offset < chapters.size) triggerPagination(startChapter + offset, PRIORITY_LOW)
if (startChapter - offset >= 0) triggerPagination(startChapter - offset, PRIORITY_LOW)
}
isLoading = false
Timber.i("Paginator initialized. UI is ready.")
@ -238,10 +270,8 @@ class BookPaginator(
}
}
// [ADD this new function]
private fun runEstimator() {
var runningTotal = 0
val tempCounts = mutableMapOf<Int, Int>()
// This loop is extremely fast (math only)
chapters.forEachIndexed { index, chapter ->
@ -255,12 +285,12 @@ class BookPaginator(
chapterPageCounts[index] = estimatedCount
chapterStartPageIndices[index] = runningTotal
tempCounts[index] = estimatedCount
runningTotal += estimatedCount
}
totalPageCount = runningTotal
pageCountsAreAccurate = false
rebuildChapterStartSnapshot()
Timber.i("Estimator finished. Estimated total pages: $totalPageCount")
}
@ -287,6 +317,11 @@ class BookPaginator(
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
append("-vm:$verticalMarginMultiplier")
append("-proc:$LATEST_PROCESSING_VERSION")
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
append("-ua:${userAgentStylesheet.hashCode()}")
append("-css:${bookCss.hashCode()}")
append("-fonts:${allFontFaces.hashCode()}")
}
val hash = configString.hashCode()
return hash
@ -325,6 +360,7 @@ class BookPaginator(
}
totalPageCount = runningTotal
pageCountsAreAccurate = countsMap.size == chapters.size
rebuildChapterStartSnapshot()
}
private suspend fun updateAndSaveConfigurationCache() {
@ -340,12 +376,204 @@ class BookPaginator(
bookCacheDao.insertConfigurationCache(newCache)
bookCacheDao.cleanupOldConfigurations(bookId)
bookCacheDao.cleanupOldPageCaches(bookId)
if (finalizedChapterCounts.size >= chapters.size) {
pageCountsAreAccurate = true
}
}
private fun rebuildChapterStartSnapshot() {
chapterStartSnapshot = IntArray(chapters.size) { index ->
chapterStartPageIndices[index] ?: 0
}
}
private fun chapterContentVersion(chapter: EpubChapter): Int {
val backingFile = java.io.File(extractionBasePath, chapter.htmlFilePath)
return buildString {
append(chapter.absPath)
append('|')
append(chapter.htmlFilePath)
append('|')
append(chapter.htmlContent.length)
append('|')
append(chapter.htmlContent.hashCode())
append('|')
append(chapter.plainTextContent.length)
append('|')
append(chapter.plainTextContent.hashCode())
append('|')
if (backingFile.exists()) {
append(backingFile.length())
append('|')
append(backingFile.lastModified())
}
}.hashCode()
}
private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List<Page>? {
val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null
val expectedContentVersion = chapterContentVersion(chapter)
val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION &&
cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION &&
cachedPages.contentVersion == expectedContentVersion
if (!isCompatible) {
Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.")
return null
}
return try {
val pages = proto.decodeFromByteArray<List<Page>>(cachedPages.pagesProto)
if (pages.size != cachedPages.pageCount) {
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
null
} else {
pageCache.put(chapterIndex, pages)
applyPageRuntimeIndexes(chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size)
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
pages
}
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize page cache for chapter $chapterIndex")
null
}
}
private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List<Page>) {
coroutineScope.launch(Dispatchers.IO) {
try {
val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages)
val cacheEntry = PageCacheEntry(
bookId = bookId,
configHash = currentConfigHash,
chapterIndex = chapterIndex,
processingVersion = LATEST_PROCESSING_VERSION,
pageCacheVersion = LATEST_PAGE_CACHE_VERSION,
contentVersion = chapterContentVersion(chapter),
pageCount = pages.size,
pagesProto = proto.encodeToByteArray(pages)
)
bookCacheDao.insertPageCache(cacheEntry, pageIndexEntries)
Timber.d("Saved measured page cache for chapter $chapterIndex (${pages.size} pages).")
} catch (e: Exception) {
Timber.e(e, "Failed to persist page cache for chapter $chapterIndex")
}
}
}
private fun getAllBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
return blocks.flatMap { block ->
when (block) {
is WrappingContentBlock -> listOf(block, block.floatedImage) + getAllBlocks(block.paragraphsToWrap)
is FlexContainerBlock -> listOf(block) + getAllBlocks(block.children)
is TableBlock -> listOf(block) + block.rows.flatten().flatMap { getAllBlocks(it.content) }
else -> listOf(block)
}
}
}
private fun applyPageRuntimeIndexes(chapterIndex: Int, pages: List<Page>) {
val characterIndex = mutableListOf<PageCharacterRange>()
val textRangeIndex = mutableListOf<TextRangeIndex>()
val navigationEntries = mutableListOf<PageNavigationEntry>()
val anchorPageMap = linkedMapOf<String, Int>()
val cumulativeCharsPerPage = mutableListOf<Long>()
var runningTotalChars = 0L
pages.forEachIndexed { pageInChapterIndex, page ->
val allBlocksOnPage = getAllBlocks(page.content)
val allTextBlocksOnPage = getAllTextBlocks(page.content)
val anchors = allBlocksOnPage.flatMap { findAllIds(it) }.toSet()
anchors.forEach { anchorPageMap.putIfAbsent(it, pageInChapterIndex) }
allTextBlocksOnPage.forEach { block ->
if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) {
val startOffset = block.startCharOffsetInSource
val endOffset = startOffset + block.content.text.length
characterIndex.add(
PageCharacterRange(
pageInChapter = pageInChapterIndex,
cfi = block.cfi!!,
startOffset = startOffset,
endOffset = endOffset
)
)
textRangeIndex.add(
TextRangeIndex(
pageInChapter = pageInChapterIndex,
blockIndex = block.blockIndex,
startOffset = startOffset,
endOffset = endOffset
)
)
}
}
val firstTextBlock = allTextBlocksOnPage.firstOrNull { it.content.text.isNotBlank() }
?: allTextBlocksOnPage.firstOrNull()
val firstBlock = allBlocksOnPage.firstOrNull()
val blockIndices = allBlocksOnPage.map { it.blockIndex }
navigationEntries.add(
PageNavigationEntry(
pageInChapter = pageInChapterIndex,
firstBlockIndex = blockIndices.minOrNull() ?: firstBlock?.blockIndex ?: -1,
lastBlockIndex = blockIndices.maxOrNull() ?: firstBlock?.blockIndex ?: -1,
firstTextBlockIndex = firstTextBlock?.blockIndex,
firstTextCharOffset = firstTextBlock?.startCharOffsetInSource ?: 0,
firstTextEndOffset = firstTextBlock?.let { it.startCharOffsetInSource + it.content.text.length } ?: 0,
firstCfi = firstTextBlock?.cfi ?: firstBlock?.cfi,
anchors = anchors
)
)
runningTotalChars += allTextBlocksOnPage.sumOf { it.content.text.length.toLong() }
cumulativeCharsPerPage.add(runningTotalChars)
}
chapterCharacterIndex[chapterIndex] = characterIndex
chapterTextRangeIndex[chapterIndex] = textRangeIndex
chapterPageNavigationIndex[chapterIndex] = navigationEntries
chapterAnchorPageIndex[chapterIndex] = anchorPageMap
chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage
}
private fun buildPersistentPageIndexEntries(chapterIndex: Int, pages: List<Page>): List<PageIndexEntry> {
val entries = chapterPageNavigationIndex[chapterIndex] ?: run {
applyPageRuntimeIndexes(chapterIndex, pages)
chapterPageNavigationIndex[chapterIndex].orEmpty()
}
return entries.map { entry ->
PageIndexEntry(
bookId = bookId,
configHash = currentConfigHash,
chapterIndex = chapterIndex,
pageInChapter = entry.pageInChapter,
firstBlockIndex = entry.firstBlockIndex,
lastBlockIndex = entry.lastBlockIndex,
firstTextBlockIndex = entry.firstTextBlockIndex,
firstTextCharOffset = entry.firstTextCharOffset,
firstTextEndOffset = entry.firstTextEndOffset,
firstCfi = entry.firstCfi,
anchors = entry.anchors.sorted().joinToString(PAGE_INDEX_ANCHOR_SEPARATOR)
)
}
}
private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) {
withContext(Dispatchers.Main) {
if (chapterPageCounts[chapterIndex] != actualPageCount) {
updatePageCounts(chapterIndex, actualPageCount)
} else if (finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
}
generation++
}
}
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
if (pages.isNullOrEmpty()) {
@ -401,7 +629,12 @@ class BookPaginator(
private fun enqueueBookProcessingWork() {
val serializableChapters = chapters.map {
SerializableEpubChapter(it.htmlContent, it.title, it.absPath)
SerializableEpubChapter(
htmlContent = it.htmlContent,
title = it.title,
absPath = it.absPath,
htmlFilePath = it.htmlFilePath
)
}
val input = BookProcessingInput(
@ -437,13 +670,12 @@ class BookPaginator(
chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign,
paragraphGapMultiplier = paragraphGapMultiplier
paragraphGapMultiplier = paragraphGapMultiplier,
adaptThemeColors = false
)
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->
if (cachedChapter.estimatedPageCount == 0) {
Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.")
} else {
if (cachedChapter.contentBlocksProto.isNotEmpty()) {
try {
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
@ -466,10 +698,12 @@ class BookPaginator(
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
}
} else {
Timber.d("getBlocksForChapter: Cached chapter $chapterIndex had no semantic payload. Reprocessing.")
}
}
Timber.d("getBlocksForChapter: Cache MISS or 'lite' version found for chapter $chapterIndex. Parsing to Semantic IR.")
Timber.d("getBlocksForChapter: Cache MISS for chapter $chapterIndex. Parsing to Semantic IR.")
var htmlToParse = chapter.htmlContent
if (htmlToParse.isEmpty()) {
@ -506,10 +740,10 @@ class BookPaginator(
val processedHtml = document.outerHtml()
var parsingCssRules = OptimizedCssRules()
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false)
parsingCssRules = parsingCssRules.merge(uaResult.rules)
bookCss.forEach { (path, content) ->
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false)
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
@ -522,7 +756,8 @@ class BookPaginator(
density = density,
fontFamilyMap = fontFamilyMap,
constraints = constraints,
mathSvgCache = svgResults
mathSvgCache = svgResults,
adaptThemeColors = false
)
coroutineScope.launch(Dispatchers.IO) {
@ -617,6 +852,7 @@ class BookPaginator(
for (i in (chapterIndex + 1) until chapters.size) {
chapterStartPageIndices[i] = (chapterStartPageIndices[i] ?: 0) + difference
}
rebuildChapterStartSnapshot()
if (chapterIndex < currentUserChapterIndex.value) {
pageShiftRequest.tryEmit(difference)
@ -640,6 +876,14 @@ class BookPaginator(
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
val currentPageInChapter = pageIndex - chapterStart
chapterAnchorPageIndex[chapterIndex]?.let { anchorPages ->
val anchorSet = tocAnchors.toSet()
return anchorPages
.filter { (anchor, anchorPage) -> anchor in anchorSet && anchorPage <= currentPageInChapter }
.maxByOrNull { it.value }
?.key
}
var lastFoundAnchor: String? = null
val anchorSet = tocAnchors.toSet()
@ -685,6 +929,19 @@ class BookPaginator(
)
return null
}
val starts = chapterStartSnapshot
if (starts.isNotEmpty()) {
val exactOrInsertionPoint = starts.binarySearch(pageIndex)
val index = if (exactOrInsertionPoint >= 0) {
exactOrInsertionPoint
} else {
-exactOrInsertionPoint - 2
}
if (index in chapters.indices) {
return index
}
}
val entry = chapterStartPageIndices.entries
.filter { it.value <= pageIndex }
.maxWithOrNull(compareBy({ it.value }, { it.key }))
@ -698,12 +955,24 @@ class BookPaginator(
override fun getCfiForPage(pageIndex: Int): String? {
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
val pageInChapterIndex = pageIndex - chapterStart
chapterPageNavigationIndex[chapterIndex]
?.getOrNull(pageInChapterIndex)
?.firstCfi
?.let { cfi ->
val offset = chapterPageNavigationIndex[chapterIndex]
?.getOrNull(pageInChapterIndex)
?.firstTextCharOffset
?: 0
return if (offset > 0 && !cfi.contains(':')) "$cfi:$offset" else cfi
}
val chapterPages = pageCache[chapterIndex]
if (chapterPages == null) {
Timber.w("getCfiForPage: Chapter $chapterIndex not in cache for page $pageIndex.")
return null
}
val pageInChapterIndex = pageIndex - (chapterStartPageIndices[chapterIndex] ?: 0)
val pageContent = chapterPages.getOrNull(pageInChapterIndex)?.content ?: return null
val firstTextBlock = pageContent.firstOrNull { it is TextContentBlock } as? TextContentBlock
@ -729,6 +998,11 @@ class BookPaginator(
return null
}
loadCachedPagesForChapter(chapter, chapterIndex)?.let {
Timber.d("paginateChapter: Persistent page cache HIT for chapter $chapterIndex.")
return it
}
val blocks = blockCache[chapterIndex] ?: run {
Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
val blocksFromDb = getBlocksForChapter(chapter, chapterIndex)
@ -757,47 +1031,10 @@ class BookPaginator(
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
applyPageRuntimeIndexes(chapterIndex, pages)
savePageCacheAsync(chapter, chapterIndex, pages)
val characterIndex = mutableListOf<PageCharacterRange>()
pages.forEachIndexed { pageInChapterIndex, page ->
var totalCharsOnPage = 0L
val allTextBlocksOnPage = getAllTextBlocks(page.content)
allTextBlocksOnPage.forEach { block ->
if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) {
val startOffset = block.startCharOffsetInSource
val endOffset = startOffset + block.content.text.length
totalCharsOnPage += block.content.text.length
characterIndex.add(
PageCharacterRange(
pageInChapter = pageInChapterIndex,
cfi = block.cfi!!,
startOffset = startOffset,
endOffset = endOffset
)
)
}
}
}
chapterCharacterIndex[chapterIndex] = characterIndex
val cumulativeCharsPerPage = mutableListOf<Long>()
var runningTotalChars = 0L
pages.forEachIndexed { _, page ->
val charsOnPage = getAllTextBlocks(page.content).sumOf { it.content.text.length.toLong() }
runningTotalChars += charsOnPage
cumulativeCharsPerPage.add(runningTotalChars)
}
chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage
withContext(Dispatchers.Main) {
if (chapterPageCounts[chapterIndex] != pages.size) {
updatePageCounts(chapterIndex, pages.size)
}
generation++
}
updatePageCountsOnMain(chapterIndex, pages.size)
return pages
}
@ -835,16 +1072,18 @@ class BookPaginator(
private fun prefetchChapters(currentChapterIndex: Int) {
Timber.v("Prefetching chapters around index $currentChapterIndex.")
val nextChapterIndex = currentChapterIndex + 1
for (offset in 1..2) {
val nextChapterIndex = currentChapterIndex + offset
if (nextChapterIndex < chapters.size) {
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
}
val prevChapterIndex = currentChapterIndex - 1
val prevChapterIndex = currentChapterIndex - offset
if (prevChapterIndex >= 0) {
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
}
}
}
override fun getChapterPathForPage(pageIndex: Int): String? {
val chapterIndex = findChapterIndexForPage(pageIndex)
@ -908,6 +1147,19 @@ class BookPaginator(
return@launch
}
val indexedPageInChapter = targetBlock?.let { blockIndex ->
chapterPageNavigationIndex[targetChapter]
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.pageInChapter
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
if (indexedPageInChapter != null) {
val finalPage = chapterStartPage + indexedPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
withContext(Dispatchers.Main) { onResult(finalPage) }
return@launch
}
// 3. FIND PAGE
var targetPageInChapter = 0
var found = false
@ -1093,6 +1345,26 @@ class BookPaginator(
return null
}
chapterTextRangeIndex[targetChapterIndex]
?.firstOrNull { range ->
range.blockIndex == locator.blockIndex &&
(locator.charOffset in range.startOffset..<range.endOffset ||
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
}
?.let { range ->
val finalPageIndex = chapterStartPage + range.pageInChapter
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex")
return finalPageIndex
}
chapterPageNavigationIndex[targetChapterIndex]
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.let { entry ->
val finalPageIndex = chapterStartPage + entry.pageInChapter
Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex")
return finalPageIndex
}
var fallbackPageInChapter = -1
for ((pageIndex, page) in chapterPages.withIndex()) {
@ -1150,6 +1422,23 @@ class BookPaginator(
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
Timber.tag("POS_DIAG").d("getLocatorForPage: Request pageIndex=$pageIndex. Resolved chapterIndex=$chapterIndex (starts at $chStart). PageInChapter=${pageIndex - chStart}")
chapterPageNavigationIndex[chapterIndex]?.getOrNull(pageIndex - chStart)?.let { entry ->
entry.firstTextBlockIndex?.let { blockIndex ->
return Locator(
chapterIndex = chapterIndex,
blockIndex = blockIndex,
charOffset = entry.firstTextCharOffset
)
}
if (entry.firstBlockIndex >= 0) {
return Locator(
chapterIndex = chapterIndex,
blockIndex = entry.firstBlockIndex,
charOffset = 0
)
}
}
val pageContent = getPageContent(pageIndex) ?: return null
Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}")

View file

@ -46,6 +46,8 @@ import org.jsoup.Jsoup
import java.io.File
import java.net.URLDecoder
private const val DEBUG_CONTENT_STYLING = false
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class ContentStyler(
private val baseTextStyle: TextStyle,
@ -57,7 +59,8 @@ class ContentStyler(
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float
private val paragraphGapMultiplier: Float,
private val adaptThemeColors: Boolean = true
) {
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
@ -167,6 +170,7 @@ class ContentStyler(
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
val finalSvgContent = when {
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
!adaptThemeColors -> embedImagesInSvg(nonBlankSvgContent)
else -> {
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
embedImagesInSvg(themedSvg)
@ -223,6 +227,10 @@ class ContentStyler(
}
private fun applyThemeToStyle(style: CssStyle): CssStyle {
if (!adaptThemeColors) {
return style
}
val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
@ -346,7 +354,9 @@ class ContentStyler(
block: SemanticTextBlock,
blockStyle: CssStyle
): AnnotatedString {
if (DEBUG_CONTENT_STYLING) {
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
}
val builtString = buildAnnotatedString {
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
@ -394,7 +404,9 @@ class ContentStyler(
.merge(blockStyle.spanStyle)
.copy(fontFamily = effectiveBlockFontFamily)
if (DEBUG_CONTENT_STYLING) {
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
}
withStyle(finalParagraphStyle) {
withStyle(initialSpanStyle) {

View file

@ -48,10 +48,20 @@ data class Locator(
class LocatorConverter(
private val bookCacheDao: BookCacheDao,
private val proto: ProtoBuf,
private val context: Context
private val context: Context,
private val stableBookId: String? = null
) {
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='${book.title}' index=$chapterIndex")
private fun cacheBookId(book: EpubBook, overrideBookId: String? = null): String {
return overrideBookId ?: stableBookId ?: book.title
}
private suspend fun processAndCacheChapter(
book: EpubBook,
chapterIndex: Int,
explicitBookId: String? = null
): List<SemanticBlock>? = withContext(Dispatchers.IO) {
val cacheBookId = cacheBookId(book, explicitBookId)
Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='$cacheBookId' index=$chapterIndex")
try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
@ -98,7 +108,8 @@ class LocatorConverter(
baseFontSizeSp = 16f,
density = density.density,
constraints = constraints,
isDarkTheme = false
isDarkTheme = false,
adaptThemeColors = false
)
val rules = bookCssResult.rules
@ -123,16 +134,17 @@ class LocatorConverter(
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = emptyMap(),
constraints = constraints
constraints = constraints,
adaptThemeColors = false
)
val protoBytes = proto.encodeToByteArray(semanticBlocks)
val newCacheEntry = ProcessedChapter(
bookId = book.title,
bookId = cacheBookId,
chapterIndex = chapterIndex,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
)
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
semanticBlocks
@ -141,9 +153,9 @@ class LocatorConverter(
}
}
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) {
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
@ -154,7 +166,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) {
@ -224,8 +236,8 @@ class LocatorConverter(
return bestMatch
}
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -235,7 +247,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) return@withContext null
@ -279,9 +291,9 @@ class LocatorConverter(
chunks
}
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) {
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var blocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -291,7 +303,7 @@ class LocatorConverter(
}
if (blocks.isNullOrEmpty()) {
blocks = processAndCacheChapter(book, locator.chapterIndex)
blocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
}
if (blocks.isNullOrEmpty()) {
@ -331,8 +343,26 @@ class LocatorConverter(
return null
}
suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
private fun estimateSemanticPageCount(blocks: List<SemanticBlock>): Int {
var charCount = 0
fun walk(block: SemanticBlock) {
when (block) {
is SemanticTextBlock -> charCount += block.text.length
is SemanticFlexContainer -> block.children.forEach(::walk)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } }
is SemanticList -> block.items.forEach(::walk)
is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk)
else -> Unit
}
}
blocks.forEach(::walk)
return ((charCount + 2_499) / 2_500).coerceAtLeast(1)
}
suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -342,7 +372,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, locator.chapterIndex)
allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) return@withContext null

View file

@ -723,6 +723,7 @@ private fun WrappingContentLayout(
fun PaginatedReaderScreen(
modifier: Modifier = Modifier,
book: EpubBook,
bookId: String? = null,
isDarkTheme: Boolean,
effectiveBg: Color,
effectiveText: Color,
@ -739,6 +740,9 @@ fun PaginatedReaderScreen(
textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
fallbackLocatorForReconfiguration: Locator? = null,
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
onPaginatorReady: (IPaginator) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
@ -796,37 +800,32 @@ fun PaginatedReaderScreen(
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
val latestFallbackLocatorForReconfiguration by rememberUpdatedState(fallbackLocatorForReconfiguration)
val previousState = remember {
arrayOf<Any>(this.constraints, isDarkTheme, effectiveBg, effectiveText)
var previousConstraints by remember {
mutableStateOf(this.constraints)
}
if (previousState[0] != this.constraints ||
previousState[1] != isDarkTheme ||
previousState[2] != effectiveBg ||
previousState[3] != effectiveText
) {
if (previousConstraints != this.constraints) {
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
val locator = resolvePaginatedReconfigurationAnchor(
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
fallbackLocator = fallbackLocatorForReconfiguration
)
anchorLocatorForReconfig = locator
Timber.tag("ThemeReconfig").d("""
RECONFIG DETECTED
- Reason: ${if (previousState[0] != this.constraints) "Constraints" else "Theme/Colors"}
- Reason: Constraints
- Current Page: $currentPage
- Saved Locator: $locator
""".trimIndent())
}
previousState[0] = this.constraints
previousState[1] = isDarkTheme
previousState[2] = effectiveBg
previousState[3] = effectiveText
previousConstraints = this.constraints
}
val textStyle = remember(
baseTextStyle, effectiveText,
val layoutTextStyle = remember(
baseTextStyle,
debouncedFontSizeMult,
debouncedLineHeightMult,
debouncedFontFamily
@ -835,7 +834,7 @@ fun PaginatedReaderScreen(
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
baseTextStyle.copy(
color = effectiveText,
color = Color.Unspecified,
fontSize = adjustedFontSize,
lineHeight = adjustedLineHeight,
fontFamily = debouncedFontFamily,
@ -848,6 +847,9 @@ fun PaginatedReaderScreen(
)
)
}
val textStyle = remember(layoutTextStyle, effectiveText) {
layoutTextStyle.copy(color = effectiveText)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
@ -875,13 +877,14 @@ fun PaginatedReaderScreen(
delay(400L)
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
val locator = resolvePaginatedReconfigurationAnchor(
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
fallbackLocator = fallbackLocatorForReconfiguration
)
if (locator != null) {
anchorLocatorForReconfig = locator
}
}
debouncedFontSizeMult = fontSizeMultiplier
debouncedLineHeightMult = lineHeightMultiplier
@ -955,7 +958,15 @@ fun PaginatedReaderScreen(
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
}
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText, debouncedParagraphGapMult) {
LaunchedEffect(anchorLocatorForReconfig) {
anchorLocatorForReconfig?.let { locator ->
onReconfigurationAnchorCaptured(locator)
onReconfigurationRestoreActiveChanged(true)
}
}
val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult) {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
@ -963,12 +974,11 @@ fun PaginatedReaderScreen(
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
@ -977,12 +987,11 @@ fun PaginatedReaderScreen(
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
@ -990,12 +999,11 @@ fun PaginatedReaderScreen(
val fontFamilyMap = loadFontFamilies(
fontFaces = allFontFaces, extractionPath = book.extractionBasePath
)
book.title
val bookCacheDao =
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val proto = ProtoBuf { serializersModule = semanticBlockModule }
val uniqueBookId = if (book.fileName.length > 20) book.fileName else book.title
val uniqueBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title
Timber.d("Recreating BookPaginator for ID: $uniqueBookId. TextAlign: $userTextAlign")
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter")
@ -1005,7 +1013,7 @@ fun PaginatedReaderScreen(
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
textStyle = layoutTextStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
@ -1037,6 +1045,8 @@ fun PaginatedReaderScreen(
if (anchorLocatorForReconfig != null) {
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
try {
onReconfigurationRestoreActiveChanged(true)
snapshotFlow { paginator.isLoading }.filter { !it }.first()
val targetLocator = anchorLocatorForReconfig
@ -1047,16 +1057,21 @@ fun PaginatedReaderScreen(
if (page != null) {
pagerState.scrollToPage(page)
paginator.onUserScrolledTo(page)
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
} else {
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
paginator.onUserScrolledTo(startPage)
}
}
anchorLocatorForReconfig = null
}
} finally {
onReconfigurationRestoreActiveChanged(false)
}
}
}
@ -1083,13 +1098,31 @@ fun PaginatedReaderScreen(
LaunchedEffect(pagerState, paginator) {
snapshotFlow { pagerState.currentPage }.debounce(500)
.collectLatest { page -> paginator.onUserScrolledTo(page) }
.collectLatest { page ->
if (anchorLocatorForReconfig == null) {
paginator.onUserScrolledTo(page)
}
}
}
LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount ->
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = anchorLocatorForReconfig,
fallbackLocator = latestFallbackLocatorForReconfiguration
)
val resolvedPage = anchor?.let { locator ->
(paginator as? BookPaginator)?.findPageForLocator(locator)
}
if (resolvedPage != null) {
pagerState.scrollToPage(resolvedPage)
paginator.onUserScrolledTo(resolvedPage)
} else {
val newPage = pagerState.currentPage + shiftAmount
pagerState.scrollToPage(newPage)
paginator.onUserScrolledTo(newPage)
}
}
}
@ -2218,6 +2251,13 @@ internal fun PaginatedReaderContent(
var pageContent by remember { mutableStateOf<Page?>(null) }
var currentChapterPath by remember { mutableStateOf<String?>(null) }
val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) {
pageContent?.applyReaderThemeForDisplay(
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
)
}
LaunchedEffect(pageIndex, uiState.generation) {
val fetchStartTime = System.currentTimeMillis()
@ -2236,7 +2276,7 @@ internal fun PaginatedReaderContent(
}
val textBlocksOnPage =
pageContent?.content?.extractTextBlocks()
themedPageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList()
val lastTextBlock = textBlocksOnPage.lastOrNull()
val lastBlockAbs = lastTextBlock?.let {
@ -2379,7 +2419,8 @@ internal fun PaginatedReaderContent(
horizontal = horizontalPadding,
vertical = verticalPadding
), contentAlignment = Alignment.TopStart) {
if (pageContent != null) {
if (themedPageContent != null) {
val displayPage = themedPageContent
val onGeneralTapCallback: (Offset) -> Unit = { offset ->
activeSelection = null
onTap(offset)
@ -2405,7 +2446,7 @@ internal fun PaginatedReaderContent(
val ttsHighlightColor =
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
pageContent!!.content.forEach { block ->
displayPage.content.forEach { block ->
val marginModifier = Modifier.padding(
top = block.style.margin.top.coerceAtLeast(0.dp),
bottom = block.style.margin.bottom.coerceAtLeast(

View file

@ -24,6 +24,7 @@ import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
@ -77,7 +78,8 @@ class PaginatedReaderViewModel : ViewModel() {
context: Context,
initialChapterToPaginate: Int?,
mathMLRenderer: MathMLRenderer,
paragraphGapMultiplier: Float
paragraphGapMultiplier: Float,
bookId: String? = null
) {
if (paginator != null) return
@ -88,16 +90,16 @@ class PaginatedReaderViewModel : ViewModel() {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
val layoutTextStyle = textStyle.copy(color = Color.Unspecified)
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
@ -106,12 +108,11 @@ class PaginatedReaderViewModel : ViewModel() {
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
@ -120,21 +121,21 @@ class PaginatedReaderViewModel : ViewModel() {
fontFaces = allFontFaces,
extractionPath = book.extractionBasePath
)
val bookId = book.title
val cacheBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title
val bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val newPaginator = BookPaginator(
coroutineScope = viewModelScope,
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
textStyle = layoutTextStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
bookId = bookId,
bookId = cacheBookId,
bookCacheDao = bookCacheDao,
proto = proto,
initialChapterToPaginate = initialChapterToPaginate ?: 0,

View file

@ -0,0 +1,6 @@
package com.aryan.reader.paginatedreader
internal fun resolvePaginatedReconfigurationAnchor(
currentPageLocator: Locator?,
fallbackLocator: Locator?
): Locator? = currentPageLocator ?: fallbackLocator

View file

@ -35,8 +35,11 @@ import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
private const val DEBUG_PAGINATION_LOGS = false
interface BlockMeasurementProvider {
suspend fun measure(block: ContentBlock): Int
suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>?
@ -53,9 +56,13 @@ class SuspendingAndroidBlockMeasurementProvider(
private val density: Density,
private val imageSizeMultiplier: Float
) : BlockMeasurementProvider {
private val measurementCache = ConcurrentHashMap<Int, Int>()
override suspend fun measure(block: ContentBlock): Int {
return measureBlockHeight(
val cacheKey = blockMeasurementCacheKey(block)
measurementCache[cacheKey]?.let { return it }
val measured = measureBlockHeight(
block = block,
textMeasurer = textMeasurer,
constraints = constraints,
@ -64,6 +71,17 @@ class SuspendingAndroidBlockMeasurementProvider(
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
measurementCache[cacheKey] = measured
return measured
}
private fun blockMeasurementCacheKey(block: ContentBlock): Int {
var result = block.hashCode()
result = 31 * result + constraints.maxWidth
result = 31 * result + constraints.maxHeight
result = 31 * result + textStyle.hashCode()
result = 31 * result + imageSizeMultiplier.hashCode()
return result
}
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
@ -280,7 +298,9 @@ class SuspendingAndroidBlockMeasurementProvider(
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
}
currentHeight += decorationTop
for (i in block.rows.indices) {
@ -305,7 +325,9 @@ class SuspendingAndroidBlockMeasurementProvider(
}
if (currentHeight + maxRowHeight + decorationBottom > availableHeight) {
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
}
splitRowIndex = i
break
}
@ -410,7 +432,9 @@ suspend fun paginate(
if (blocks.isEmpty()) {
return emptyList()
}
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
}
val pages = mutableListOf<Page>()
var currentPageContent = mutableListOf<ContentBlock>()
@ -437,8 +461,10 @@ suspend fun paginate(
val spaceRequired = blockHeightWithSafetyMargin + spaceBetweenBlocks
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
}
if (spaceRequired <= remainingHeight) {
var blockToAdd = block
@ -608,23 +634,31 @@ suspend fun paginate(
}
else -> {
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
}
}
}
} else {
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
}
}
if (!wasSplit) {
if (currentPageContent.isEmpty()) {
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)")
}
val forcedHeight = blockHeight + spaceBetweenBlocks
val blockToAdd = setBlockExpectedHeight(block, forcedHeight)
currentPageContent.add(blockToAdd)
} else {
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
}
remainingBlocks.add(0, block)
}
}
@ -643,7 +677,9 @@ suspend fun paginate(
pages.add(Page(content = currentPageContent.toList()))
}
if (DEBUG_PAGINATION_LOGS) {
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
}
return pages
}
@ -906,7 +942,9 @@ private suspend fun measureBlockHeight(
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
}
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
}
return finalHeight
}
@ -935,10 +973,14 @@ private suspend fun splitParagraphBlock(
val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
}
if (availableTextHeight <= 0) {
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0")
}
return null
}
@ -969,7 +1011,9 @@ private suspend fun splitParagraphBlock(
}
if (lastVisibleLine == 0) {
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
}
return null
}
@ -985,7 +1029,9 @@ private suspend fun splitParagraphBlock(
)
}
if (part2Layout.lineCount == 1) {
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
}
lastVisibleLine--
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
}
@ -1046,7 +1092,9 @@ private suspend fun splitParagraphBlock(
endCharOffsetInSource = block.endCharOffsetInSource
)
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
}
return part1 to part2
}
@ -1090,8 +1138,10 @@ private suspend fun calculateContentHeightWithMargins(
}
}.roundToInt()
totalHeight += (childHeight + margin)
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
}
}
if (children.isNotEmpty()) {
totalHeight += with(density) { children.last().style.margin.bottom.toPx().roundToInt() }
}

View file

@ -0,0 +1,265 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import org.jsoup.Jsoup
internal fun Page.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): Page {
return copy(
content = content.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
}
)
}
private fun ContentBlock.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): ContentBlock {
val themedStyle = style.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
return when (this) {
is ParagraphBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is HeaderBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is QuoteBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is ListItemBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is ImageBlock -> copy(style = themedStyle)
is SpacerBlock -> copy(style = themedStyle)
is MathBlock -> copy(
svgContent = if (isFromMathJax) {
svgContent
} else {
svgContent?.applyReaderThemeToSvgText(themeTextColor)
},
style = themedStyle
)
is WrappingContentBlock -> copy(
floatedImage = floatedImage.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
) as ImageBlock,
paragraphsToWrap = paragraphsToWrap.map {
it.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
) as ParagraphBlock
},
style = themedStyle
)
is TableBlock -> copy(
rows = rows.map { row ->
row.map { cell ->
cell.copy(
content = cell.content.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
},
style = cell.style.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
)
)
}
},
style = themedStyle
)
is FlexContainerBlock -> copy(
children = children.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
},
style = themedStyle
)
}
}
private fun AnnotatedString.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): AnnotatedString {
return buildAnnotatedString {
append(this@applyReaderThemeForDisplay.text)
this@applyReaderThemeForDisplay.spanStyles.forEach { range ->
addStyle(
range.item.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
range.start,
range.end
)
}
this@applyReaderThemeForDisplay.paragraphStyles.forEach { range ->
addStyle(range.item, range.start, range.end)
}
this@applyReaderThemeForDisplay.getStringAnnotations(0, this@applyReaderThemeForDisplay.length).forEach { range ->
val item = if (range.tag == "CustomUnderline") {
range.item.applyReaderThemeToUnderlineAnnotation(isDarkTheme, themeBackgroundColor, themeTextColor)
} else {
range.item
}
addStringAnnotation(range.tag, item, range.start, range.end)
}
}
}
private fun CssStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): CssStyle {
val emphasis = textEmphasis
return copy(
spanStyle = spanStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
blockStyle = blockStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
textDecorationColor = textDecorationColor.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
textEmphasis = emphasis?.copy(
color = emphasis.color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
)
}
private fun SpanStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): SpanStyle {
return copy(
color = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
background = background.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = true,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
}
private fun BlockStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): BlockStyle {
return copy(
backgroundColor = backgroundColor.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = true,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
borderTop = borderTop?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderRight = borderRight?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderBottom = borderBottom?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderLeft = borderLeft?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
)
}
private fun BorderStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): BorderStyle {
return copy(
color = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
}
private fun Color.applyReaderThemeColor(
isDarkTheme: Boolean,
isBackground: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): Color {
if (!isSpecified) return this
return CssParser.adaptColorForTheme(
color = this,
isDarkTheme = isDarkTheme,
isBackground = isBackground,
themeBackground = themeBackgroundColor,
themeText = themeTextColor
)
}
private fun String.applyReaderThemeToUnderlineAnnotation(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): String {
val parts = split('|').toMutableList()
val colorPart = parts.getOrNull(1) ?: return this
if (colorPart == "Unspecified") return this
val color = colorPart.toULongOrNull()?.let { Color(it) } ?: return this
parts[1] = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
).value.toString()
return parts.joinToString("|")
}
private fun String.applyReaderThemeToSvgText(themeTextColor: Color): String {
if (!themeTextColor.isSpecified || isBlank()) return this
return try {
val textColorHex = themeTextColor.toCssHexString()
val svgDocument = Jsoup.parseBodyFragment(this)
val svgElement = svgDocument.body().children().firstOrNull() ?: return this
svgElement.select("text").forEach { textElement ->
val existingStyle = textElement.attr("style")
val styleWithoutFill = existingStyle.replace(Regex("""\bfill\s*:\s*[^;]+;?"""), "")
val newStyle = "fill:$textColorHex; $styleWithoutFill".trim()
textElement.attr("style", newStyle)
textElement.removeAttr("fill")
}
svgElement.outerHtml()
} catch (_: Exception) {
this
}
}
private fun Color.toCssHexString(): String {
val red = (this.red * 255).toInt()
val green = (this.green * 255).toInt()
val blue = (this.blue * 255).toInt()
return "#%02X%02X%02X".format(red, green, blue)
}

View file

@ -28,6 +28,8 @@ import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Dao
abstract class BookCacheDao {
@ -151,12 +153,19 @@ abstract class BookCacheDao {
@Query("DELETE FROM configuration_cache WHERE bookId = :bookId")
abstract suspend fun deleteConfigurationCacheForBook(bookId: String)
@Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId")
protected abstract suspend fun deletePageCacheMetadataForBook(bookId: String)
@Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex")
protected abstract suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int)
@Transaction
open suspend fun deleteEntireBookCache(bookId: String) {
deleteBook(bookId)
deleteChaptersForBook(bookId)
deleteAnchorsForBook(bookId)
deleteConfigurationCacheForBook(bookId)
deletePageCacheMetadataForBook(bookId)
}
@Query("DELETE FROM anchor_index")
@ -165,12 +174,16 @@ abstract class BookCacheDao {
@Query("DELETE FROM configuration_cache")
abstract suspend fun clearConfigurationCache()
@Query("DELETE FROM page_cache_metadata")
protected abstract suspend fun clearPageCacheMetadata()
@Transaction
open suspend fun clearAllCache() {
clearProcessedBooks()
clearProcessedChapters()
clearAnchors()
clearConfigurationCache()
clearPageCacheMetadata()
}
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
@ -188,6 +201,101 @@ abstract class BookCacheDao {
)
""")
abstract suspend fun cleanupOldConfigurations(bookId: String)
@Query("SELECT * FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex")
protected abstract suspend fun getPageCacheMetadata(bookId: String, configHash: Int, chapterIndex: Int): PageCacheMetadata?
@Query("SELECT chunk_data FROM page_cache_chunks WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY chunk_index ASC")
protected abstract suspend fun getPageCacheChunks(bookId: String, configHash: Int, chapterIndex: Int): List<ByteArray>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertPageCacheMetadata(metadata: PageCacheMetadata)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertPageCacheChunks(chunks: List<PageCacheChunk>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertPageIndexEntries(entries: List<PageIndexEntry>)
@Query("SELECT * FROM page_index_entries WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY page_in_chapter ASC")
abstract suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List<PageIndexEntry>
@Transaction
open suspend fun getPageCache(bookId: String, configHash: Int, chapterIndex: Int): PageCacheEntry? {
val metadata = getPageCacheMetadata(bookId, configHash, chapterIndex) ?: return null
val chunks = getPageCacheChunks(bookId, configHash, chapterIndex)
if (chunks.isEmpty()) return null
val totalSize = chunks.sumOf { it.size }
val mergedData = ByteArray(totalSize)
var offset = 0
for (chunk in chunks) {
System.arraycopy(chunk, 0, mergedData, offset, chunk.size)
offset += chunk.size
}
return PageCacheEntry(
bookId = metadata.bookId,
configHash = metadata.configHash,
chapterIndex = metadata.chapterIndex,
processingVersion = metadata.processingVersion,
pageCacheVersion = metadata.pageCacheVersion,
contentVersion = metadata.contentVersion,
pageCount = metadata.pageCount,
pagesProto = mergedData
)
}
@Transaction
open suspend fun insertPageCache(entry: PageCacheEntry, pageIndexEntries: List<PageIndexEntry>) {
@Suppress("LocalVariableName") val CHUNK_SIZE = 900 * 1024
deletePageCacheMetadataForChapter(entry.bookId, entry.configHash, entry.chapterIndex)
insertPageCacheMetadata(
PageCacheMetadata(
bookId = entry.bookId,
configHash = entry.configHash,
chapterIndex = entry.chapterIndex,
processingVersion = entry.processingVersion,
pageCacheVersion = entry.pageCacheVersion,
contentVersion = entry.contentVersion,
pageCount = entry.pageCount
)
)
val chunks = ArrayList<PageCacheChunk>()
var offset = 0
var chunkIndex = 0
while (offset < entry.pagesProto.size) {
val end = (offset + CHUNK_SIZE).coerceAtMost(entry.pagesProto.size)
chunks.add(
PageCacheChunk(
bookId = entry.bookId,
configHash = entry.configHash,
chapterIndex = entry.chapterIndex,
chunkIndex = chunkIndex,
chunkData = entry.pagesProto.copyOfRange(offset, end)
)
)
offset = end
chunkIndex++
}
insertPageCacheChunks(chunks)
if (pageIndexEntries.isNotEmpty()) {
insertPageIndexEntries(pageIndexEntries)
}
}
@Query("""
DELETE FROM page_cache_metadata
WHERE book_id = :bookId AND config_hash NOT IN (
SELECT configHash FROM configuration_cache
WHERE bookId = :bookId
ORDER BY rowid DESC LIMIT 3
)
""")
abstract suspend fun cleanupOldPageCaches(bookId: String)
}
@Database(
@ -196,9 +304,12 @@ abstract class BookCacheDao {
ProcessedChapterMetadata::class,
ProcessedChapterChunk::class,
ConfigurationCache::class,
AnchorIndexEntry::class
AnchorIndexEntry::class,
PageCacheMetadata::class,
PageCacheChunk::class,
PageIndexEntry::class
],
version = 10,
version = 11,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {
@ -215,11 +326,73 @@ abstract class BookCacheDatabase : RoomDatabase() {
BookCacheDatabase::class.java,
"book_cache_database"
)
.addMigrations(MIGRATION_10_11)
.fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
instance
}
}
private val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_cache_metadata` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`processing_version` INTEGER NOT NULL,
`page_cache_version` INTEGER NOT NULL,
`content_version` INTEGER NOT NULL,
`page_count` INTEGER NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_cache_chunks` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`chunk_index` INTEGER NOT NULL,
`chunk_data` BLOB NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `chunk_index`),
FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`)
REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_page_cache_chunks_book_id_config_hash_chapter_index` ON `page_cache_chunks` (`book_id`, `config_hash`, `chapter_index`)"
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_index_entries` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`page_in_chapter` INTEGER NOT NULL,
`first_block_index` INTEGER NOT NULL,
`last_block_index` INTEGER NOT NULL,
`first_text_block_index` INTEGER,
`first_text_char_offset` INTEGER NOT NULL,
`first_text_end_offset` INTEGER NOT NULL,
`first_cfi` TEXT,
`anchors` TEXT NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `page_in_chapter`),
FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`)
REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_page_index_entries_book_id_config_hash_chapter_index` ON `page_index_entries` (`book_id`, `config_hash`, `chapter_index`)"
)
}
}
}
}

View file

@ -25,7 +25,8 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 10
const val LATEST_PROCESSING_VERSION = 11
const val LATEST_PAGE_CACHE_VERSION = 3
@Entity(tableName = "processed_books")
data class ProcessedBook(
@ -131,3 +132,121 @@ data class ConfigurationCache(
val configHash: Int,
val chapterPageCounts: String
)
data class PageCacheEntry(
val bookId: String,
val configHash: Int,
val chapterIndex: Int,
val processingVersion: Int,
val pageCacheVersion: Int,
val contentVersion: Int,
val pageCount: Int,
val pagesProto: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PageCacheEntry
if (bookId != other.bookId) return false
if (configHash != other.configHash) return false
if (chapterIndex != other.chapterIndex) return false
if (processingVersion != other.processingVersion) return false
if (pageCacheVersion != other.pageCacheVersion) return false
if (contentVersion != other.contentVersion) return false
if (pageCount != other.pageCount) return false
if (!pagesProto.contentEquals(other.pagesProto)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + configHash
result = 31 * result + chapterIndex
result = 31 * result + processingVersion
result = 31 * result + pageCacheVersion
result = 31 * result + contentVersion
result = 31 * result + pageCount
result = 31 * result + pagesProto.contentHashCode()
return result
}
}
@Entity(tableName = "page_cache_metadata", primaryKeys = ["book_id", "config_hash", "chapter_index"])
data class PageCacheMetadata(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "processing_version") val processingVersion: Int,
@ColumnInfo(name = "page_cache_version") val pageCacheVersion: Int,
@ColumnInfo(name = "content_version") val contentVersion: Int,
@ColumnInfo(name = "page_count") val pageCount: Int
)
@Entity(
tableName = "page_cache_chunks",
primaryKeys = ["book_id", "config_hash", "chapter_index", "chunk_index"],
foreignKeys = [
ForeignKey(
entity = PageCacheMetadata::class,
parentColumns = ["book_id", "config_hash", "chapter_index"],
childColumns = ["book_id", "config_hash", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "config_hash", "chapter_index"])]
)
data class PageCacheChunk(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PageCacheChunk
if (bookId != other.bookId) return false
if (configHash != other.configHash) return false
if (chapterIndex != other.chapterIndex) return false
if (chunkIndex != other.chunkIndex) return false
if (!chunkData.contentEquals(other.chunkData)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + configHash
result = 31 * result + chapterIndex
result = 31 * result + chunkIndex
result = 31 * result + chunkData.contentHashCode()
return result
}
}
@Entity(
tableName = "page_index_entries",
primaryKeys = ["book_id", "config_hash", "chapter_index", "page_in_chapter"],
foreignKeys = [
ForeignKey(
entity = PageCacheMetadata::class,
parentColumns = ["book_id", "config_hash", "chapter_index"],
childColumns = ["book_id", "config_hash", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "config_hash", "chapter_index"])]
)
data class PageIndexEntry(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "page_in_chapter") val pageInChapter: Int,
@ColumnInfo(name = "first_block_index") val firstBlockIndex: Int,
@ColumnInfo(name = "last_block_index") val lastBlockIndex: Int,
@ColumnInfo(name = "first_text_block_index") val firstTextBlockIndex: Int?,
@ColumnInfo(name = "first_text_char_offset") val firstTextCharOffset: Int,
@ColumnInfo(name = "first_text_end_offset") val firstTextEndOffset: Int,
@ColumnInfo(name = "first_cfi") val firstCfi: String?,
@ColumnInfo(name = "anchors") val anchors: String
)

View file

@ -63,7 +63,8 @@ import kotlin.math.abs
data class SerializableEpubChapter(
@ProtoNumber(1) val htmlContent: String,
@ProtoNumber(2) val title: String,
@ProtoNumber(3) val absPath: String
@ProtoNumber(3) val absPath: String,
@ProtoNumber(4) val htmlFilePath: String = absPath
)
@OptIn(ExperimentalSerializationApi::class)
@ -208,7 +209,8 @@ class BookProcessingWorker(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
isDarkTheme = false,
adaptThemeColors = false
)
lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules)
@ -219,7 +221,8 @@ class BookProcessingWorker(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
isDarkTheme = false,
adaptThemeColors = false
)
lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules)
}
@ -242,7 +245,20 @@ class BookProcessingWorker(
Timber.d("Async task started for chapter index $index.")
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
val document = Jsoup.parse(chapter.htmlContent, chapter.absPath)
val htmlToParse = chapter.htmlContent.ifBlank {
val backingFile = File(extractionBasePath, chapter.htmlFilePath)
if (backingFile.exists()) {
backingFile.readText()
} else {
""
}
}
if (htmlToParse.isBlank()) {
Timber.w("[BG_PROC] Skipping chapter $index because no HTML content was available.")
return@async null
}
val document = Jsoup.parse(htmlToParse, chapter.absPath)
val mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>()
@ -294,7 +310,7 @@ class BookProcessingWorker(
bookId = bookId,
chapterIndex = index,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
)
} else {
Timber.d("Chapter $index was already in the database. Skipping.")
@ -371,4 +387,28 @@ class BookProcessingWorker(
blocks.forEach { walk(it) }
return anchors
}
private fun estimateSemanticPageCount(
blocks: List<com.aryan.reader.paginatedreader.SemanticBlock>
): Int {
var charCount = 0
fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) {
when (block) {
is com.aryan.reader.paginatedreader.SemanticTextBlock -> {
charCount += block.text.length
}
is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach(::walk)
is com.aryan.reader.paginatedreader.SemanticTable -> {
block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } }
}
is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach(::walk)
is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk)
else -> Unit
}
}
blocks.forEach(::walk)
return ((charCount + 2_499) / 2_500).coerceAtLeast(1)
}
}

View file

@ -32,6 +32,38 @@ object NativePdfiumBridge {
@JvmStatic external fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
@JvmStatic external fun checkActionSupport(): Boolean
@JvmStatic external fun exportAnnotatedPdf(
sourcePath: String,
destPath: String,
inkPageIndices: IntArray,
inkTypes: IntArray,
inkColors: IntArray,
inkStrokeWidths: FloatArray,
inkPointOffsets: IntArray,
inkPointCounts: IntArray,
inkPoints: FloatArray,
textPageIndices: IntArray,
textBounds: FloatArray,
textColors: IntArray,
textBackgroundColors: IntArray,
textFontSizes: FloatArray,
textFlags: IntArray,
textValues: Array<String>,
textFontPaths: Array<String>,
textFontNames: Array<String>,
rasterPageIndices: IntArray,
rasterBounds: FloatArray,
rasterWidths: IntArray,
rasterHeights: IntArray,
rasterPixelOffsets: IntArray,
rasterPixels: IntArray,
highlightPageIndices: IntArray,
highlightColors: IntArray,
highlightRectOffsets: IntArray,
highlightRectCounts: IntArray,
highlightRects: FloatArray,
highlightContents: Array<String>
): Boolean
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK

File diff suppressed because it is too large Load diff

View file

@ -549,6 +549,7 @@ internal fun PdfPageComposable(
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
eraserToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
@ -4270,6 +4271,7 @@ internal fun PdfPageComposable(
eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness,
eraserToolThickness = eraserToolThickness,
richTextController = richTextController,
textBoxes = textBoxes,
selectedTextBoxId = selectedTextBoxId,
@ -5106,6 +5108,7 @@ private fun PdfPageRenderer(
onHighlightDelete: (String) -> Unit,
onTts: (Int, Int) -> Unit,
activeToolThickness: Float,
eraserToolThickness: Float,
onNote: (String?) -> Unit,
isBubbleZoomModeActive: Boolean = false,
isActivePage: Boolean = true,
@ -5173,7 +5176,7 @@ private fun PdfPageRenderer(
val isEditable = isEditMode && selectedTool == InkType.TEXT
val hasContent = richTextController.pageLayouts.any {
it.pageIndex == selectionData.pageIndex
}
} || richTextController.hasRenderableText
if (isEditable || hasContent) {
PdfRichTextLayer(
@ -5323,8 +5326,13 @@ private fun PdfPageRenderer(
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
val eraserStrokeWidth = resolveEraserStrokeWidth(
isStylusEraserOverride,
activeToolThickness,
eraserToolThickness
)
val radiusPx = if (eraserStrokeWidth > 0f && staticData.targetWidth > 0) {
eraserStrokeWidth * staticData.targetWidth * scale
} else {
8.dp.toPx()
}
@ -5798,7 +5806,7 @@ fun PdfRichTextLayer(
val textToRender = if (controller.activePageIndex == pageIndex) {
controller.localTextFieldValue.annotatedString
} else {
pageLayout?.visibleText
pageLayout?.visibleText?.withoutTrailingPdfPageBreakForRender()
}
if (textToRender != null) {
@ -5871,6 +5879,14 @@ fun PdfRichTextLayer(
}
}
private fun AnnotatedString.withoutTrailingPdfPageBreakForRender(): AnnotatedString {
return if (text.lastOrNull() == PAGE_BREAK_CHAR) {
subSequence(0, length - 1)
} else {
this
}
}
private fun getNativePointer(obj: Any): Long {
val priorityFields = listOf("pagePtr", "mNativePage", "page")

View file

@ -39,10 +39,10 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme"
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
enum class PdfReaderTool(val title: String, val category: String) {
@ -64,6 +64,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
REFLOW("Text View (Reflow)", "Overflow Menu"),

View file

@ -117,6 +117,7 @@ internal fun PdfTopBar(
onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit,
onShowTtsSettings: () -> Unit,
onShowTtsReplacements: () -> Unit,
onToggleBookmark: () -> Unit,
onInsertPage: () -> Unit,
onDeletePage: () -> Unit,
@ -425,6 +426,15 @@ internal fun PdfTopBar(
onClick = { showMoreMenu = false; onShowTtsSettings() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = { showMoreMenu = false; onShowTtsReplacements() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {

View file

@ -251,6 +251,7 @@ internal fun PdfVerticalReader(
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
eraserToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {},
lockedState: Triple<Float, Float, Float>? = null,
@ -1744,6 +1745,7 @@ internal fun PdfVerticalReader(
onNoteRequested = onNoteRequested,
onTts = onTts,
activeToolThickness = activeToolThickness,
eraserToolThickness = eraserToolThickness,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick,
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
@ -2151,8 +2153,13 @@ internal fun PdfVerticalReader(
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val pos = globalEraserPosition!!
val radiusPx = if (activeToolThickness > 0f) {
activeToolThickness * screenWidth * zoomAnimatable.value
val eraserStrokeWidth = resolveEraserStrokeWidth(
isStylusEraserOverride,
activeToolThickness,
eraserToolThickness
)
val radiusPx = if (eraserStrokeWidth > 0f) {
eraserStrokeWidth * screenWidth * zoomAnimatable.value
} else {
8.dp.toPx()
}

View file

@ -210,6 +210,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.TtsWordReplacementsSheet
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
@ -224,6 +225,7 @@ import com.aryan.reader.callByokGeminiInlineAi
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@ -238,11 +240,14 @@ import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -277,6 +282,12 @@ import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.isBackPressed
import androidx.compose.ui.input.pointer.isForwardPressed
internal fun resolveEraserStrokeWidth(
isEraserOverride: Boolean,
activeToolThickness: Float,
eraserToolThickness: Float
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
@Suppress("KotlinConstantConditions")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@ExperimentalMaterial3Api
@ -441,6 +452,12 @@ fun PdfViewerScreen(
)
}
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsReplacementsSheet by remember { mutableStateOf(false) }
var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) }
val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next ->
ttsReplacementPreferences = next
saveTtsReplacementPreferences(context, next)
}
DisposableEffect(isKeepScreenOn) {
view.keepScreenOn = isKeepScreenOn
@ -805,6 +822,7 @@ fun PdfViewerScreen(
val activeToolColor = toolSettings.getToolColor(selectedTool)
val activeToolThickness = toolSettings.getToolThickness(selectedTool)
val eraserToolThickness = toolSettings.getToolThickness(InkType.ERASER)
val fountainPenColor = toolSettings.getToolColor(InkType.FOUNTAIN_PEN)
val markerColor = toolSettings.getToolColor(InkType.PEN)
@ -824,6 +842,7 @@ fun PdfViewerScreen(
val currentStrokeColor by remember(activeToolColor) { derivedStateOf { activeToolColor } }
val currentStrokeWidth by remember(activeToolThickness) { derivedStateOf { activeToolThickness } }
val currentEraserStrokeWidth by remember(eraserToolThickness) { derivedStateOf { eraserToolThickness } }
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
@ -1342,6 +1361,30 @@ fun PdfViewerScreen(
Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
suspend fun rebuildMissingHighlightBounds(
document: ReaderDocument,
highlights: List<PdfUserHighlight>
): List<PdfUserHighlight> = withContext(Dispatchers.IO) {
highlights.map { highlight ->
if (highlight.bounds.isNotEmpty()) return@map highlight
val start = highlight.range.first
val end = highlight.range.second
if (highlight.pageIndex < 0 || end <= start) return@map highlight
runCatching {
document.openPage(highlight.pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val rects = textPage.textPageGetRectsForRanges(intArrayOf(start, end - start))
?.map { it.rect }
.orEmpty()
val merged = mergePdfRectsIntoLines(rects)
if (merged.isEmpty()) highlight else highlight.copy(bounds = merged)
}
} ?: highlight
}.getOrDefault(highlight)
}
}
val onHighlightAdd = remember(pdfDocument, currentBookId) {
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...")
@ -2042,6 +2085,25 @@ fun PdfViewerScreen(
}
}
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) {
val document = pdfDocument ?: return@LaunchedEffect
if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
val snapshot = userHighlights.toList()
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
isRebuildingSyncedHighlightBounds = true
try {
val rebuilt = rebuildMissingHighlightBounds(document, snapshot)
if (rebuilt != snapshot) {
userHighlights.clear()
userHighlights.addAll(rebuilt)
}
} finally {
isRebuildingSyncedHighlightBounds = false
}
}
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
val saveLauncher = rememberLauncherForActivityResult(
@ -2076,7 +2138,7 @@ fun PdfViewerScreen(
viewModel.saveOriginalPdf(effectivePdfUri, uri)
}
else -> {}
null -> Unit
}
}
pendingSaveMode = null
@ -2618,7 +2680,7 @@ fun PdfViewerScreen(
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = bookTitle,
chapterTitle = pageTitle,
coverImageUri = null,
@ -3434,6 +3496,7 @@ fun PdfViewerScreen(
}
showTtsSettingsSheet -> showTtsSettingsSheet = false
showTtsReplacementsSheet -> showTtsReplacementsSheet = false
showThemePanel -> showThemePanel = false
else -> {
@ -3712,6 +3775,9 @@ fun PdfViewerScreen(
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
val currentEraserStrokeWidthState by rememberUpdatedState(
currentEraserStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
remember(pageIndex) {
@ -3719,10 +3785,15 @@ fun PdfViewerScreen(
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
lastEraserPoint = point
if (toRemove.isNotEmpty()) {
@ -3762,10 +3833,15 @@ fun PdfViewerScreen(
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
if (toRemove.isNotEmpty()) {
val batch =
@ -3892,6 +3968,7 @@ fun PdfViewerScreen(
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
eraserToolThickness = currentEraserStrokeWidthState,
lockedState = lockedState,
onZoomAndPanChanged = { newScale, newOffset ->
if (pagerState.currentPage == pageIndex) {
@ -4152,6 +4229,9 @@ fun PdfViewerScreen(
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
val currentEraserStrokeWidthState by rememberUpdatedState(
currentEraserStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
remember {
@ -4164,11 +4244,16 @@ fun PdfViewerScreen(
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
if (toRemove.isNotEmpty()) {
val batch =
@ -4204,10 +4289,15 @@ fun PdfViewerScreen(
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.ERASER) {
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
lastEraserPoint = point
if (toRemove.isNotEmpty()) {
@ -4281,6 +4371,7 @@ fun PdfViewerScreen(
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
eraserToolThickness = currentEraserStrokeWidthState,
onLinkClicked = onLinkClickedStable,
onInternalLinkClicked = onInternalLinkNavStable,
bookmarks = bookmarksHolder,
@ -5019,6 +5110,7 @@ fun PdfViewerScreen(
showBars = !isMusicianMode
},
onShowTtsSettings = { showTtsSettingsSheet = true },
onShowTtsReplacements = { showTtsReplacementsSheet = true },
onToggleBookmark = onBookmarkClick,
onInsertPage = onInsertPage,
onDeletePage = onDeletePage,
@ -6511,6 +6603,15 @@ fun PdfViewerScreen(
)
}
TtsWordReplacementsSheet(
isVisible = showTtsReplacementsSheet,
bookId = bookId,
bookTitle = documentMetadataTitle ?: originalFileName,
preferences = ttsReplacementPreferences,
onPreferencesChange = updateTtsReplacementPreferences,
onDismiss = { showTtsReplacementsSheet = false },
)
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,
@ -6684,6 +6785,7 @@ fun PdfViewerScreen(
title = { Text(stringResource(R.string.title_save_to_device)) },
text = { Text(stringResource(R.string.desc_choose_format_save)) },
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
TextButton(
onClick = {
showSaveDialog = false
@ -6693,6 +6795,8 @@ fun PdfViewerScreen(
)
saveLauncher.launch(suggestedName)
}) { Text(stringResource(R.string.action_with_annotations)) }
}
},
dismissButton = {
Row {
@ -6723,6 +6827,7 @@ fun PdfViewerScreen(
title = { Text(stringResource(R.string.share_chooser_title)) },
text = { Text(stringResource(R.string.desc_choose_format_share)) },
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
TextButton(
onClick = {
showShareDialog = false
@ -6748,6 +6853,8 @@ fun PdfViewerScreen(
isShareLoading = false
}
}) { Text(stringResource(R.string.action_with_annotations)) }
}
},
dismissButton = {
Row {

View file

@ -0,0 +1,768 @@
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.text.Layout
import android.text.SpannableString
import android.text.Spanned
import android.text.StaticLayout
import android.text.TextPaint
import android.text.style.AbsoluteSizeSpan
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.MetricAffectingSpan
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import android.util.TypedValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.ceil
import kotlin.math.roundToInt
internal object PdfiumAnnotationExporter {
internal const val TEXT_FLAG_BOLD = 1
internal const val TEXT_FLAG_ITALIC = 1 shl 1
internal const val TEXT_FLAG_UNDERLINE = 1 shl 2
internal const val TEXT_FLAG_STRIKE_THROUGH = 1 shl 3
internal const val TEXT_FLAG_ABSOLUTE_LINE = 1 shl 4
private const val TEXT_BOX_PADDING_DP = 8f
private const val TEXT_RASTER_PDF_POINT_SCALE = 3f
private const val TEXT_RASTER_MIN_PAGE_HEIGHT_PX = 1200f
private const val TEXT_RASTER_MAX_PAGE_HEIGHT_PX = 3600f
private const val RICH_TEXT_MARGIN_X = 0.1f
private const val RICH_TEXT_MARGIN_Y = 0.08f
suspend fun exportAnnotatedPdf(
context: Context,
sourceUri: Uri,
destStream: OutputStream,
virtualPages: List<VirtualPage>?,
inkAnnotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = null
) {
withContext(Dispatchers.IO) {
if (!supportsOriginalPageOrder(virtualPages)) {
destStream.close()
throw UnsupportedOperationException(
"PDFium annotation export currently supports only the original PDF page order."
)
}
val exportDir = File(context.cacheDir, "pdfium_annotation_export")
if (!exportDir.exists() && !exportDir.mkdirs()) {
destStream.close()
throw IOException("Unable to create PDFium export cache directory.")
}
val sourceFile: File
val destFile: File
try {
sourceFile = File.createTempFile("source_", ".pdf", exportDir)
destFile = File.createTempFile("annotated_", ".pdf", exportDir)
} catch (e: IOException) {
destStream.close()
throw e
}
try {
context.contentResolver.openInputStream(sourceUri)?.use { input ->
FileOutputStream(sourceFile).use { output -> input.copyTo(output) }
} ?: throw IOException("Unable to open source PDF for PDFium export.")
val pageSizes = runCatching { readPdfPageSizes(sourceFile) }
.onFailure { Timber.tag("PdfExportDebug").w(it, "Unable to read page sizes for text raster export.") }
.getOrDefault(emptyList())
val rasterOverlays = buildTextRasterOverlays(
context = context,
textBoxes = textBoxes.orEmpty(),
richTextPageLayouts = richTextPageLayouts.orEmpty(),
pageSizes = pageSizes
)
val payload = buildPayload(
inkAnnotations = inkAnnotations,
textBoxes = emptyList(),
highlights = highlights.orEmpty(),
richTextPageLayouts = emptyList(),
rasterOverlays = rasterOverlays
)
if (!payload.hasAnnotations()) {
FileInputStream(sourceFile).use { input -> input.copyTo(destStream) }
return@withContext
}
val exported = NativePdfiumBridge.exportAnnotatedPdf(
sourcePath = sourceFile.absolutePath,
destPath = destFile.absolutePath,
inkPageIndices = payload.inkPageIndices,
inkTypes = payload.inkTypes,
inkColors = payload.inkColors,
inkStrokeWidths = payload.inkStrokeWidths,
inkPointOffsets = payload.inkPointOffsets,
inkPointCounts = payload.inkPointCounts,
inkPoints = payload.inkPoints,
textPageIndices = payload.textPageIndices,
textBounds = payload.textBounds,
textColors = payload.textColors,
textBackgroundColors = payload.textBackgroundColors,
textFontSizes = payload.textFontSizes,
textFlags = payload.textFlags,
textValues = payload.textValues,
textFontPaths = payload.textFontPaths,
textFontNames = payload.textFontNames,
rasterPageIndices = payload.rasterPageIndices,
rasterBounds = payload.rasterBounds,
rasterWidths = payload.rasterWidths,
rasterHeights = payload.rasterHeights,
rasterPixelOffsets = payload.rasterPixelOffsets,
rasterPixels = payload.rasterPixels,
highlightPageIndices = payload.highlightPageIndices,
highlightColors = payload.highlightColors,
highlightRectOffsets = payload.highlightRectOffsets,
highlightRectCounts = payload.highlightRectCounts,
highlightRects = payload.highlightRects,
highlightContents = payload.highlightContents
)
if (!exported) {
throw IOException("PDFium failed to write annotated PDF.")
}
FileInputStream(destFile).use { input -> input.copyTo(destStream) }
Timber.tag("PdfExportDebug").i(
"PDFium export saved ${payload.inkPageIndices.size} ink, " +
"${payload.highlightPageIndices.size} highlight, " +
"${payload.rasterPageIndices.size} raster text overlays."
)
} finally {
destStream.close()
sourceFile.delete()
destFile.delete()
}
}
}
internal fun supportsOriginalPageOrder(virtualPages: List<VirtualPage>?): Boolean {
return virtualPages == null || virtualPages.withIndex().all { (index, page) ->
page is VirtualPage.PdfPage && page.pdfIndex == index
}
}
@Suppress("UNUSED_PARAMETER")
internal fun buildPayload(
inkAnnotations: Map<Int, List<PdfAnnotation>>,
textBoxes: List<PdfTextBox>,
highlights: List<PdfUserHighlight>,
richTextPageLayouts: List<PageTextLayout> = emptyList(),
fontPathResolver: (String?) -> String? = { it },
rasterOverlays: List<PdfiumRasterOverlay> = emptyList()
): PdfiumAnnotationExportPayload {
val inkItems = inkAnnotations.entries
.flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } }
.filter { (_, annotation) ->
annotation.points.size >= 2 &&
annotation.inkType != InkType.ERASER &&
annotation.inkType != InkType.TEXT
}
val inkPageIndices = IntArray(inkItems.size)
val inkTypes = IntArray(inkItems.size)
val inkColors = IntArray(inkItems.size)
val inkStrokeWidths = FloatArray(inkItems.size)
val inkPointOffsets = IntArray(inkItems.size)
val inkPointCounts = IntArray(inkItems.size)
val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2)
var inkPointCursor = 0
inkItems.forEachIndexed { index, (pageIndex, annotation) ->
inkPageIndices[index] = pageIndex
inkTypes[index] = annotation.inkType.ordinal
inkColors[index] = annotation.color.toArgb()
inkStrokeWidths[index] = annotation.strokeWidth
inkPointOffsets[index] = inkPointCursor / 2
inkPointCounts[index] = annotation.points.size
annotation.points.forEach { point ->
inkPoints[inkPointCursor++] = point.x
inkPoints[inkPointCursor++] = point.y
}
}
val textPageIndices = IntArray(0)
val textBounds = FloatArray(0)
val textColors = IntArray(0)
val textBackgroundColors = IntArray(0)
val textFontSizes = FloatArray(0)
val textFlags = IntArray(0)
val textValues = emptyArray<String>()
val textFontPaths = emptyArray<String>()
val textFontNames = emptyArray<String>()
val rasterPageIndices = IntArray(rasterOverlays.size)
val rasterBounds = FloatArray(rasterOverlays.size * 4)
val rasterWidths = IntArray(rasterOverlays.size)
val rasterHeights = IntArray(rasterOverlays.size)
val rasterPixelOffsets = IntArray(rasterOverlays.size)
val rasterPixels = IntArray(rasterOverlays.sumOf { it.pixels.size })
var rasterPixelCursor = 0
rasterOverlays.forEachIndexed { index, overlay ->
rasterPageIndices[index] = overlay.pageIndex
rasterBounds[index * 4] = overlay.left
rasterBounds[index * 4 + 1] = overlay.top
rasterBounds[index * 4 + 2] = overlay.right
rasterBounds[index * 4 + 3] = overlay.bottom
rasterWidths[index] = overlay.width
rasterHeights[index] = overlay.height
rasterPixelOffsets[index] = rasterPixelCursor
overlay.pixels.copyInto(rasterPixels, rasterPixelCursor)
rasterPixelCursor += overlay.pixels.size
}
val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() }
val highlightPageIndices = IntArray(boundedHighlights.size)
val highlightColors = IntArray(boundedHighlights.size)
val highlightRectOffsets = IntArray(boundedHighlights.size)
val highlightRectCounts = IntArray(boundedHighlights.size)
val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4)
val highlightContents = Array(boundedHighlights.size) { "" }
var highlightRectCursor = 0
boundedHighlights.forEachIndexed { index, highlight ->
highlightPageIndices[index] = highlight.pageIndex
highlightColors[index] = highlight.color.color.toArgb()
highlightRectOffsets[index] = highlightRectCursor / 4
highlightRectCounts[index] = highlight.bounds.size
highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text
highlight.bounds.forEach { rect ->
highlightRects[highlightRectCursor++] = rect.left
highlightRects[highlightRectCursor++] = rect.top
highlightRects[highlightRectCursor++] = rect.right
highlightRects[highlightRectCursor++] = rect.bottom
}
}
return PdfiumAnnotationExportPayload(
inkPageIndices = inkPageIndices,
inkTypes = inkTypes,
inkColors = inkColors,
inkStrokeWidths = inkStrokeWidths,
inkPointOffsets = inkPointOffsets,
inkPointCounts = inkPointCounts,
inkPoints = inkPoints,
textPageIndices = textPageIndices,
textBounds = textBounds,
textColors = textColors,
textBackgroundColors = textBackgroundColors,
textFontSizes = textFontSizes,
textFlags = textFlags,
textValues = textValues,
textFontPaths = textFontPaths,
textFontNames = textFontNames,
rasterPageIndices = rasterPageIndices,
rasterBounds = rasterBounds,
rasterWidths = rasterWidths,
rasterHeights = rasterHeights,
rasterPixelOffsets = rasterPixelOffsets,
rasterPixels = rasterPixels,
highlightPageIndices = highlightPageIndices,
highlightColors = highlightColors,
highlightRectOffsets = highlightRectOffsets,
highlightRectCounts = highlightRectCounts,
highlightRects = highlightRects,
highlightContents = highlightContents
)
}
private fun buildTextRasterOverlays(
context: Context,
textBoxes: List<PdfTextBox>,
richTextPageLayouts: List<PageTextLayout>,
pageSizes: List<PdfiumPageSize>
): List<PdfiumRasterOverlay> {
val overlays = mutableListOf<PdfiumRasterOverlay>()
textBoxes.mapNotNullTo(overlays) { box ->
renderTextBoxOverlay(context, box, pageSizeFor(pageSizes, box.pageIndex))
}
richTextPageLayouts.mapNotNullTo(overlays) { layout ->
renderRichTextOverlay(context, layout, pageSizeFor(pageSizes, layout.pageIndex))
}
return overlays
}
private fun renderTextBoxOverlay(
context: Context,
box: PdfTextBox,
pageSize: PdfiumPageSize
): PdfiumRasterOverlay? {
val text = box.text.sanitizeRasterText()
if (box.pageIndex < 0 || text.isBlank()) return null
val bounds = box.relativeBounds
val left = bounds.left.coerceIn(0f, 1f)
val top = bounds.top.coerceIn(0f, 1f)
val right = bounds.right.coerceIn(left, 1f)
val bottom = bounds.bottom.coerceIn(top, 1f)
if (right - left <= 0f || bottom - top <= 0f) return null
val pageHeightPx = pageSize.exportHeightPx()
val pageWidthPx = pageHeightPx * pageSize.aspect
val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1)
val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1)
val paddingPx = dpToPx(context, TEXT_BOX_PADDING_DP)
.coerceAtMost((minOf(bitmapWidth, bitmapHeight) / 2f).coerceAtLeast(0f))
val contentWidth = (bitmapWidth - paddingPx * 2f).roundToInt().coerceAtLeast(1)
val fontSizePx = (box.fontSize * pageHeightPx).coerceAtLeast(1f)
val typeface = resolveTypeface(context, box.fontPath, box.fontName, box.isBold, box.isItalic)
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
return try {
val paint = textPaint(
colorArgb = box.color.toArgb(),
textSizePx = fontSizePx,
typeface = typeface
)
val spannable = SpannableString(text)
applyTextBoxSpans(
text = spannable,
colorArgb = box.color.toArgb(),
backgroundArgb = box.backgroundColor.toArgb(),
fontSizePx = fontSizePx,
isBold = box.isBold,
isItalic = box.isItalic,
isUnderline = box.isUnderline,
isStrikeThrough = box.isStrikeThrough,
typeface = typeface
)
drawStaticLayout(
bitmap = bitmap,
text = spannable,
paint = paint,
width = contentWidth,
translateX = paddingPx,
translateY = paddingPx
)
bitmap.toRasterOverlay(box.pageIndex, left, top, right, bottom)
} finally {
bitmap.recycle()
}
}
private fun renderRichTextOverlay(
context: Context,
layout: PageTextLayout,
pageSize: PdfiumPageSize
): PdfiumRasterOverlay? {
val visibleText = layout.visibleText.withoutTrailingPdfiumPageBreak()
if (layout.pageIndex < 0 || visibleText.text.isBlank()) return null
val pageHeightPx = layout.pageHeightPx.takeIf { it > 0f } ?: pageSize.exportHeightPx()
val pageWidthPx = pageHeightPx * pageSize.aspect
val left = RICH_TEXT_MARGIN_X
val top = RICH_TEXT_MARGIN_Y
val right = 1f - RICH_TEXT_MARGIN_X
val bottom = 1f - RICH_TEXT_MARGIN_Y
val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1)
val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
return try {
val paint = textPaint(
colorArgb = Color.Black.toArgb(),
textSizePx = spToPx(context, 16f),
typeface = Typeface.DEFAULT
)
val spannable = visibleText.toAndroidSpannable(context)
drawStaticLayout(
bitmap = bitmap,
text = spannable,
paint = paint,
width = bitmapWidth,
translateX = 0f,
translateY = 0f
)
bitmap.toRasterOverlay(layout.pageIndex, left, top, right, bottom)
} finally {
bitmap.recycle()
}
}
private fun applyTextBoxSpans(
text: SpannableString,
colorArgb: Int,
backgroundArgb: Int,
fontSizePx: Float,
isBold: Boolean,
isItalic: Boolean,
isUnderline: Boolean,
isStrikeThrough: Boolean,
typeface: Typeface
) {
if (text.isEmpty()) return
val end = text.length
text.setSpan(ForegroundColorSpan(colorArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if ((backgroundArgb ushr 24) != 0) {
text.setSpan(BackgroundColorSpan(backgroundArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
text.setSpan(AbsoluteSizeSpan(fontSizePx.roundToInt().coerceAtLeast(1), false), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
text.setSpan(TypefaceSpanCompat(typeface), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (!hasStyle(typeface, isBold, isItalic)) {
text.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (isUnderline) {
text.setSpan(UnderlineSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (isStrikeThrough) {
text.setSpan(StrikethroughSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun AnnotatedString.toAndroidSpannable(context: Context): SpannableString {
val spannable = SpannableString(text.sanitizeRasterTextPreservingLength())
spanStyles.forEach { range ->
applySpanStyle(context, spannable, range.item, range.start, range.end)
}
return spannable
}
private fun applySpanStyle(
context: Context,
spannable: SpannableString,
style: SpanStyle,
rawStart: Int,
rawEnd: Int
) {
val start = rawStart.coerceIn(0, spannable.length)
val end = rawEnd.coerceIn(start, spannable.length)
if (start >= end) return
val color = style.color
if (color != Color.Unspecified) {
spannable.setSpan(ForegroundColorSpan(color.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val background = style.background
if (background != Color.Unspecified && background.alpha > 0f) {
spannable.setSpan(BackgroundColorSpan(background.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (style.fontSize.isSpecified) {
val textSizePx = spToPx(context, style.fontSize.value)
spannable.setSpan(
AbsoluteSizeSpan(textSizePx.roundToInt().coerceAtLeast(1), false),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
val isBold = isBold(style.fontWeight)
val isItalic = style.fontStyle == FontStyle.Italic
val fontPath = PdfFontCache.getPath(style.fontFamily)
val fontName = standardFontName(style.fontFamily)
val typeface = resolveTypeface(context, fontPath, fontName, isBold, isItalic)
if (fontPath != null || fontName != null) {
spannable.setSpan(TypefaceSpanCompat(typeface), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} else if (isBold || isItalic) {
spannable.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val decoration = style.textDecoration ?: TextDecoration.None
if (decoration.contains(TextDecoration.Underline)) {
spannable.setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (decoration.contains(TextDecoration.LineThrough)) {
spannable.setSpan(StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun drawStaticLayout(
bitmap: Bitmap,
text: CharSequence,
paint: TextPaint,
width: Int,
translateX: Float,
translateY: Float
) {
val canvas = Canvas(bitmap)
canvas.save()
canvas.clipRect(0, 0, bitmap.width, bitmap.height)
canvas.translate(translateX, translateY)
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.setLineSpacing(0f, 1f)
.build()
.draw(canvas)
canvas.restore()
}
private fun textPaint(
colorArgb: Int,
textSizePx: Float,
typeface: Typeface
): TextPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
color = colorArgb
textSize = textSizePx
this.typeface = typeface
}
private fun Bitmap.toRasterOverlay(
pageIndex: Int,
boundsLeft: Float,
boundsTop: Float,
boundsRight: Float,
boundsBottom: Float
): PdfiumRasterOverlay? {
val allPixels = IntArray(width * height)
getPixels(allPixels, 0, width, 0, 0, width, height)
var minX = width
var minY = height
var maxX = -1
var maxY = -1
for (y in 0 until height) {
val rowOffset = y * width
for (x in 0 until width) {
if ((allPixels[rowOffset + x] ushr 24) != 0) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
}
if (maxX < minX || maxY < minY) return null
val cropWidth = maxX - minX + 1
val cropHeight = maxY - minY + 1
val cropped = IntArray(cropWidth * cropHeight)
for (row in 0 until cropHeight) {
System.arraycopy(
allPixels,
(minY + row) * width + minX,
cropped,
row * cropWidth,
cropWidth
)
}
val boundsWidth = boundsRight - boundsLeft
val boundsHeight = boundsBottom - boundsTop
return PdfiumRasterOverlay(
pageIndex = pageIndex,
left = boundsLeft + boundsWidth * (minX.toFloat() / width),
top = boundsTop + boundsHeight * (minY.toFloat() / height),
right = boundsLeft + boundsWidth * ((maxX + 1).toFloat() / width),
bottom = boundsTop + boundsHeight * ((maxY + 1).toFloat() / height),
width = cropWidth,
height = cropHeight,
pixels = cropped
)
}
private fun readPdfPageSizes(sourceFile: File): List<PdfiumPageSize> {
return ParcelFileDescriptor.open(sourceFile, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor ->
PdfRenderer(descriptor).use { renderer ->
List(renderer.pageCount) { index ->
val page = renderer.openPage(index)
try {
PdfiumPageSize(page.width, page.height)
} finally {
page.close()
}
}
}
}
}
private fun pageSizeFor(pageSizes: List<PdfiumPageSize>, pageIndex: Int): PdfiumPageSize =
pageSizes.getOrNull(pageIndex) ?: PdfiumPageSize.Default
private fun PdfiumPageSize.exportHeightPx(): Float =
(height * TEXT_RASTER_PDF_POINT_SCALE)
.coerceIn(TEXT_RASTER_MIN_PAGE_HEIGHT_PX, TEXT_RASTER_MAX_PAGE_HEIGHT_PX)
private fun resolveTypeface(
context: Context,
fontPath: String?,
fontName: String?,
isBold: Boolean,
isItalic: Boolean
): Typeface {
val base = try {
when {
!fontPath.isNullOrBlank() && fontPath.startsWith("asset:") ->
Typeface.createFromAsset(context.assets, fontPath.removePrefix("asset:"))
!fontPath.isNullOrBlank() ->
Typeface.createFromFile(fontPath)
else -> when (fontName?.lowercase(Locale.US)) {
"serif" -> Typeface.SERIF
"monospace" -> Typeface.MONOSPACE
"cursive" -> Typeface.create("casual", Typeface.NORMAL)
"sans", "sansserif", "sans-serif" -> Typeface.SANS_SERIF
else -> Typeface.DEFAULT
}
}
} catch (e: Exception) {
Timber.tag("PdfFontDebug").w(e, "Falling back while rasterizing fontPath=$fontPath fontName=$fontName")
Typeface.DEFAULT
}
return Typeface.create(base, typefaceStyle(isBold, isItalic))
}
private fun typefaceStyle(isBold: Boolean, isItalic: Boolean): Int =
when {
isBold && isItalic -> Typeface.BOLD_ITALIC
isBold -> Typeface.BOLD
isItalic -> Typeface.ITALIC
else -> Typeface.NORMAL
}
private fun hasStyle(typeface: Typeface, isBold: Boolean, isItalic: Boolean): Boolean {
val style = typeface.style
return (!isBold || style and Typeface.BOLD != 0) &&
(!isItalic || style and Typeface.ITALIC != 0)
}
private fun isBold(weight: FontWeight?): Boolean =
(weight?.weight ?: FontWeight.Normal.weight) >= FontWeight.SemiBold.weight
private fun standardFontName(fontFamily: FontFamily?): String? =
when (fontFamily) {
FontFamily.Serif -> "Serif"
FontFamily.Monospace -> "Monospace"
FontFamily.SansSerif -> "Sans"
FontFamily.Cursive -> "Cursive"
else -> null
}
private fun dpToPx(context: Context, value: Float): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, context.resources.displayMetrics)
private fun spToPx(context: Context, value: Float): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, context.resources.displayMetrics)
private fun AnnotatedString.withoutTrailingPdfiumPageBreak(): AnnotatedString =
if (text.lastOrNull() == PAGE_BREAK_CHAR) subSequence(0, length - 1) else this
private fun String.sanitizeRasterText(): String =
replace(PAGE_BREAK_CHAR, '\n')
.replace("\u200B", "")
.replace('\r', ' ')
private fun String.sanitizeRasterTextPreservingLength(): String =
replace(PAGE_BREAK_CHAR, '\n')
.replace('\r', ' ')
}
internal data class PdfiumRasterOverlay(
val pageIndex: Int,
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val width: Int,
val height: Int,
val pixels: IntArray
)
private data class PdfiumPageSize(
val width: Int,
val height: Int
) {
val aspect: Float
get() = if (width > 0 && height > 0) width.toFloat() / height.toFloat() else Default.aspect
companion object {
val Default = PdfiumPageSize(612, 792)
}
}
private class TypefaceSpanCompat(
private val typeface: Typeface
) : MetricAffectingSpan() {
override fun updateDrawState(tp: TextPaint) {
apply(tp)
}
override fun updateMeasureState(tp: TextPaint) {
apply(tp)
}
private fun apply(paint: Paint) {
val oldStyle = paint.typeface?.style ?: Typeface.NORMAL
val missingStyles = oldStyle and typeface.style.inv()
if (missingStyles and Typeface.BOLD != 0) {
paint.isFakeBoldText = true
}
if (missingStyles and Typeface.ITALIC != 0) {
paint.textSkewX = -0.25f
}
paint.typeface = typeface
}
}
internal data class PdfiumAnnotationExportPayload(
val inkPageIndices: IntArray,
val inkTypes: IntArray,
val inkColors: IntArray,
val inkStrokeWidths: FloatArray,
val inkPointOffsets: IntArray,
val inkPointCounts: IntArray,
val inkPoints: FloatArray,
val textPageIndices: IntArray,
val textBounds: FloatArray,
val textColors: IntArray,
val textBackgroundColors: IntArray,
val textFontSizes: FloatArray,
val textFlags: IntArray,
val textValues: Array<String>,
val textFontPaths: Array<String>,
val textFontNames: Array<String>,
val rasterPageIndices: IntArray,
val rasterBounds: FloatArray,
val rasterWidths: IntArray,
val rasterHeights: IntArray,
val rasterPixelOffsets: IntArray,
val rasterPixels: IntArray,
val highlightPageIndices: IntArray,
val highlightColors: IntArray,
val highlightRectOffsets: IntArray,
val highlightRectCounts: IntArray,
val highlightRects: FloatArray,
val highlightContents: Array<String>
) {
fun hasAnnotations(): Boolean =
inkPageIndices.isNotEmpty() ||
textPageIndices.isNotEmpty() ||
rasterPageIndices.isNotEmpty() ||
highlightPageIndices.isNotEmpty()
}

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
@ -56,12 +57,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import timber.log.Timber
import java.io.File
const val PAGE_BREAK_CHAR = '\u000C'
private const val ZWSP = "\u200B"
internal fun String.hasRenderableRichText(): Boolean =
any { it != PAGE_BREAK_CHAR && !it.isWhitespace() }
object PdfFontCache {
private val cache = ConcurrentHashMap<String, FontFamily>()
private var assetManager: android.content.res.AssetManager? = null
@ -262,126 +267,200 @@ class TextPaginationEngine {
dirtyGlobalIndex: Int = 0
): List<PageTextLayout> {
val totalLen = globalText.length
if (totalLen == 0) return listOf(
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
)
if (totalLen == 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
return listOf(
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
)
if (pageWidthPx <= 0 || pageHeightPx <= 0) return emptyList()
val validPages = if (dirtyGlobalIndex > 0 && previousLayouts.isNotEmpty()) {
previousLayouts.takeWhile { it.globalEndIndex < dirtyGlobalIndex }
} else {
emptyList()
}
val startPageIndex = validPages.size
val measurementStartIndex = validPages.lastOrNull()?.globalEndIndex ?: 0
if (measurementStartIndex >= totalLen) return validPages
val textToMeasure = globalText.subSequence(measurementStartIndex, totalLen)
val fullString = textToMeasure.text
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
return emptyList()
}
val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f)
val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f)
val measureResult = textMeasurer.measure(
text = textToMeasure,
style = TextStyle(fontSize = 16.sp, color = Color.Black),
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
val newPages = mutableListOf<PageTextLayout>()
var currentPageIndex = 0
var segmentStart = 0
val rawText = globalText.text
while (segmentStart < totalLen) {
val breakIndex = rawText.indexOf(PAGE_BREAK_CHAR, startIndex = segmentStart)
val hasExplicitBreak = breakIndex != -1
val contentEnd = if (hasExplicitBreak) breakIndex else totalLen
val segmentEnd = if (hasExplicitBreak) breakIndex + 1 else totalLen
currentPageIndex = newPages.appendMeasuredAndroidRichTextSegment(
globalText = globalText,
segmentStart = segmentStart,
contentEnd = contentEnd,
explicitBreakEnd = if (hasExplicitBreak) segmentEnd else null,
pageIndex = currentPageIndex,
pageHeightPx = pageHeightPx,
editorWidth = editorWidth,
editorHeight = editorHeight,
textMeasurer = textMeasurer,
density = density
)
val newPages = mutableListOf<PageTextLayout>()
var currentPageIndex = startPageIndex
var currentPageStartRel = 0
var currentPageAccumulatedHeight = 0f
var currentLineIndex = 0
val totalLines = measureResult.lineCount
Timber.tag("RichTextFlow").d("Pagination: Measuring ${fullString.length} chars from Global $measurementStartIndex. Lines: $totalLines")
while (currentLineIndex < totalLines) {
val lineTop = measureResult.getLineTop(currentLineIndex)
val lineBottom = measureResult.getLineBottom(currentLineIndex)
val lineHeight = lineBottom - lineTop
val lineStartRel = measureResult.getLineStart(currentLineIndex)
val lineEndRel = measureResult.getLineEnd(currentLineIndex)
val localStartOffset = (currentPageStartRel - lineStartRel).coerceAtLeast(0)
if (lineStartRel + localStartOffset >= lineEndRel && currentLineIndex < totalLines - 1) {
currentLineIndex++
continue
segmentStart = segmentEnd
}
val safeEndRel = lineEndRel.coerceAtMost(fullString.length)
val lineContent = fullString.substring(lineStartRel, safeEndRel)
val breakIndexInLine = lineContent.indexOf(PAGE_BREAK_CHAR, localStartOffset)
val hasPageBreak = breakIndexInLine != -1
val isStartOfPage = (currentPageAccumulatedHeight == 0f)
val willOverflow = !isStartOfPage && (currentPageAccumulatedHeight + lineHeight > editorHeight)
if (hasPageBreak) {
val splitRelIndex = lineStartRel + breakIndexInLine + 1
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + splitRelIndex
Timber.tag("RichTextMigration").v("PaginationEngine: Found PAGE_BREAK_CHAR at relative ${breakIndexInLine}. Breaking Page $currentPageIndex at Global Index $globalEnd")
if (globalEnd > globalStart) {
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
currentPageIndex++
}
currentPageStartRel = splitRelIndex
currentPageAccumulatedHeight = 0f
continue
}
else if (willOverflow) {
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + lineStartRel
if (globalEnd > globalStart) {
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
Timber.tag("RichTextFlow").v("Page $currentPageIndex Created (Overflow): $globalStart -> $globalEnd")
currentPageIndex++
}
currentPageStartRel = lineStartRel
currentPageAccumulatedHeight = 0f
continue
}
currentPageAccumulatedHeight += lineHeight
currentLineIndex++
}
if (currentPageStartRel < fullString.length) {
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + fullString.length
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
}
val resultLayouts = validPages + newPages
val resultLayouts = newPages.withTrailingAndroidBlankRichTextPageIfNeeded(
globalText = globalText,
pageHeightPx = pageHeightPx
)
val mapLog = resultLayouts.joinToString("\n") {
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
}
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
return resultLayouts
}
}
private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
globalText: AnnotatedString,
segmentStart: Int,
contentEnd: Int,
explicitBreakEnd: Int?,
pageIndex: Int,
pageHeightPx: Float,
editorWidth: Float,
editorHeight: Float,
textMeasurer: TextMeasurer,
density: Density
): Int {
var nextPageIndex = pageIndex
if (segmentStart >= contentEnd) {
val breakEnd = explicitBreakEnd ?: return nextPageIndex
add(
PageTextLayout(
pageIndex = nextPageIndex,
visibleText = globalText.subSequence(segmentStart, breakEnd),
globalStartIndex = segmentStart,
globalEndIndex = breakEnd,
pageHeightPx = pageHeightPx
)
)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
)
return nextPageIndex + 1
}
val contentLength = contentEnd - segmentStart
var relativeStart = 0
while (relativeStart < contentLength) {
val globalStart = segmentStart + relativeStart
val remainingText = globalText.subSequence(globalStart, contentEnd)
val measureResult = textMeasurer.measure(
text = remainingText,
style = TextStyle(fontSize = 16.sp, color = Color.Black),
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
density = density
)
val fitsOnPage = measureResult.size.height.toFloat() <= editorHeight || measureResult.lineCount <= 1
var overflowLineIndex: Int? = null
val relativeEnd = if (fitsOnPage) {
contentLength
} else {
val lineIndex = measureResult.richAndroidLastFittingLineIndex(editorHeight)
overflowLineIndex = lineIndex
val localEnd = measureResult.getLineEnd(lineIndex)
.coerceIn(0, remainingText.length)
.coerceAtLeast(1)
(relativeStart + localEnd)
.coerceAtLeast(relativeStart + 1)
.coerceAtMost(contentLength)
}
val isLastContentPage = relativeEnd >= contentLength
val globalEnd = if (isLastContentPage && explicitBreakEnd != null) {
explicitBreakEnd
} else {
segmentStart + relativeEnd
}
add(
PageTextLayout(
pageIndex = nextPageIndex,
visibleText = globalText.subSequence(globalStart, globalEnd),
globalStartIndex = globalStart,
globalEndIndex = globalEnd,
pageHeightPx = pageHeightPx
)
)
if (isLastContentPage && explicitBreakEnd != null) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
)
} else if (!fitsOnPage) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
)
}
nextPageIndex++
relativeStart = relativeEnd
}
return nextPageIndex
}
private fun TextLayoutResult.richAndroidLastFittingLineIndex(editorHeight: Float): Int {
var lastFitting = 0
for (lineIndex in 0 until lineCount) {
if (lineIndex == 0 || getLineBottom(lineIndex) <= editorHeight) {
lastFitting = lineIndex
} else {
break
}
}
return lastFitting.coerceIn(0, (lineCount - 1).coerceAtLeast(0))
}
private fun List<PageTextLayout>.withTrailingAndroidBlankRichTextPageIfNeeded(
globalText: AnnotatedString,
pageHeightPx: Float
): List<PageTextLayout> {
if (globalText.text.lastOrNull() != PAGE_BREAK_CHAR) return this
val lastLayout = lastOrNull()
val trailingStart = globalText.length
if (lastLayout != null &&
lastLayout.globalStartIndex == trailingStart &&
lastLayout.globalEndIndex == trailingStart
) {
return this
}
return this + PageTextLayout(
pageIndex = (lastLayout?.pageIndex ?: -1) + 1,
visibleText = AnnotatedString(""),
globalStartIndex = trailingStart,
globalEndIndex = trailingStart,
pageHeightPx = pageHeightPx
)
}
private fun AnnotatedString.withoutTrailingAndroidPageBreak(): AnnotatedString {
return if (text.lastOrNull() == PAGE_BREAK_CHAR) {
subSequence(0, length - 1)
} else {
this
}
}
private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore: Boolean): AnnotatedString {
if (!shouldRestore) return this
if (text.lastOrNull() == PAGE_BREAK_CHAR) return this
return this + AnnotatedString(PAGE_BREAK_CHAR.toString())
}
class PdfRichTextRepository(private val context: Context) {
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
val document = _document.asStateFlow()
@ -396,8 +475,12 @@ class PdfRichTextRepository(private val context: Context) {
suspend fun load(bookId: String) {
withContext(Dispatchers.IO) {
val file = getFile(bookId)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
)
if (!file.exists()) {
_document.value = GlobalRichDocument("", emptyList())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
return@withContext
}
try {
@ -425,7 +508,11 @@ class PdfRichTextRepository(private val context: Context) {
)
}
_document.value = GlobalRichDocument(text, spans)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
)
} catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId")
Timber.e(e, "Failed to load rich text doc")
_document.value = GlobalRichDocument("", emptyList())
}
@ -436,6 +523,9 @@ class PdfRichTextRepository(private val context: Context) {
_document.value = document
withContext(Dispatchers.IO) {
try {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
)
val obj = JSONObject().apply {
put("text", document.text)
val spansArray = JSONArray()
@ -456,14 +546,35 @@ class PdfRichTextRepository(private val context: Context) {
}
put("spans", spansArray)
}
getFile(bookId).writeText(obj.toString())
val file = getFile(bookId)
file.writeText(obj.toString())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
)
} catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId")
Timber.e(e, "Failed to save rich text doc")
}
}
}
}
private fun Float.richAndroidLogFloat(): String {
return if (isFinite()) {
val rounded = kotlin.math.round(this * 10f) / 10f
rounded.toString()
} else {
toString()
}
}
private fun List<PageTextLayout>.richAndroidLayoutSummary(): String {
if (isEmpty()) return "[]"
return joinToString(prefix = "[", postfix = "]", limit = 8, truncated = "...") { layout ->
"p${layout.pageIndex}:${layout.globalStartIndex}-${layout.globalEndIndex}/len${layout.visibleText.length}"
}
}
@Stable
class RichTextController(
private val repository: PdfRichTextRepository,
@ -485,6 +596,9 @@ class RichTextController(
var pageLayouts by mutableStateOf(emptyList<PageTextLayout>())
private set
val hasRenderableText: Boolean
get() = globalTextFieldValue.text.hasRenderableRichText()
var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp))
private set
@ -720,9 +834,11 @@ class RichTextController(
val currentGlobal = globalTextFieldValue.annotatedString
// FIX: Strip ZWSP (index 0) from local text
val localText = if (localTextFieldValue.annotatedString.isNotEmpty()) {
val localEditableText = if (localTextFieldValue.annotatedString.isNotEmpty()) {
localTextFieldValue.annotatedString.subSequence(1, localTextFieldValue.annotatedString.length)
} else AnnotatedString("")
val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR
val localText = localEditableText.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak)
Timber.tag("RichTextFlow").d("Sync: Page $activePageIndex, GlobalRange [$globalStart..$globalEnd], LocalLen ${localText.length}")
@ -776,7 +892,7 @@ class RichTextController(
activePageIndex = newActiveLayout.pageIndex
val reExtractedText = newGlobalAnnotated.subSequence(
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtractedText
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1)
.coerceIn(0, textWithZwsp.length)
@ -865,28 +981,26 @@ class RichTextController(
val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f)
val vText = currentLayout.visibleText
val editableText = vText.withoutTrailingAndroidPageBreak()
// FIX: Prepend ZWSP to the visible text
val textWithZwsp = AnnotatedString(ZWSP) + vText
val safeLen = if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR) vText.length - 1 else vText.length
val textWithZwsp = AnnotatedString(ZWSP) + editableText
val safeLen = editableText.length
// FIX: Adjust initial selection by +1 because of ZWSP
localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1))
val measureResult = measurer.measure(
text = currentLayout.visibleText, // We measure the original for layout tap calc
text = editableText, // We measure editable text, not the hidden page-break sentinel
style = TextStyle(fontSize = 16.sp, color = Color.Black),
constraints = Constraints(maxWidth = editorWidth.toInt()),
density = density
)
val textHeight = measureResult.size.height.toFloat()
val textHeight = if (editableText.isEmpty()) 0f else measureResult.size.height.toFloat()
if (localTapOffset.y <= textHeight) {
if (editableText.isNotEmpty() && localTapOffset.y <= textHeight) {
var localIndex = measureResult.getOffsetForPosition(localTapOffset)
if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR && localIndex >= vText.length) {
localIndex = vText.length - 1
}
localIndex = localIndex.coerceIn(0, editableText.length)
localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1))
} else {
val gap = localTapOffset.y - textHeight
@ -1132,7 +1246,9 @@ class RichTextController(
val currentGlobal = globalTextFieldValue.annotatedString
val localAnnotatedRaw = localTextFieldValue.annotatedString
val localAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("")
val localEditableAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("")
val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR
val localAnnotated = localEditableAnnotated.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak)
val charBeforeSync = if (globalStart > 0) currentGlobal.text[globalStart - 1] else "START"
val charAfterSync = if (globalEnd < currentGlobal.length) currentGlobal.text[globalEnd] else "END"
@ -1197,7 +1313,7 @@ class RichTextController(
val reExtracted = newGlobalAnnotated.subSequence(
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
@ -1301,7 +1417,7 @@ class RichTextController(
activePageIndex = finalActiveLayout.pageIndex
val reExtracted = intermediateGlobal.subSequence(
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
@ -1351,7 +1467,7 @@ class RichTextController(
val reExtracted = newGlobalText.subSequence(
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)

View file

@ -545,8 +545,11 @@ class OpdsStreamDocumentWrapper(
private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder()
.apply {
if (!catalog?.username.isNullOrBlank() && !catalog.password.isNullOrBlank()) {
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(catalog.username, catalog.password))
val streamCatalog = catalog
val username = streamCatalog?.username
val password = streamCatalog?.password
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(username, password))
}
}
.build()
@ -580,10 +583,11 @@ class OpdsStreamDocumentWrapper(
}
}
val finalUrlTemplate = if (catalog != null && urlTemplate.startsWith("http")) {
val streamCatalog = catalog
val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
try {
val oldUrl = java.net.URL(urlTemplate)
val newUrl = java.net.URL(catalog.url)
val newUrl = java.net.URL(streamCatalog.url)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)

View file

@ -186,11 +186,13 @@ class TtsController(context: Context) : Player.Listener {
)
val textList = ArrayList(chunks.map { it.text })
val spokenTextList = ArrayList(chunks.map { it.spokenText.ifBlank { it.text } })
val cfiList = ArrayList(chunks.map { it.sourceCfi })
val offsetList = ArrayList(chunks.map { it.startOffsetInSource })
val args = Bundle().apply {
putStringArrayList(KEY_TEXT_CHUNKS, textList)
putStringArrayList(KEY_SPOKEN_TEXT_CHUNKS, spokenTextList)
putStringArrayList(KEY_SOURCE_CFIS, cfiList)
putIntegerArrayList(KEY_START_OFFSETS, offsetList)
putString(KEY_SPEAKER_ID, _ttsState.value.speakerId)

View file

@ -61,6 +61,7 @@ val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYB
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SPOKEN_TEXT_CHUNKS = "KEY_SPOKEN_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
const val KEY_START_OFFSETS = "KEY_START_OFFSETS"
const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID"
@ -205,6 +206,7 @@ class TtsPlaybackManager(
)
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
val spokenTexts = args.getStringArrayList(KEY_SPOKEN_TEXT_CHUNKS)
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
val bookTitle = args.getString(KEY_BOOK_TITLE)
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
@ -218,10 +220,23 @@ class TtsPlaybackManager(
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
chunks.mapIndexed { index, text ->
val safeOffset = offsets.getOrNull(index) ?: -1
TtsChunk(text, cfis[index], safeOffset)
val spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text
TtsChunk(
text = text,
sourceCfi = cfis[index],
startOffsetInSource = safeOffset,
spokenText = spokenText,
)
}
} else {
chunks.map { TtsChunk(it, "", -1) }
chunks.mapIndexed { index, text ->
TtsChunk(
text = text,
sourceCfi = "",
startOffsetInSource = -1,
spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text,
)
}
}
val authToken = args.getString(KEY_AUTH_TOKEN)
@ -337,7 +352,11 @@ class TtsPlaybackManager(
}
val slicedText = currentChunk.text.substring(relativeOffset)
val newChunk = currentChunk.copy(text = slicedText, startOffsetInSource = offset)
val newChunk = currentChunk.copy(
text = slicedText,
startOffsetInSource = offset,
spokenText = slicedText,
)
val mutableChunks = textChunks.toMutableList()
mutableChunks[currentIdx] = newChunk
@ -540,7 +559,8 @@ class TtsPlaybackManager(
"Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
)
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text }
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
@ -572,7 +592,7 @@ class TtsPlaybackManager(
if (id != null) chunkStreamIds[startAtIndex] = id
}
val pathToUse = streamUri ?: audioFile!!.absolutePath
val mediaItem = createMediaItem(serverText, pathToUse, startAtIndex, updatedChunk)
val mediaItem = createMediaItem(updatedChunk.text, pathToUse, startAtIndex, updatedChunk)
withContext(Dispatchers.Main) {
val prepStartTime = System.currentTimeMillis()
@ -589,7 +609,7 @@ class TtsPlaybackManager(
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = playWhenReady,
currentText = serverText,
currentText = updatedChunk.text,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
@ -618,6 +638,9 @@ class TtsPlaybackManager(
if (wordTimings.isNullOrEmpty()) {
return originalChunk
}
if (originalChunk.spokenText != originalChunk.text) {
return originalChunk.copy(timedWords = emptyList())
}
val timedWords = mutableListOf<TimedWord>()
var currentSearchIndex = 0
@ -823,7 +846,8 @@ class TtsPlaybackManager(
val prefetchStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, nextChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text }
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms")
@ -842,7 +866,7 @@ class TtsPlaybackManager(
if ((audioFile != null || streamUri != null) && serverText != null) {
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
val pathToUse = streamUri ?: audioFile!!.absolutePath
val nextMediaItem = createMediaItem(serverText, pathToUse, targetIndex, updatedChunk)
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
withContext(Dispatchers.Main) {
if (audioFile != null) {

View file

@ -707,6 +707,8 @@
<string name="menu_auto_scroll">Auto Scroll</string>
<!-- TTS = Text-to-Speech. -->
<string name="menu_tts_voice_settings">TTS Voice Settings</string>
<!-- TTS = Text-to-Speech. -->
<string name="menu_tts_word_replacements">TTS Word Replacements</string>
<!-- TTS = Text-to-Speech. Debug-only menu item. -->
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
<string name="content_desc_navigate_slider">Navigate with slider</string>

View file

@ -0,0 +1,54 @@
package com.aryan.reader
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
class FileHasherTest {
@Test
fun `calculateSha256 returns known SHA-256 for stream content`() = runTest {
val hash = FileHasher.calculateSha256 {
ByteArrayInputStream("hello world".toByteArray())
}
assertEquals(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
hash
)
}
@Test
fun `calculateSha256 supports large multi-buffer streams`() = runTest {
val bytes = ByteArray(20_000) { index -> (index % 127).toByte() }
val first = FileHasher.calculateSha256 { ByteArrayInputStream(bytes) }
val second = FileHasher.calculateSha256 {
object : InputStream() {
private var index = 0
override fun read(): Int {
if (index >= bytes.size) return -1
return bytes[index++].toInt() and 0xff
}
}
}
assertEquals(first, second)
}
@Test
fun `calculateSha256 returns null when provider is null or stream throws`() = runTest {
assertNull(FileHasher.calculateSha256 { null })
assertNull(
FileHasher.calculateSha256 {
object : InputStream() {
override fun read(): Int = throw IOException("boom")
}
}
)
}
}

View file

@ -1,6 +1,8 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FileTypeResolverTest {
@ -13,6 +15,27 @@ class FileTypeResolverTest {
assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt"))
}
@Test
fun `code and data files resolve for manual viewing`() {
assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv"))
assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt"))
}
@Test
fun `manual only reader files are excluded from folder sync eligibility`() {
assertTrue(isManualOnlyReaderFileName("table.csv"))
assertTrue(isManualOnlyReaderFileName("script.kt.txt"))
assertFalse(isManualOnlyReaderFileName("chapter.html"))
assertFalse(isManualOnlyReaderFileName("notes.txt"))
assertFalse(isManualOnlyReaderFileName("book.fodt"))
assertFalse(isLocalFolderSyncEligibleFile("table.csv", "text/csv"))
assertFalse(isLocalFolderSyncEligibleFile("payload", "application/json"))
assertTrue(isLocalFolderSyncEligibleFile("chapter.html", "text/html"))
assertTrue(isLocalFolderSyncEligibleFile("book.fodt", "text/xml"))
}
@Test
fun `plain txt remains txt when inner extension is unsupported`() {
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))

View file

@ -0,0 +1,514 @@
package com.aryan.reader
import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LibraryStateProjectorTest {
@Test
fun `filterBySearch matches display name title author and tags`() {
val sciFi = tag("tag_scifi", "Sci-Fi")
val fantasy = tag("tag_fantasy", "Fantasy")
val files = listOf(
recentFile("display", displayName = "Android Patterns.pdf"),
recentFile("title", title = "Clean Architecture"),
recentFile("author", author = "Octavia Butler"),
recentFile("tagged", tags = listOf(sciFi)),
recentFile("miss", tags = listOf(fantasy))
)
assertEquals(listOf("display"), filterBySearch(files, "android").ids())
assertEquals(listOf("title"), filterBySearch(files, "architecture").ids())
assertEquals(listOf("author"), filterBySearch(files, "butler").ids())
assertEquals(listOf("tagged"), filterBySearch(files, "sci").ids())
assertEquals(files.ids(), filterBySearch(files, " ").ids())
}
@Test
fun `applyLibraryFilters requires all active filters to match`() {
val activeTag = tag("active", "Active")
val files = listOf(
recentFile(
id = "match",
type = FileType.PDF,
sourceFolderUri = "content://sync",
progressPercentage = 50f,
tags = listOf(activeTag)
),
recentFile(
id = "wrong_type",
type = FileType.EPUB,
sourceFolderUri = "content://sync",
progressPercentage = 50f,
tags = listOf(activeTag)
),
recentFile(
id = "wrong_source",
type = FileType.PDF,
sourceFolderUri = null,
progressPercentage = 50f,
tags = listOf(activeTag)
),
recentFile(
id = "completed",
type = FileType.PDF,
sourceFolderUri = "content://sync",
progressPercentage = 100f,
tags = listOf(activeTag)
)
)
val filters = LibraryFilters(
fileTypes = setOf(FileType.PDF),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf(activeTag.id)
)
assertEquals(listOf("match"), applyLibraryFilters(files, filters).ids())
assertTrue(filters.isActive)
}
@Test
fun `applyLibraryFilters supports in-app storage source`() {
val localBook = recentFile("local", uriString = "content://local", sourceFolderUri = null)
val streamedBook = recentFile("streamed", uriString = "opds-pse://book", sourceFolderUri = null)
val syncedBook = recentFile("synced", sourceFolderUri = "content://sync")
val result = applyLibraryFilters(
listOf(localBook, streamedBook, syncedBook),
LibraryFilters(sourceFolders = setOf("IN_APP_STORAGE"))
)
assertEquals(listOf("local"), result.ids())
}
@Test
fun `applyLibraryFilters treats opds streams separately from in-app storage`() {
val localBook = recentFile("local", uriString = "file:///local/book.epub", sourceFolderUri = null)
val streamedBook = recentFile("streamed", uriString = "opds-pse://book", sourceFolderUri = null)
assertEquals(
listOf("local"),
applyLibraryFilters(
listOf(localBook, streamedBook),
LibraryFilters(sourceFolders = setOf("IN_APP_STORAGE"))
).ids()
)
}
@Test
fun `applyLibraryFilters separates unread in progress and completed books`() {
val unread = recentFile("unread", progressPercentage = null)
val started = recentFile("started", progressPercentage = 1f)
val middle = recentFile("middle", progressPercentage = 45f)
val done = recentFile("done", progressPercentage = 100f)
val files = listOf(unread, started, middle, done)
assertEquals(
listOf("unread"),
applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.UNREAD)).ids()
)
assertEquals(
listOf("started", "middle"),
applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.IN_PROGRESS)).ids()
)
assertEquals(
listOf("done"),
applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.COMPLETED)).ids()
)
}
@Test
fun `sortFiles orders by title author progress size and recency`() {
val files = listOf(
recentFile("charlie", title = "Charlie", author = null, timestamp = 3L, progressPercentage = 50f, fileSize = 300L),
recentFile("alpha", title = "Alpha", author = "Zimmer", timestamp = 1L, progressPercentage = 10f, fileSize = 100L),
recentFile("bravo", title = "Bravo", author = "Asimov", timestamp = 2L, progressPercentage = 90f, fileSize = 200L)
)
assertEquals(listOf("charlie", "bravo", "alpha"), sortFiles(files, SortOrder.RECENT).ids())
assertEquals(listOf("alpha", "bravo", "charlie"), sortFiles(files, SortOrder.TITLE_ASC).ids())
assertEquals(listOf("bravo", "alpha", "charlie"), sortFiles(files, SortOrder.AUTHOR_ASC).ids())
assertEquals(listOf("alpha", "charlie", "bravo"), sortFiles(files, SortOrder.PERCENT_ASC).ids())
assertEquals(listOf("bravo", "charlie", "alpha"), sortFiles(files, SortOrder.PERCENT_DESC).ids())
assertEquals(listOf("alpha", "bravo", "charlie"), sortFiles(files, SortOrder.SIZE_ASC).ids())
assertEquals(listOf("charlie", "bravo", "alpha"), sortFiles(files, SortOrder.SIZE_DESC).ids())
}
@Test
fun `sortFiles falls back to display names and keeps unknown authors last`() {
val files = listOf(
recentFile("unknown", displayName = "Zulu.epub", title = null, author = null),
recentFile("known", displayName = "Beta.epub", title = null, author = "Ada"),
recentFile("title", displayName = "Alpha.epub", title = "Omega", author = "Grace")
)
assertEquals(listOf("known", "title", "unknown"), sortFiles(files, SortOrder.AUTHOR_ASC).ids())
assertEquals(listOf("known", "title", "unknown"), sortFiles(files, SortOrder.TITLE_ASC).ids())
}
@Test
fun `project builds non-reader library state from repository data`() {
val tag = tag("tag_favorite", "Favorite")
val alpha = recentFile(
id = "alpha",
type = FileType.PDF,
title = "Zebra",
timestamp = 30L,
progressPercentage = 100f
)
val beta = recentFile(
id = "beta",
type = FileType.EPUB,
title = "Alpha",
timestamp = 20L,
sourceFolderUri = "content://sync",
progressPercentage = 40f
)
val gamma = recentFile(
id = "gamma",
type = FileType.MD,
title = "Notes",
timestamp = 10L,
isRecent = false
)
val reflowCopy = recentFile(id = "beta_reflow", title = "Alpha Reflow")
val manualShelf = shelfEntity("manual", "Manual")
val state = ReaderScreenState(
sortOrder = SortOrder.TITLE_ASC,
recentFilesLimit = 1,
openTabIds = listOf("beta", "missing"),
contextualActionItems = setOf(recentFile("beta"), recentFile("missing")),
viewingShelfId = "manual",
contextualActionShelfIds = setOf("manual", "missing")
)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = state,
recentFilesFromDb = listOf(alpha, beta, gamma, reflowCopy),
dbShelves = listOf(manualShelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "alpha", shelfId = "manual", addedAt = 1L)),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "beta", tagId = tag.id))
)
)
assertEquals(listOf("beta", "gamma", "alpha"), result.allRecentFiles.ids())
assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids())
assertEquals(listOf("beta"), result.recentFiles.ids())
assertEquals(listOf("beta"), result.openTabs.ids())
assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
assertEquals(listOf(tag), result.contextualActionItems.first().tags)
assertEquals("manual", result.viewingShelfId)
assertEquals(setOf("manual"), result.contextualActionShelfIds)
assertEquals(listOf(tag), result.allTags)
assertFalse(result.rawLibraryFiles.any { it.bookId.endsWith("_reflow") })
}
@Test
fun `project applies search filters and sort only to library results`() {
val tag = tag("work", "Work")
val match = recentFile(
id = "match",
title = "Android Work",
type = FileType.PDF,
progressPercentage = 80f,
sourceFolderUri = "content://sync"
)
val searchMiss = recentFile(
id = "search_miss",
title = "Poetry",
type = FileType.PDF,
progressPercentage = 80f,
sourceFolderUri = "content://sync"
)
val filterMiss = recentFile(
id = "filter_miss",
title = "Android Notes",
type = FileType.EPUB,
progressPercentage = 80f,
sourceFolderUri = "content://sync"
)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
searchQuery = "android",
sortOrder = SortOrder.TITLE_ASC,
libraryFilters = LibraryFilters(
fileTypes = setOf(FileType.PDF),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf(tag.id)
)
),
recentFilesFromDb = listOf(searchMiss, filterMiss, match),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "match", tagId = tag.id))
)
)
assertEquals(listOf("match"), result.allRecentFiles.ids())
assertEquals(listOf("search_miss", "filter_miss", "match"), result.rawLibraryFiles.ids())
assertEquals(listOf(tag), result.allTags)
}
@Test
fun `project builds manual tag series and unshelved shelves`() {
val favorite = tag("favorite", "Favorite")
val manualShelf = shelfEntity("manual", "Manual")
val manualBook = recentFile("manual", title = "Manual")
val taggedBook = recentFile("tagged", title = "Tagged")
val seriesOne = recentFile("series_1", title = "Series One", seriesName = "Saga", seriesIndex = 1.0)
val seriesTwo = recentFile("series_2", title = "Series Two", seriesName = "Saga", seriesIndex = 2.0)
val loose = recentFile("loose", title = "Loose")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(sortOrder = SortOrder.TITLE_ASC),
recentFilesFromDb = listOf(manualBook, taggedBook, seriesTwo, loose, seriesOne),
dbShelves = listOf(manualShelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "manual", shelfId = "manual", addedAt = 1L)),
dbTags = listOf(favorite),
tagRefs = listOf(BookTagCrossRef(bookId = "tagged", tagId = favorite.id))
)
)
val manual = result.shelves.first { it.id == "manual" }
val tagShelf = result.shelves.first { it.id == "tag_favorite" }
val series = result.shelves.first { it.id == "series_Saga" }
val unshelved = result.shelves.first { it.id == "unshelved" }
assertEquals(ShelfType.MANUAL, manual.type)
assertEquals(listOf("manual"), manual.books.ids())
assertEquals(ShelfType.TAG, tagShelf.type)
assertEquals(listOf("tagged"), tagShelf.books.ids())
assertEquals(ShelfType.SERIES, series.type)
assertEquals(listOf("series_1", "series_2"), series.books.ids())
assertEquals(listOf("loose", "tagged"), unshelved.books.ids())
}
@Test
fun `project does not create series shelf for a single series book`() {
val single = recentFile("single", title = "Only Volume", seriesName = "Solo", seriesIndex = 1.0)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(single),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertTrue(result.shelves.none { it.type == ShelfType.SERIES })
assertEquals(listOf("single"), result.shelves.first { it.id == "unshelved" }.books.ids())
}
@Test
fun `project exposes all books for adding except books already in current shelf`() {
val shelf = shelfEntity("manual", "Manual")
val shelved = recentFile("shelved", title = "Shelved")
val loose = recentFile("loose", title = "Loose")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
viewingShelfId = "manual",
isAddingBooksToShelf = true,
addBooksSource = AddBooksSource.ALL_BOOKS,
sortOrder = SortOrder.TITLE_ASC
),
recentFilesFromDb = listOf(shelved, loose),
dbShelves = listOf(shelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L)),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertEquals(listOf("loose"), result.booksAvailableForAdding.ids())
}
@Test
fun `project exposes only unshelved books for default add books source`() {
val shelf = shelfEntity("manual", "Manual")
val shelved = recentFile("shelved", title = "Shelved")
val loose = recentFile("loose", title = "Loose")
val tagged = recentFile("tagged", title = "Tagged")
val tag = tag("tagged", "Tagged")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
viewingShelfId = "manual",
isAddingBooksToShelf = true,
addBooksSource = AddBooksSource.UNSHELVED,
sortOrder = SortOrder.TITLE_ASC
),
recentFilesFromDb = listOf(shelved, loose, tagged),
dbShelves = listOf(shelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L)),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "tagged", tagId = tag.id))
)
)
assertEquals(listOf("loose", "tagged"), result.booksAvailableForAdding.ids())
}
@Test
fun `project clears stale shelf mode when selected shelf disappears`() {
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
viewingShelfId = "deleted",
isAddingBooksToShelf = true,
contextualActionShelfIds = setOf("deleted")
),
recentFilesFromDb = listOf(recentFile("book")),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertNull(result.viewingShelfId)
assertFalse(result.isAddingBooksToShelf)
assertTrue(result.contextualActionShelfIds.isEmpty())
}
@Test
fun `project creates root and nested shelves for synced folders`() {
val rootBook = recentFile("root", sourceFolderUri = "content://library", timestamp = 2L)
val nestedBook = recentFile("nested", sourceFolderUri = "content://library", timestamp = 1L)
val projector = LibraryStateProjector(
FolderPathResolver { item ->
when (item.bookId) {
"nested" -> listOf("Series", "Volume 1")
else -> emptyList()
}
}
)
val result = projector.project(
LibraryProjectionInput(
state = ReaderScreenState(
syncedFolders = listOf(
SyncedFolder(
uriString = "content://library",
name = "Library",
lastScanTime = 1L
)
)
),
recentFilesFromDb = listOf(rootBook, nestedBook),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
val rootShelf = result.shelves.first { it.id == "folder_content://library" }
val seriesShelf = result.shelves.first { it.id == "folder_content://library::Series" }
val volumeShelf = result.shelves.first { it.id == "folder_content://library::Series/Volume 1" }
assertEquals("Library", rootShelf.name)
assertEquals(listOf("root", "nested"), rootShelf.books.ids())
assertEquals(listOf("root"), rootShelf.directBooks.ids())
assertEquals(listOf(seriesShelf.id), rootShelf.childShelfIds)
assertEquals(rootShelf.id, seriesShelf.parentShelfId)
assertEquals(listOf("nested"), seriesShelf.books.ids())
assertEquals(listOf(volumeShelf.id), seriesShelf.childShelfIds)
assertEquals(seriesShelf.id, volumeShelf.parentShelfId)
assertEquals(listOf("nested"), volumeShelf.directBooks.ids())
assertEquals(2, volumeShelf.depth)
}
@Test
fun `project names folder shelf local folder when synced folder metadata is missing`() {
val book = recentFile("folder_book", sourceFolderUri = "content://external")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(book),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
val folderShelf = result.shelves.first { it.id == "folder_content://external" }
assertEquals("Local Folder", folderShelf.name)
assertEquals(listOf("folder_book"), folderShelf.books.ids())
assertEquals(listOf("folder_book"), folderShelf.directBooks.ids())
}
private fun recentFile(
id: String,
uriString: String? = "content://$id",
type: FileType = FileType.EPUB,
displayName: String = "$id.${type.name.lowercase()}",
title: String? = null,
author: String? = null,
timestamp: Long = 1L,
isRecent: Boolean = true,
sourceFolderUri: String? = null,
progressPercentage: Float? = null,
tags: List<TagEntity> = emptyList(),
fileSize: Long = 0L,
seriesName: String? = null,
seriesIndex: Double? = null
) = RecentFileItem(
bookId = id,
uriString = uriString,
type = type,
displayName = displayName,
title = title,
author = author,
timestamp = timestamp,
isRecent = isRecent,
sourceFolderUri = sourceFolderUri,
progressPercentage = progressPercentage,
tags = tags,
fileSize = fileSize,
seriesName = seriesName,
seriesIndex = seriesIndex
)
private fun tag(id: String, name: String) = TagEntity(
id = id,
name = name,
createdAt = 1L
)
private fun shelfEntity(id: String, name: String) = ShelfEntity(
id = id,
name = name,
createdAt = 1L,
updatedAt = 1L
)
private fun List<RecentFileItem>.ids() = map { it.bookId }
}

View file

@ -3,13 +3,26 @@ package com.aryan.reader
import android.app.Application
import android.content.SharedPreferences
import android.content.res.Resources
import android.net.Uri
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.credentials.CredentialManager
import androidx.work.WorkManager
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.aryan.reader.data.*
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@ -20,6 +33,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
@ -33,9 +47,24 @@ class MainViewModelTest {
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
private val ttsStateFlow = MutableStateFlow(TtsPlaybackManager.TtsState())
private val recentFilesFlow = MutableStateFlow<List<RecentFileItem>>(emptyList())
private val shelvesFlow = MutableStateFlow<List<ShelfEntity>>(emptyList())
private val shelfRefsFlow = MutableStateFlow<List<BookShelfCrossRef>>(emptyList())
private val tagsFlow = MutableStateFlow<List<TagEntity>>(emptyList())
private val tagRefsFlow = MutableStateFlow<List<BookTagCrossRef>>(emptyList())
@Before
fun setup() {
recentFilesFlow.value = emptyList()
shelvesFlow.value = emptyList()
shelfRefsFlow.value = emptyList()
tagsFlow.value = emptyList()
tagRefsFlow.value = emptyList()
billingStateFlow.value = ProUpgradeState()
customFontsFlow.value = emptyList()
ttsStateFlow.value = TtsPlaybackManager.TtsState()
mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false
every { Log.d(any(), any()) } returns 0
@ -49,10 +78,18 @@ class MainViewModelTest {
mockPrefs = mockk(relaxed = true)
mockEditor = mockk(relaxed = true)
val mockResources = mockk<Resources>(relaxed = true)
val testRoot = File("build/test-tmp/MainViewModelTest/${System.nanoTime()}")
val filesDir = File(testRoot, "files").apply { mkdirs() }
val cacheDir = File(testRoot, "cache").apply { mkdirs() }
val externalFilesDir = File(testRoot, "external-files").apply { mkdirs() }
every { mockApplication.applicationContext } returns mockApplication
every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs
every { mockApplication.resources } returns mockResources
every { mockApplication.packageName } returns "com.aryan.reader"
every { mockApplication.filesDir } returns filesDir
every { mockApplication.cacheDir } returns cacheDir
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
every { mockPrefs.edit() } returns mockEditor
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
@ -60,15 +97,39 @@ class MainViewModelTest {
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
mockkStatic(AppDatabase::class)
mockkObject(AppDatabase.Companion)
val mockDb = mockk<AppDatabase>(relaxed = true)
every { AppDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookCacheDatabase.Companion)
val mockBookCacheDb = mockk<BookCacheDatabase>(relaxed = true)
every { mockBookCacheDb.bookCacheDao() } returns mockk<BookCacheDao>(relaxed = true)
every { BookCacheDatabase.getDatabase(any()) } returns mockBookCacheDb
mockkStatic(WorkManager::class)
every { WorkManager.getInstance(any()) } returns mockk(relaxed = true)
mockkStatic(PDFBoxResourceLoader::class)
every { PDFBoxResourceLoader.init(any()) } just Runs
mockkObject(WorkManager.Companion)
val mockWorkManager = mockk<WorkManager>(relaxed = true)
every { WorkManager.getInstance(any()) } returns mockWorkManager
mockkStatic(FirebaseAuth::class)
every { FirebaseAuth.getInstance() } returns mockk(relaxed = true)
mockkStatic(FirebaseFirestore::class)
every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true)
mockkObject(CredentialManager.Companion)
every { CredentialManager.create(any()) } returns mockk(relaxed = true)
mockkStatic(BillingClient::class)
val mockBillingClient = mockk<BillingClient>(relaxed = true)
val mockBillingBuilder = mockk<BillingClient.Builder>(relaxed = true)
every { BillingClient.newBuilder(any()) } returns mockBillingBuilder
every { mockBillingBuilder.setListener(any()) } returns mockBillingBuilder
every { mockBillingBuilder.enablePendingPurchases(any()) } returns mockBillingBuilder
every { mockBillingBuilder.build() } returns mockBillingClient
every { mockBillingClient.isReady } returns false
every { mockBillingClient.startConnection(any()) } answers {
firstArg<com.android.billingclient.api.BillingClientStateListener>()
.onBillingSetupFinished(
BillingResult.newBuilder()
.setResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
.build()
)
}
mockkConstructor(AuthRepository::class)
mockkConstructor(RecentFilesRepository::class)
mockkConstructor(BillingClientWrapper::class)
@ -76,18 +137,28 @@ class MainViewModelTest {
mockkConstructor(FirestoreRepository::class)
mockkConstructor(FeedbackRepository::class)
mockkConstructor(FontsRepository::class)
mockkConstructor(TtsController::class)
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
every { anyConstructed<RecentFilesRepository>().getRecentFilesFlow() } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().activeShelvesFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().shelfCrossRefsFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().tagsFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().tagCrossRefsFlow } returns flowOf(emptyList())
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
every { anyConstructed<TtsController>().ttsState } returns ttsStateFlow
every { anyConstructed<TtsController>().connect() } just Runs
every { anyConstructed<TtsController>().release() } just Runs
every { anyConstructed<RecentFilesRepository>().getRecentFilesFlow() } returns recentFilesFlow
every { anyConstructed<RecentFilesRepository>().activeShelvesFlow } returns shelvesFlow
every { anyConstructed<RecentFilesRepository>().shelfCrossRefsFlow } returns shelfRefsFlow
every { anyConstructed<RecentFilesRepository>().tagsFlow } returns tagsFlow
every { anyConstructed<RecentFilesRepository>().tagCrossRefsFlow } returns tagRefsFlow
coEvery { anyConstructed<RecentFilesRepository>().migrateLegacyShelvesToRoom() } just Runs
coEvery { anyConstructed<RecentFilesRepository>().seedTagsIfEmpty(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().assignTagToBook(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().removeTagFromBook(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().removeBooksFromShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
@ -109,8 +180,11 @@ class MainViewModelTest {
viewModel.setSearchActive(true)
viewModel.onSearchQueryChange("Moby Dick")
assertEquals("Moby Dick", viewModel.uiState.value.searchQuery)
assertTrue(viewModel.uiState.value.isSearchActive)
val state = viewModel.uiState.first {
it.searchQuery == "Moby Dick" && it.isSearchActive
}
assertEquals("Moby Dick", state.searchQuery)
assertTrue(state.isSearchActive)
}
@Test
@ -127,6 +201,18 @@ class MainViewModelTest {
assertFalse(viewModel.uiState.value.isSearchActive)
}
@Test
fun `search query change is ignored while search is inactive`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.onSearchQueryChange("Invisible")
assertEquals("", viewModel.uiState.value.searchQuery)
assertFalse(viewModel.uiState.value.isSearchActive)
}
@Test
fun `switching theme updates internal state and preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
@ -135,7 +221,8 @@ class MainViewModelTest {
viewModel.setAppThemeMode(AppThemeMode.DARK)
assertEquals(AppThemeMode.DARK, viewModel.uiState.value.appThemeMode)
val state = viewModel.uiState.first { it.appThemeMode == AppThemeMode.DARK }
assertEquals(AppThemeMode.DARK, state.appThemeMode)
verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) }
}
@ -147,10 +234,688 @@ class MainViewModelTest {
viewModel.setTabsEnabled(true)
assertTrue(viewModel.uiState.value.isTabsEnabled)
val state = viewModel.uiState.first { it.isTabsEnabled }
assertTrue(state.isTabsEnabled)
verify { mockEditor.putBoolean("tabs_enabled", true) }
}
@Test
fun `setRenderMode persists mode without touching saved epub position`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setRenderMode(RenderMode.PAGINATED)
val state = viewModel.uiState.first { it.renderMode == RenderMode.PAGINATED }
assertEquals(RenderMode.PAGINATED, state.renderMode)
verify { mockEditor.putString(KEY_RENDER_MODE, RenderMode.PAGINATED.name) }
coVerify(exactly = 0) {
anyConstructed<RecentFilesRepository>().updateEpubReadingPosition(any(), any(), any(), any())
}
}
@Test
fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest {
val uriString = "content://books/one"
val uri = mockUri(uriString)
val locator = Locator(chapterIndex = 5, blockIndex = 77, charOffset = 14)
coEvery { anyConstructed<RecentFilesRepository>().getFileByUri(uriString) } returns RecentFileItem(
bookId = "book-1",
uriString = uriString,
type = FileType.EPUB,
displayName = "One.epub",
timestamp = 1L
)
coEvery {
anyConstructed<RecentFilesRepository>().updateEpubReadingPosition(any(), any(), any(), any())
} just Runs
viewModel.saveEpubReadingPosition(uri, locator, "/4/2/6:14", 37.25f)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
anyConstructed<RecentFilesRepository>().updateEpubReadingPosition(
uriString = uriString,
locator = locator,
cfiForWebView = "/4/2/6:14",
progress = 37.25f
)
}
}
@Test
fun `setRecentFilesLimit persists and limits visible home recents`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val first = recentFile("first", isRecent = true)
val second = recentFile("second", isRecent = true)
recentFilesFlow.value = listOf(first, second)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.setRecentFilesLimit(1)
val state = viewModel.uiState.first { it.recentFiles.bookIds() == setOf("first") }
assertEquals(listOf("first"), state.recentFiles.map { it.bookId })
verify { mockEditor.putInt("recent_files_limit", 1) }
}
@Test
fun `strict file filter and external file behavior persist preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setStrictFileFilter(true)
viewModel.setExternalFileBehavior("KEEP")
val state = viewModel.uiState.first {
it.useStrictFileFilter && it.externalFileBehavior == "KEEP"
}
assertTrue(state.useStrictFileFilter)
assertEquals("KEEP", state.externalFileBehavior)
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
verify { mockEditor.putString("external_file_behavior", "KEEP") }
}
@Test
fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val beta = recentFile("beta", title = "Beta", timestamp = 3L)
val alpha = recentFile("alpha", title = "Alpha", timestamp = 1L)
val gamma = recentFile("gamma", title = "Gamma", timestamp = 2L, isRecent = false)
recentFilesFlow.value = listOf(beta, alpha, gamma)
viewModel.uiState.first { it.rawLibraryFiles.size == 3 }
viewModel.setSortOrder(SortOrder.TITLE_ASC)
val state = viewModel.uiState.first {
it.sortOrder == SortOrder.TITLE_ASC &&
it.allRecentFiles.map { item -> item.bookId } == listOf("alpha", "beta", "gamma")
}
assertEquals(listOf("alpha", "beta"), state.recentFiles.map { it.bookId })
assertEquals(listOf("alpha", "beta", "gamma"), state.allRecentFiles.map { it.bookId })
verify { mockEditor.putString("sort_order", SortOrder.TITLE_ASC.name) }
}
@Test
fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setMainScreenPage(99)
val state = viewModel.uiState.first { it.mainScreenStartPage == 1 }
assertEquals(1, state.mainScreenStartPage)
verify { mockEditor.putInt(KEY_MAIN_SCREEN_START_PAGE, 1) }
}
@Test
fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setLibraryScreenPage(99)
val expectedMaxPage = if (BuildConfig.IS_OFFLINE) 2 else 3
val state = viewModel.uiState.first { it.libraryScreenStartPage == expectedMaxPage }
assertEquals(expectedMaxPage, state.libraryScreenStartPage)
verify { mockEditor.putInt(KEY_LIBRARY_SCREEN_START_PAGE, expectedMaxPage) }
}
@Test
fun `create shelf dialog state opens and dismisses`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showCreateShelfDialog()
val openedState = viewModel.uiState.first { it.showCreateShelfDialog }
assertTrue(openedState.showCreateShelfDialog)
viewModel.dismissCreateShelfDialog()
val dismissedState = viewModel.uiState.first { !it.showCreateShelfDialog }
assertFalse(dismissedState.showCreateShelfDialog)
}
@Test
fun `selectAllRecentFiles toggles only visible recent home items`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val recent = recentFile("recent", isRecent = true)
val notRecent = recentFile("not_recent", isRecent = false)
recentFilesFlow.value = listOf(recent, notRecent)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.selectAllRecentFiles()
val selectedState = viewModel.uiState.first {
it.contextualActionItems.bookIds() == setOf("recent")
}
assertEquals(setOf("recent"), selectedState.contextualActionItems.bookIds())
viewModel.selectAllRecentFiles()
val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(clearedState.contextualActionItems.isEmpty())
}
@Test
fun `selectAllLibraryFiles toggles all filtered library items`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val pdf = recentFile("pdf", type = FileType.PDF)
val epub = recentFile("epub", type = FileType.EPUB)
recentFilesFlow.value = listOf(pdf, epub)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.updateLibraryFilters(LibraryFilters(fileTypes = setOf(FileType.PDF)))
viewModel.uiState.first { it.allRecentFiles.bookIds() == setOf("pdf") }
viewModel.selectAllLibraryFiles()
val selectedState = viewModel.uiState.first {
it.contextualActionItems.bookIds() == setOf("pdf")
}
assertEquals(setOf("pdf"), selectedState.contextualActionItems.bookIds())
}
@Test
fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val first = recentFile("first")
val second = recentFile("second")
recentFilesFlow.value = listOf(first, second)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.selectAllLibraryFiles()
viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("first", "second") }
viewModel.selectAllLibraryFiles()
val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(clearedState.contextualActionItems.isEmpty())
}
@Test
fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = true)
val pinnedState = viewModel.uiState.first {
it.pinnedHomeBookIds == setOf("book") && it.contextualActionItems.isEmpty()
}
assertEquals(setOf("book"), pinnedState.pinnedHomeBookIds)
assertTrue(pinnedState.contextualActionItems.isEmpty())
verify { mockEditor.putStringSet("pinned_home_books", setOf("book")) }
}
@Test
fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = true)
viewModel.uiState.first { it.pinnedHomeBookIds == setOf("book") }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = true)
val state = viewModel.uiState.first {
it.pinnedHomeBookIds.isEmpty() && it.contextualActionItems.isEmpty()
}
assertTrue(state.pinnedHomeBookIds.isEmpty())
verify { mockEditor.putStringSet("pinned_home_books", emptySet<String>()) }
}
@Test
fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("book") }
viewModel.clearContextualAction()
val state = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(state.contextualActionItems.isEmpty())
assertTrue(state.pinnedHomeBookIds.isEmpty())
assertTrue(state.pinnedLibraryBookIds.isEmpty())
}
@Test
fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("library_book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = false)
val state = viewModel.uiState.first {
it.pinnedLibraryBookIds == setOf("library_book") && it.contextualActionItems.isEmpty()
}
assertEquals(setOf("library_book"), state.pinnedLibraryBookIds)
assertTrue(state.pinnedHomeBookIds.isEmpty())
verify { mockEditor.putStringSet("pinned_library_books", setOf("library_book")) }
}
@Test
fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val filters = LibraryFilters(
fileTypes = setOf(FileType.PDF, FileType.EPUB),
sourceFolders = setOf("IN_APP_STORAGE", "content://sync"),
readStatus = ReadStatusFilter.COMPLETED,
tagIds = setOf("favorite")
)
viewModel.updateLibraryFilters(filters)
val state = viewModel.uiState.first { it.libraryFilters == filters }
assertEquals(filters, state.libraryFilters)
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF", "EPUB")) }
verify { mockEditor.putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders) }
verify { mockEditor.putString(KEY_FILTER_READ_STATUS, ReadStatusFilter.COMPLETED.name) }
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) }
}
@Test
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.updateLibraryFilters(
LibraryFilters(
fileTypes = setOf(FileType.PDF),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf("favorite")
)
)
viewModel.uiState.first { it.libraryFilters.isActive }
viewModel.updateLibraryFilters(LibraryFilters())
val state = viewModel.uiState.first { !it.libraryFilters.isActive }
assertEquals(LibraryFilters(), state.libraryFilters)
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, emptySet<String>()) }
verify { mockEditor.putStringSet(KEY_FILTER_FOLDERS, emptySet<String>()) }
verify { mockEditor.putString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) }
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, emptySet<String>()) }
}
@Test
fun `tag selection ignores empty targets and closes after opening`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.openTagSelection(emptySet())
assertTrue(viewModel.uiState.value.showTagSelectionDialogFor.isEmpty())
viewModel.openTagSelection(setOf("book"))
val openedState = viewModel.uiState.first { it.showTagSelectionDialogFor == setOf("book") }
assertEquals(setOf("book"), openedState.showTagSelectionDialogFor)
viewModel.closeTagSelection()
val closedState = viewModel.uiState.first { it.showTagSelectionDialogFor.isEmpty() }
assertTrue(closedState.showTagSelectionDialogFor.isEmpty())
}
@Test
fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.toggleTagForBooks("favorite", setOf(" book ", "", "other"), assign = true)
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().assignTagToBook("book", "favorite") }
coVerify { anyConstructed<RecentFilesRepository>().assignTagToBook("other", "favorite") }
viewModel.toggleTagForBooks("favorite", setOf("book"), assign = false)
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().removeTagFromBook("book", "favorite") }
viewModel.toggleTagForBooks(" ", setOf("book"), assign = true)
advanceUntilIdle()
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().assignTagToBook("book", " ") }
}
@Test
fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showRenameShelfDialog("manual")
val renameState = viewModel.uiState.first { it.showRenameShelfDialogFor == "manual" }
assertEquals("manual", renameState.showRenameShelfDialogFor)
viewModel.dismissRenameShelfDialog()
viewModel.uiState.first { it.showRenameShelfDialogFor == null }
viewModel.showDeleteShelfDialog("manual")
val deleteState = viewModel.uiState.first { it.showDeleteShelfDialogFor == "manual" }
assertEquals("manual", deleteState.showDeleteShelfDialogFor)
viewModel.dismissDeleteShelfDialog()
val dismissedState = viewModel.uiState.first { it.showDeleteShelfDialogFor == null }
assertEquals(null, dismissedState.showDeleteShelfDialogFor)
}
@Test
fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
.shelves.first { it.id == "manual" }
val tagShelf = Shelf("tag_favorite", "Favorite", ShelfType.TAG, books = emptyList())
viewModel.onShelfLongPress(tagShelf)
assertTrue(viewModel.uiState.value.contextualActionShelfIds.isEmpty())
viewModel.onShelfLongPress(manualShelf)
val selectedState = viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") }
assertEquals(setOf("manual"), selectedState.contextualActionShelfIds)
viewModel.onShelfClick(manualShelf)
val clearedState = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() }
assertTrue(clearedState.contextualActionShelfIds.isEmpty())
}
@Test
fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
.shelves.first { it.id == "manual" }
viewModel.onShelfClick(manualShelf)
val state = viewModel.uiState.first {
it.viewingShelfId == "manual" && it.mainScreenStartPage == 1 && it.libraryScreenStartPage == 1
}
assertEquals("manual", state.viewingShelfId)
}
@Test
fun `shelf navigation sets library landing state and can be cleared`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
val shelfState = viewModel.uiState.first {
it.viewingShelfId == "manual" && it.mainScreenStartPage == 1 && it.libraryScreenStartPage == 1
}
assertEquals("manual", shelfState.viewingShelfId)
viewModel.unselectShelf()
val clearedState = viewModel.uiState.first { it.viewingShelfId == null }
assertEquals(null, clearedState.viewingShelfId)
}
@Test
fun `clearShelfContextualAction clears selected shelves`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
.shelves.first { it.id == "manual" }
viewModel.onShelfLongPress(manualShelf)
viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") }
viewModel.clearShelfContextualAction()
val state = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() }
assertTrue(state.contextualActionShelfIds.isEmpty())
}
@Test
fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(
shelfEntity("manual", "Manual"),
shelfEntity("other", "Other")
)
val shelves = viewModel.uiState.first { state ->
state.shelves.any { it.id == "manual" } && state.shelves.any { it.id == "unshelved" }
}.shelves
val manual = shelves.first { it.id == "manual" }
val unshelved = shelves.first { it.id == "unshelved" }
viewModel.onShelfLongPress(manual)
viewModel.onShelfLongPress(unshelved)
viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") }
viewModel.deleteSelectedShelves()
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().deleteShelf("manual") }
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteShelf("unshelved") }
val clearedState = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() }
assertTrue(clearedState.contextualActionShelfIds.isEmpty())
}
@Test
fun `add books mode resets selection and tracks source changes`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val shelved = recentFile("shelved")
val loose = recentFile("loose")
recentFilesFlow.value = listOf(shelved, loose)
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
shelfRefsFlow.value = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
viewModel.showAddBooksToShelf()
val addModeState = viewModel.uiState.first {
it.isAddingBooksToShelf && it.booksAvailableForAdding.bookIds() == setOf("loose")
}
assertEquals(AddBooksSource.UNSHELVED, addModeState.addBooksSource)
viewModel.setAddBooksSource(AddBooksSource.ALL_BOOKS)
viewModel.toggleBookSelectionForAdding("loose")
val selectedState = viewModel.uiState.first {
it.addBooksSource == AddBooksSource.ALL_BOOKS && it.booksSelectedForAdding == setOf("loose")
}
assertEquals(setOf("loose"), selectedState.booksSelectedForAdding)
verify { mockEditor.putString("add_books_source", AddBooksSource.ALL_BOOKS.name) }
viewModel.dismissAddBooksToShelf()
val dismissedState = viewModel.uiState.first {
!it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty()
}
assertFalse(dismissedState.isAddingBooksToShelf)
}
@Test
fun `toggleBookSelectionForAdding toggles individual books`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.toggleBookSelectionForAdding("loose")
val selectedState = viewModel.uiState.first { it.booksSelectedForAdding == setOf("loose") }
assertEquals(setOf("loose"), selectedState.booksSelectedForAdding)
viewModel.toggleBookSelectionForAdding("loose")
val clearedState = viewModel.uiState.first { it.booksSelectedForAdding.isEmpty() }
assertTrue(clearedState.booksSelectedForAdding.isEmpty())
}
@Test
fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val loose = recentFile("loose")
recentFilesFlow.value = listOf(loose)
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
viewModel.showAddBooksToShelf()
viewModel.toggleBookSelectionForAdding("loose")
viewModel.addBooksToShelf("manual")
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().addBooksToShelf("manual", listOf("loose")) }
val state = viewModel.uiState.first {
!it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty()
}
assertFalse(state.isAddingBooksToShelf)
assertTrue(state.booksSelectedForAdding.isEmpty())
}
@Test
fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.toggleBookSelectionForAdding("loose")
viewModel.addBooksToShelf("unshelved")
val state = viewModel.uiState.first {
!it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty()
}
assertFalse(state.isAddingBooksToShelf)
assertTrue(state.booksSelectedForAdding.isEmpty())
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addBooksToShelf("unshelved", any()) }
}
@Test
fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
shelfRefsFlow.value = listOf(BookShelfCrossRef(bookId = "book", shelfId = "manual", addedAt = 1L))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
viewModel.onRecentItemLongPress(book)
viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("book") }
viewModel.removeContextualItemsFromShelf()
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().removeBooksFromShelf("manual", listOf("book")) }
val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(clearedState.contextualActionItems.isEmpty())
}
@Test
fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val color = Color(0xFF006C4C)
val theme = CustomAppTheme(id = "forest", name = "Forest", seedColor = color)
viewModel.setAppContrastOption(AppContrastOption.HIGH)
viewModel.setAppTextDimFactorLight(0.75f)
viewModel.setAppTextDimFactorDark(0.65f)
viewModel.addCustomAppTheme(theme)
val themedState = viewModel.uiState.first {
it.appContrastOption == AppContrastOption.HIGH &&
it.appTextDimFactorLight == 0.75f &&
it.appTextDimFactorDark == 0.65f &&
it.customAppThemes == listOf(theme) &&
it.appSeedColor == color
}
assertEquals(AppContrastOption.HIGH, themedState.appContrastOption)
assertEquals(listOf(theme), themedState.customAppThemes)
verify { mockEditor.putString("app_contrast_option", AppContrastOption.HIGH.name) }
verify { mockEditor.putFloat("app_text_dim_factor_light", 0.75f) }
verify { mockEditor.putFloat("app_text_dim_factor_dark", 0.65f) }
verify { mockEditor.putInt("app_seed_color", color.toArgb()) }
viewModel.deleteCustomAppTheme(theme.id)
val deletedState = viewModel.uiState.first {
it.customAppThemes.isEmpty() && it.appSeedColor == null
}
assertTrue(deletedState.customAppThemes.isEmpty())
assertEquals(null, deletedState.appSeedColor)
verify { mockEditor.remove("app_seed_color") }
}
@Test
fun `setAppSeedColor can clear a selected seed color`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val color = Color(0xFF123456)
viewModel.setAppSeedColor(color)
viewModel.uiState.first { it.appSeedColor == color }
viewModel.setAppSeedColor(null)
val clearedState = viewModel.uiState.first { it.appSeedColor == null }
assertEquals(null, clearedState.appSeedColor)
verify { mockEditor.putInt("app_seed_color", color.toArgb()) }
verify { mockEditor.remove("app_seed_color") }
}
@Test
fun `addCustomAppTheme replaces existing theme with the same id`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val first = CustomAppTheme(id = "theme", name = "First", seedColor = Color(0xFF123456))
val second = CustomAppTheme(id = "theme", name = "Second", seedColor = Color(0xFF654321))
viewModel.addCustomAppTheme(first)
viewModel.uiState.first { it.customAppThemes == listOf(first) }
viewModel.addCustomAppTheme(second)
val state = viewModel.uiState.first { it.customAppThemes == listOf(second) }
assertEquals(listOf(second), state.customAppThemes)
assertEquals(second.seedColor, state.appSeedColor)
}
@Test
fun `banner message logic works correctly`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
@ -159,11 +924,46 @@ class MainViewModelTest {
viewModel.showBanner("Test Message", isError = true)
val currentBanner = viewModel.uiState.value.bannerMessage
val currentBanner = viewModel.uiState.first {
it.bannerMessage?.message == "Test Message"
}.bannerMessage
assertEquals("Test Message", currentBanner?.message)
assertTrue(currentBanner?.isError == true)
viewModel.bannerMessageShown()
assertEquals(null, viewModel.uiState.value.bannerMessage)
val clearedState = viewModel.uiState.first { it.bannerMessage == null }
assertEquals(null, clearedState.bannerMessage)
}
private fun recentFile(
id: String,
type: FileType = FileType.EPUB,
isRecent: Boolean = true,
title: String? = null,
timestamp: Long = 1L
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = type,
displayName = "$id.${type.name.lowercase()}",
timestamp = timestamp,
isRecent = isRecent,
title = title
)
private fun mockUri(uriString: String): Uri {
return mockk<Uri>().also { uri ->
every { uri.toString() } returns uriString
every { uri.scheme } returns uriString.substringBefore(":", "")
}
}
private fun shelfEntity(id: String, name: String) = ShelfEntity(
id = id,
name = name,
createdAt = 1L,
updatedAt = 1L
)
private fun Iterable<RecentFileItem>.bookIds(): Set<String> = mapTo(mutableSetOf()) { it.bookId }
}

View file

@ -0,0 +1,147 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NonReaderScreenModelsTest {
@Test
fun `home model treats open tabs as non-empty content`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = true,
openTabs = listOf(tab),
rawLibraryFiles = listOf(tab)
).toHomeScreenModel()
assertFalse(model.isEmpty)
assertTrue(model.isLibraryEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model reports empty when there are no recents or open tabs`() {
val archivedBook = recentFile("archived", isRecent = false)
val model = ReaderScreenState(
recentFiles = emptyList(),
rawLibraryFiles = listOf(archivedBook)
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertTrue(model.isLibraryEmpty)
}
@Test
fun `home model ignores open tabs for empty state when tabs are disabled`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = false,
openTabs = listOf(tab),
recentFiles = emptyList()
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model exposes contextual selection and device limit state`() {
val selected = recentFile("selected")
val deviceState = DeviceLimitReachedState(isLimitReached = true)
val model = ReaderScreenState(
recentFiles = listOf(selected),
contextualActionItems = setOf(selected),
deviceLimitState = deviceState
).toHomeScreenModel()
assertTrue(model.isContextualModeActive)
assertEquals(setOf(selected), model.selectedItems)
assertEquals(deviceState, model.deviceLimitState)
assertFalse(model.isEmpty)
assertFalse(model.isLibraryEmpty)
}
@Test
fun `library model exposes contextual and shelf selection state`() {
val folderBook = recentFile("folder", sourceFolderUri = "content://folder")
val shelf = Shelf(
id = "manual",
name = "Manual",
type = ShelfType.MANUAL,
books = listOf(folderBook)
)
val model = ReaderScreenState(
contextualActionItems = setOf(folderBook),
contextualActionShelfIds = setOf(shelf.id),
sortOrder = SortOrder.TITLE_ASC,
shelves = listOf(shelf),
rawLibraryFiles = listOf(folderBook),
searchQuery = "folder",
isSearchActive = true
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertTrue(model.isShelfContextualModeActive)
assertTrue(model.containsFolderItemsInSelection)
assertEquals(setOf(folderBook), model.selectedItems)
assertEquals(setOf(shelf.id), model.selectedShelves)
assertEquals(SortOrder.TITLE_ASC, model.sortOrder)
assertEquals("folder", model.searchQuery)
assertTrue(model.isSearchActive)
}
@Test
fun `library model reports inactive contextual states for normal browsing`() {
val book = recentFile("book")
val model = ReaderScreenState(
allRecentFiles = listOf(book),
rawLibraryFiles = listOf(book),
sortOrder = SortOrder.RECENT
).toLibraryScreenModel()
assertFalse(model.isContextualModeActive)
assertFalse(model.isShelfContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertTrue(model.selectedItems.isEmpty())
assertTrue(model.selectedShelves.isEmpty())
assertEquals(listOf(book), model.rawLibraryFiles)
assertEquals(SortOrder.RECENT, model.sortOrder)
}
@Test
fun `library model distinguishes folder and non-folder selections`() {
val localBook = recentFile("local")
val model = ReaderScreenState(
contextualActionItems = setOf(localBook),
rawLibraryFiles = listOf(localBook)
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertEquals(setOf(localBook), model.selectedItems)
}
private fun recentFile(
id: String,
isRecent: Boolean = true,
sourceFolderUri: String? = null
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = FileType.EPUB,
displayName = "$id.epub",
timestamp = 1L,
isRecent = isRecent,
sourceFolderUri = sourceFolderUri
)
}

View file

@ -0,0 +1,46 @@
package com.aryan.reader
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementRule
import org.junit.Assert.assertEquals
import org.junit.Test
class TtsReplacementChunkTest {
@Test
fun `tts chunk spoken text falls back to original text`() {
val chunk = TtsChunk(
text = "Dr. Smith",
sourceCfi = "epubcfi(/6/2)",
startOffsetInSource = 12
)
assertEquals("Dr. Smith", chunk.spokenText)
}
@Test
fun `chunk preparation keeps original text and writes spoken text`() {
val preferences = ReaderTtsReplacementPreferences(
globalRules = listOf(
ReaderTtsReplacementRule(
id = "dr",
from = "Dr.",
to = "Doctor",
wholeWord = false
)
)
)
val chunk = TtsChunk(
text = "Dr. Smith",
sourceCfi = "epubcfi(/6/2)",
startOffsetInSource = 12
)
val prepared = listOf(chunk).withTtsReplacements(preferences, "book").single()
assertEquals("Dr. Smith", prepared.text)
assertEquals("Doctor Smith", prepared.spokenText)
assertEquals("epubcfi(/6/2)", prepared.sourceCfi)
assertEquals(12, prepared.startOffsetInSource)
}
}

View file

@ -0,0 +1,90 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FolderBookMetadataTest {
@Test
fun `metadata JSON round trips nullable reader progress fields`() {
val metadata = FolderBookMetadata(
bookId = "book-1",
title = "Title",
author = null,
displayName = "Title.epub",
type = "EPUB",
lastChapterIndex = 4,
lastPage = null,
lastPositionCfi = "/4/2:10",
progressPercentage = 42.5f,
isRecent = false,
lastModifiedTimestamp = 1234L,
bookmarksJson = """[{"chapter":4}]""",
locatorBlockIndex = 99,
locatorCharOffset = null,
customName = "Custom",
highlightsJson = """[{"id":"h1"}]"""
)
val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString())
assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded)
}
@Test
fun `fromJsonString applies legacy defaults for missing optional fields`() {
val decoded = FolderBookMetadata.fromJsonString("""{"bookId":"legacy"}""")
assertEquals("legacy", decoded.bookId)
assertEquals("Unknown", decoded.displayName)
assertEquals("PDF", decoded.type)
assertEquals(0f, decoded.progressPercentage)
assertTrue(decoded.isRecent)
assertEquals(0L, decoded.lastModifiedTimestamp)
assertNull(decoded.title)
assertNull(decoded.lastChapterIndex)
assertNull(decoded.locatorBlockIndex)
}
@Test
fun `toRecentFileItem maps metadata and falls back to EPUB for unknown type`() {
val metadata = FolderBookMetadata(
bookId = "book-2",
title = "Remote Title",
author = "Author",
displayName = "Remote.bin",
type = "NOT_A_TYPE",
lastChapterIndex = 2,
lastPage = 12,
lastPositionCfi = "/6",
progressPercentage = 75f,
isRecent = true,
lastModifiedTimestamp = 500L,
bookmarksJson = "bookmarks",
locatorBlockIndex = 7,
locatorCharOffset = 8,
customName = "Shelf Name",
highlightsJson = "highlights"
)
val item = metadata.toRecentFileItem(
uriString = "content://book",
coverPath = "/covers/book.png",
sourceFolderUri = "content://folder"
)
assertEquals("book-2", item.bookId)
assertEquals(FileType.EPUB, item.type)
assertEquals("Remote Title", item.title)
assertEquals("Author", item.author)
assertEquals(12, item.lastPage)
assertEquals(7, item.locatorBlockIndex)
assertEquals(8, item.locatorCharOffset)
assertEquals("content://folder", item.sourceFolderUri)
assertEquals("Shelf Name", item.customName)
assertEquals("highlights", item.highlightsJson)
}
}

View file

@ -0,0 +1,138 @@
package com.aryan.reader.data
import androidx.room.Room
import com.aryan.reader.FileType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class RecentFileDaoReadingPositionTest {
private lateinit var db: AppDatabase
private lateinit var dao: RecentFileDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = db.recentFileDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun `updateEpubReadingPosition persists cfi locator progress and timestamps`() = runTest {
dao.insertOrUpdateFile(recentFileEntity())
dao.updateEpubReadingPosition(
bookId = "book-1",
cfi = "/4/2/6:33",
chapterIndex = 7,
blockIndex = 42,
charOffset = 33,
progress = 58.5f,
timestamp = 9_000L
)
val saved = dao.getFileByUri("content://books/one")!!
assertEquals("/4/2/6:33", saved.lastPositionCfi)
assertEquals(7, saved.lastChapterIndex)
assertEquals(42, saved.locatorBlockIndex)
assertEquals(33, saved.locatorCharOffset)
assertEquals(58.5f, saved.progressPercentage)
assertEquals(9_000L, saved.timestamp)
assertEquals(9_000L, saved.lastModifiedTimestamp)
}
@Test
fun `updateEpubReadingPosition can persist locator when webview cfi is unavailable`() = runTest {
dao.insertOrUpdateFile(recentFileEntity(lastPositionCfi = "/old:1"))
dao.updateEpubReadingPosition(
bookId = "book-1",
cfi = null,
chapterIndex = 2,
blockIndex = 9,
charOffset = 0,
progress = 12f,
timestamp = 2_000L
)
val saved = dao.getFileByBookId("book-1")!!
assertNull(saved.lastPositionCfi)
assertEquals(2, saved.lastChapterIndex)
assertEquals(9, saved.locatorBlockIndex)
assertEquals(0, saved.locatorCharOffset)
assertEquals(12f, saved.progressPercentage)
}
@Test
fun `recent file summary exposes persisted cfi and locator fields for reader restore`() = runTest {
dao.insertOrUpdateFile(recentFileEntity())
dao.updateEpubReadingPosition(
bookId = "book-1",
cfi = "/6/4:12",
chapterIndex = 3,
blockIndex = 21,
charOffset = 12,
progress = 44f,
timestamp = 3_000L
)
val item = dao.getRecentFiles().first().single().toRecentFileItem()
assertEquals("/6/4:12", item.lastPositionCfi)
assertEquals(3, item.lastChapterIndex)
assertEquals(21, item.locatorBlockIndex)
assertEquals(12, item.locatorCharOffset)
assertEquals(44f, item.progressPercentage)
assertTrue(item.isRecent)
}
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
return RecentFileEntity(
bookId = "book-1",
uriString = "content://books/one",
type = FileType.EPUB,
displayName = "One.epub",
timestamp = 1_000L,
coverImagePath = null,
title = "One",
author = "Author",
lastChapterIndex = null,
lastPage = null,
lastPositionCfi = lastPositionCfi,
progressPercentage = null,
isRecent = true,
isAvailable = true,
lastModifiedTimestamp = 1_000L,
isDeleted = false,
locatorBlockIndex = null,
locatorCharOffset = null,
bookmarks = null,
sourceFolderUri = null,
isReflowPreferred = false,
customName = null,
highlights = null,
fileSize = 123L,
seriesName = null,
seriesIndex = null,
description = null,
folderTextMetadataParsed = false
)
}
}

View file

@ -0,0 +1,54 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Test
class RecentFileItemReadingPositionMappingTest {
@Test
fun `recent file entity mapping preserves epub cfi locator and progress fields`() {
val item = recentFileItem()
val roundTripped = item.toRecentFileEntity().toRecentFileItem()
assertEquals(item.lastPositionCfi, roundTripped.lastPositionCfi)
assertEquals(item.lastChapterIndex, roundTripped.lastChapterIndex)
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
}
@Test
fun `cloud metadata mapping preserves epub cfi locator and progress fields`() {
val item = recentFileItem()
val roundTripped = item.toBookMetadata().toRecentFileItem()
assertEquals(item.lastPositionCfi, roundTripped.lastPositionCfi)
assertEquals(item.lastChapterIndex, roundTripped.lastChapterIndex)
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
}
private fun recentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = "book-1",
uriString = "content://books/one",
type = FileType.EPUB,
displayName = "One.epub",
timestamp = 1_000L,
title = "One",
author = "Author",
lastChapterIndex = 4,
lastPositionCfi = "/4/2/6:88",
locatorBlockIndex = 30,
locatorCharOffset = 88,
progressPercentage = 61.5f,
lastModifiedTimestamp = 2_000L,
bookmarksJson = """[{"cfi":"/4/2"}]""",
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
)
}
}

View file

@ -0,0 +1,148 @@
package com.aryan.reader.data
import android.content.Context
import com.aryan.reader.FileType
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import java.io.File
class RecentFilesRepositoryReadingPositionMergeTest {
private lateinit var context: Context
private lateinit var recentFileDao: RecentFileDao
private lateinit var repository: RecentFilesRepository
@Before
fun setUp() {
val testRoot = File("build/test-tmp/RecentFilesRepositoryReadingPositionMergeTest/${System.nanoTime()}")
val filesDir = File(testRoot, "files").apply { mkdirs() }
val cacheDir = File(testRoot, "cache").apply { mkdirs() }
context = mockk(relaxed = true)
every { context.applicationContext } returns context
every { context.filesDir } returns filesDir
every { context.cacheDir } returns cacheDir
recentFileDao = mockk()
val shelfDao = mockk<ShelfDao>()
val tagDao = mockk<TagDao>()
val db = mockk<AppDatabase>()
every { db.recentFileDao() } returns recentFileDao
every { db.shelfDao() } returns shelfDao
every { db.tagDao() } returns tagDao
every { shelfDao.getAllActiveShelves() } returns flowOf(emptyList())
every { shelfDao.getAllBookShelfCrossRefs() } returns flowOf(emptyList())
every { tagDao.getAllTags() } returns flowOf(emptyList())
every { tagDao.getAllBookTagCrossRefs() } returns flowOf(emptyList())
mockkObject(AppDatabase.Companion)
every { AppDatabase.getDatabase(any()) } returns db
repository = RecentFilesRepository(context)
}
@After
fun tearDown() {
unmockkObject(AppDatabase.Companion)
}
@Test
fun `addRecentFile preserves existing reading position when incoming metadata omits it`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity()
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
isRecent = true
)
)
assertEquals("/4/2/6:44", inserted.captured.lastPositionCfi)
assertEquals(6, inserted.captured.lastChapterIndex)
assertEquals(24, inserted.captured.locatorBlockIndex)
assertEquals(44, inserted.captured.locatorCharOffset)
assertEquals(71.5f, inserted.captured.progressPercentage)
coVerify { recentFileDao.insertOrUpdateFile(any()) }
}
@Test
fun `addRecentFile uses incoming reading position when newer metadata includes it`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity()
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
lastChapterIndex = 8,
lastPositionCfi = "/6/4:12",
locatorBlockIndex = 31,
locatorCharOffset = 12,
progressPercentage = 82f,
isRecent = true
)
)
assertEquals("/6/4:12", inserted.captured.lastPositionCfi)
assertEquals(8, inserted.captured.lastChapterIndex)
assertEquals(31, inserted.captured.locatorBlockIndex)
assertEquals(12, inserted.captured.locatorCharOffset)
assertEquals(82f, inserted.captured.progressPercentage)
}
private fun existingEntity(): RecentFileEntity {
return RecentFileEntity(
bookId = "book-1",
uriString = "content://old",
type = FileType.EPUB,
displayName = "Old.epub",
timestamp = 1_000L,
coverImagePath = "/covers/old.png",
title = "Old",
author = "Author",
lastChapterIndex = 6,
lastPage = null,
lastPositionCfi = "/4/2/6:44",
progressPercentage = 71.5f,
isRecent = true,
isAvailable = true,
lastModifiedTimestamp = 1_500L,
isDeleted = false,
locatorBlockIndex = 24,
locatorCharOffset = 44,
bookmarks = "bookmarks",
sourceFolderUri = "content://folder",
isReflowPreferred = false,
customName = "Custom",
highlights = "highlights",
fileSize = 123L,
seriesName = "Series",
seriesIndex = 1.0,
description = "Description",
folderTextMetadataParsed = true
)
}
}

View file

@ -0,0 +1,143 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SmartCollectionEngineTest {
@Test
fun `definition JSON round trips and ignores unknown fields`() {
val definition = SmartCollectionDefinition(
matchAll = false,
rules = listOf(
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "50")
)
)
val encoded = SmartCollectionEngine.toJson(definition)
val decoded = SmartCollectionEngine.fromJson(
encoded.replaceFirst("{", """{"unknown":"kept-for-forward-compat",""")
)
assertEquals(definition, decoded)
}
@Test
fun `fromJson returns null for blank malformed and incompatible payloads`() {
assertNull(SmartCollectionEngine.fromJson(null))
assertNull(SmartCollectionEngine.fromJson(" "))
assertNull(SmartCollectionEngine.fromJson("{not json"))
assertNull(SmartCollectionEngine.fromJson("""{"matchAll":true,"rules":[{"field":"NOPE"}]}"""))
}
@Test
fun `matchAll requires every rule while matchAny accepts a single matching rule`() {
val book = book(
title = "Dune Messiah",
author = "Frank Herbert",
progressPercentage = 41f,
type = FileType.EPUB
)
val titleAndHighProgress = SmartCollectionDefinition(
matchAll = true,
rules = listOf(
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "80")
)
)
val titleOrHighProgress = titleAndHighProgress.copy(matchAll = false)
assertFalse(SmartCollectionEngine.evaluate(book, titleAndHighProgress))
assertTrue(SmartCollectionEngine.evaluate(book, titleOrHighProgress))
}
@Test
fun `string folder file type and tag rules are case insensitive`() {
val book = book(
displayName = "fallback-name.pdf",
title = null,
author = "Ursula K. Le Guin",
sourceFolderUri = "content://library/Sci-Fi",
type = FileType.PDF,
tags = listOf(
TagEntity(id = "t1", name = "Classic Science Fiction", createdAt = 1L),
TagEntity(id = "t2", name = "Queued", createdAt = 2L)
)
)
assertTrue(
SmartCollectionEngine.evaluate(
book,
SmartCollectionDefinition(
rules = listOf(
SmartRule(SmartField.TITLE, SmartOperator.EQUALS, "fallback-name.pdf"),
SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, "le guin"),
SmartRule(SmartField.FOLDER, SmartOperator.CONTAINS, "SCI-FI"),
SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "pdf"),
SmartRule(SmartField.TAG, SmartOperator.CONTAINS, "science")
)
)
)
)
}
@Test
fun `numeric rules handle equals greater less missing progress and invalid values`() {
val startedBook = book(progressPercentage = 33.5f)
val missingProgressBook = book(progressPercentage = null)
assertTrue(matchesProgress(startedBook, SmartOperator.EQUALS, "33.5"))
assertTrue(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "33"))
assertTrue(matchesProgress(startedBook, SmartOperator.LESS_THAN, "34"))
assertFalse(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "not-a-number"))
assertTrue(matchesProgress(missingProgressBook, SmartOperator.EQUALS, "0"))
}
@Test
fun `empty definitions never match`() {
assertFalse(SmartCollectionEngine.evaluate(book(), SmartCollectionDefinition()))
}
private fun matchesProgress(
book: RecentFileItem,
operator: SmartOperator,
value: String
): Boolean {
return SmartCollectionEngine.evaluate(
book,
SmartCollectionDefinition(
rules = listOf(SmartRule(SmartField.PROGRESS, operator, value))
)
)
}
private fun book(
bookId: String = "book-id",
displayName: String = "display.epub",
title: String? = "Display",
author: String? = null,
progressPercentage: Float? = null,
sourceFolderUri: String? = null,
type: FileType = FileType.EPUB,
tags: List<TagEntity> = emptyList()
): RecentFileItem {
return RecentFileItem(
bookId = bookId,
uriString = "content://book/$bookId",
type = type,
displayName = displayName,
timestamp = 1L,
title = title,
author = author,
progressPercentage = progressPercentage,
sourceFolderUri = sourceFolderUri,
tags = tags
)
}
}

View file

@ -0,0 +1,452 @@
package com.aryan.reader.epub
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class EpubParserUnitTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `createEpubBook parses metadata spine ncx toc page list css images and extracted files`() = runTest {
val cacheDir = temp.newFolder("cache")
val extractionDir = temp.newFolder("extract")
val parser = EpubParser(contextWithCache(cacheDir))
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "book-id",
shouldUseToc = true,
originalBookNameHint = "fallback.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
assertEquals("Sample/Book".asFileName(), book.fileName)
assertEquals("Sample/Book", book.title)
assertEquals("Jane Writer", book.author)
assertEquals("en", book.language)
assertEquals("Series Name", book.seriesName)
assertEquals(2.5, book.seriesIndex)
assertEquals("Long description", book.description)
assertEquals(extractionDir.absolutePath, book.extractionBasePath)
assertTrue(File(extractionDir, "OEBPS/chapters/chapter 2.xhtml").isFile)
assertEquals(2, book.chapters.size)
assertEquals("NCX Chapter One", book.chapters[0].title)
assertEquals("OEBPS/chapters/chapter1.xhtml", book.chapters[0].htmlFilePath)
assertEquals(0, book.chapters[0].depth)
assertTrue(book.chapters[0].isInToc)
assertEquals("Nested Two", book.chapters[1].title)
assertEquals("OEBPS/chapters/chapter 2.xhtml", book.chapters[1].htmlFilePath)
assertEquals(1, book.chapters[1].depth)
assertTrue(book.chapters[1].plainTextContent.contains("Chapter Two"))
assertEquals(
listOf(
EpubTocEntry("NCX Chapter One", "OEBPS/chapters/chapter1.xhtml", "start", 0),
EpubTocEntry("Nested Two", "OEBPS/chapters/chapter 2.xhtml", "top", 1)
),
book.tableOfContents
)
assertEquals(1, book.pageList.size)
assertEquals("7", book.pageList.single().value)
assertEquals("OEBPS/chapters/chapter 2.xhtml#page7", book.pageList.single().contentSrc)
assertEquals(
mapOf(
"OEBPS/styles/main.css" to "body { color: black; }",
"OEBPS/styles/extra.css" to "p { margin: 0; }"
),
book.css
)
assertEquals(
setOf("OEBPS/images/picture.jpg", "OEBPS/images/unlisted.png"),
book.images.map { it.absPath }.toSet()
)
}
@Test
fun `createEpubBook can parse metadata only without chapters css or images`() = runTest {
val cacheDir = temp.newFolder("cache-metadata")
val extractionDir = temp.newFolder("extract-metadata")
val parser = EpubParser(contextWithCache(cacheDir))
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "book-id",
shouldUseToc = true,
originalBookNameHint = "fallback.epub",
parseContent = false,
extractionDirOverride = extractionDir
)
assertEquals("Sample/Book", book.title)
assertEquals(emptyList<EpubChapter>(), book.chapters)
assertEquals(emptyList<EpubImage>(), book.images)
assertEquals(emptyMap<String, String>(), book.css)
assertEquals(emptyList<EpubTocEntry>(), book.tableOfContents)
assertTrue(extractionDir.list().isNullOrEmpty())
}
@Test
fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest {
val cacheDir = temp.newFolder("cache-warm-open")
val parser = EpubParser(contextWithCache(cacheDir))
val first = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "warm-book",
shouldUseToc = true,
originalBookNameHint = "warm.epub"
)
val activeDir = ImportedFileCache.activeBookDir(contextWithCache(cacheDir), "warm-book")
File(activeDir, "sentinel.txt").writeText("still here")
val second = parser.createEpubBook(
inputStream = ByteArrayInputStream(minimalEpubBytesWithoutOptionalMetadata()),
bookId = "warm-book",
shouldUseToc = true,
originalBookNameHint = "warm.epub"
)
assertEquals(first.title, second.title)
assertEquals(first.chapters.size, second.chapters.size)
assertTrue(File(activeDir, "sentinel.txt").isFile)
}
@Test
fun `metadata only parse does not clear active extracted content`() = runTest {
val cacheDir = temp.newFolder("cache-metadata-preserve")
val context = contextWithCache(cacheDir)
val parser = EpubParser(context)
val activeDir = ImportedFileCache.ensureActiveBookDir(context, "metadata-book")
File(activeDir, "sentinel.txt").writeText("active")
parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "metadata-book",
parseContent = false,
originalBookNameHint = "metadata.epub"
)
assertTrue(File(activeDir, "sentinel.txt").isFile)
}
@Test
fun `createEpubBook falls back to file hint author language and chapter titles when metadata and ncx are absent`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-fallback")))
val extractionDir = temp.newFolder("extract-fallback")
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(minimalEpubBytesWithoutOptionalMetadata()),
bookId = "book-id",
shouldUseToc = false,
originalBookNameHint = "Original Name.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
assertEquals("Original Name", book.title)
assertEquals("Unknown Author", book.author)
assertEquals("en", book.language)
assertEquals("HTML Heading", book.chapters.single().title)
assertEquals(0, book.chapters.single().depth)
assertTrue(book.chapters.single().isInToc)
assertEquals(emptyList<EpubTocEntry>(), book.tableOfContents)
}
@Test
fun `createEpubBook throws parser exception for missing container rootfile or opf`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-errors")))
val missingContainer = runCatching {
parser.createEpubBook(ByteArrayInputStream(zipBytes("OEBPS/content.opf" to "<package/>")), "id")
}.exceptionOrNull()
val missingOpf = runCatching {
parser.createEpubBook(
ByteArrayInputStream(
zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/missing.opf"/></rootfiles></container>
""".trimIndent()
)
),
"id"
)
}.exceptionOrNull()
assertTrue(missingContainer is EpubParserException)
assertTrue(missingContainer!!.message!!.contains("container.xml"))
assertTrue(missingOpf is EpubParserException)
assertTrue(missingOpf!!.message!!.contains(".opf file missing"))
}
@Test
fun `EpubXMLFileParser extracts first heading and preserves optional fragment`() {
val parser = EpubXMLFileParser(
fileRelativePath = "chapters/one.xhtml",
data = "<html><body><h2> Chapter Title </h2><h1>Ignored</h1></body></html>".toByteArray(),
fragmentId = "anchor"
)
val output = parser.parseForTitleAndPath()
assertEquals("Chapter Title", output.title)
assertEquals("chapters/one.xhtml#anchor", output.effectiveHtmlPath)
}
@Test
fun `xml helpers select tags attributes children and filename conversions`() {
val document = parseXMLFile(
"""
<root>
<item id="one"><child>A</child><child>B</child></item>
<item id="two" />
</root>
""".trimIndent().toByteArray()
)!!
val firstItem = document.selectFirstTag("item")!!
assertEquals("one", firstItem.getAttributeValue("id"))
assertEquals("A", firstItem.selectFirstChildTag("child")!!.textContent)
assertEquals(listOf("A", "B"), firstItem.selectChildTag("child").map { it.textContent }.toList())
assertEquals("OPS_chapter_one.xhtml", "OPS/chapter/one.xhtml".asFileName())
assertNull(document.selectFirstTag("missing"))
}
@Test
fun `EpubXMLFileParser returns null title and unfragmented path when heading and fragment are absent`() {
val parser = EpubXMLFileParser(
fileRelativePath = "chapters/plain.xhtml",
data = "<html><body><p>No heading here.</p></body></html>".toByteArray()
)
val output = parser.parseForTitleAndPath()
assertNull(output.title)
assertEquals("chapters/plain.xhtml", output.effectiveHtmlPath)
}
@Test
fun `createEpubBook normalizes leading slash opf path from container`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-leading-slash")))
val extractionDir = temp.newFolder("extract-leading-slash")
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(
zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="/OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Slash Book</dc:title></metadata>
<manifest><item id="chap1" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="chap1"/></spine>
</package>
""".trimIndent(),
"OEBPS/chapter.xhtml" to "<html><body><p>Text</p></body></html>"
)
),
bookId = "book-id",
originalBookNameHint = "fallback.epub",
extractionDirOverride = extractionDir
)
assertEquals("Slash Book", book.title)
assertEquals("OEBPS/chapter.xhtml", book.chapters.single().htmlFilePath)
}
@Test
fun `createEpubBook creates synthetic readable chapter for image spine items`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-image-spine")))
val extractionDir = temp.newFolder("extract-image-spine")
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(imageSpineEpubBytes()),
bookId = "book-id",
shouldUseToc = false,
originalBookNameHint = "image-book.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
val chapter = book.chapters.single()
assertEquals("Image", chapter.title)
assertEquals("OEBPS/images/page1.jpg", chapter.htmlFilePath)
assertEquals("[Image]", chapter.plainTextContent)
assertTrue(chapter.htmlContent.contains("<img src=\"OEBPS/images/page1.jpg\""))
assertEquals(listOf(EpubImage("OEBPS/images/page1.jpg")), book.images)
}
@Test
fun `hasReadableExtractedContent validates blank dirs empty dirs and chapter files`() {
assertFalse(epubBook(extractionBasePath = "").hasReadableExtractedContent())
assertFalse(epubBook(extractionBasePath = File(temp.root, "missing").absolutePath).hasReadableExtractedContent())
val emptyDir = temp.newFolder("empty-readable")
assertFalse(epubBook(extractionBasePath = emptyDir.absolutePath).hasReadableExtractedContent())
val nonChapterDir = temp.newFolder("non-chapter")
File(nonChapterDir, "asset.css").writeText("body{}")
assertTrue(epubBook(extractionBasePath = nonChapterDir.absolutePath).hasReadableExtractedContent())
val chapterDir = temp.newFolder("chapters-readable")
File(chapterDir, "one.xhtml").writeText("<p>One</p>")
val readable = epubBook(
extractionBasePath = chapterDir.absolutePath,
chapters = listOf(chapter("one.xhtml"))
)
val missing = readable.copy(chapters = listOf(chapter("one.xhtml"), chapter("two.xhtml")))
assertTrue(readable.hasReadableExtractedContent())
assertFalse(missing.hasReadableExtractedContent())
}
private fun contextWithCache(cacheDir: File): Context {
val context = mockk<Context>()
every { context.cacheDir } returns cacheDir
return context
}
private fun sampleEpubBytes(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container version="1.0">
<rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles>
</container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Sample/Book</dc:title>
<dc:creator>Jane Writer</dc:creator>
<dc:language>en</dc:language>
<dc:description>Long description</dc:description>
<meta name="calibre:series" content="Series Name"/>
<meta name="calibre:series_index" content="2.5"/>
</metadata>
<manifest>
<item id="chap1" href="chapters/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="chap2" href="chapters/chapter%202.xhtml" media-type="application/xhtml+xml"/>
<item id="toc" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="style" href="styles/main.css" media-type="text/css"/>
<item id="pic" href="images/picture.jpg" media-type="image/jpeg"/>
</manifest>
<spine toc="toc">
<itemref idref="chap1"/>
<itemref idref="chap2"/>
</spine>
</package>
""".trimIndent(),
"OEBPS/toc.ncx" to """
<ncx>
<navMap>
<navPoint id="nav1">
<navLabel><text>NCX Chapter One</text></navLabel>
<content src="chapters/chapter1.xhtml#start"/>
<navPoint id="nav2">
<navLabel><text>Nested Two</text></navLabel>
<content src="chapters/chapter%202.xhtml#top"/>
</navPoint>
</navPoint>
</navMap>
<pageList>
<pageTarget id="p7" value="7">
<navLabel><text>7</text></navLabel>
<content src="chapters/chapter%202.xhtml#page7"/>
</pageTarget>
</pageList>
</ncx>
""".trimIndent(),
"OEBPS/chapters/chapter1.xhtml" to "<html><body><h1>Ignored HTML Title</h1><p>One</p></body></html>",
"OEBPS/chapters/chapter 2.xhtml" to "<html><body><h2>Chapter Two</h2><p>Two text</p></body></html>",
"OEBPS/styles/main.css" to "body { color: black; }",
"OEBPS/styles/extra.css" to "p { margin: 0; }",
"OEBPS/images/picture.jpg" to "not-real-image",
"OEBPS/images/unlisted.png" to "not-real-image"
)
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package>
<metadata />
<manifest>
<item id="chap1" href="chapter.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="chap1"/></spine>
</package>
""".trimIndent(),
"OEBPS/chapter.xhtml" to "<html><body><h1>HTML Heading</h1><p>Text</p></body></html>"
)
private fun imageSpineEpubBytes(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Image Book</dc:title></metadata>
<manifest>
<item id="page1" href="images/page1.jpg" media-type="image/jpeg"/>
</manifest>
<spine><itemref idref="page1"/></spine>
</package>
""".trimIndent(),
"OEBPS/images/page1.jpg" to "not-real-image"
)
private fun zipBytes(vararg entries: Pair<String, String>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
entries.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name))
zip.write(content.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
}
return out.toByteArray()
}
private fun epubBook(
extractionBasePath: String,
chapters: List<EpubChapter> = emptyList()
): EpubBook =
EpubBook(
fileName = "book.epub",
title = "Book",
author = "Author",
language = "en",
coverImage = null,
chapters = chapters,
extractionBasePath = extractionBasePath
)
private fun chapter(path: String): EpubChapter =
EpubChapter(
chapterId = path,
absPath = path,
title = path,
htmlFilePath = path,
plainTextContent = "",
htmlContent = ""
)
}

View file

@ -0,0 +1,119 @@
package com.aryan.reader.epub
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class ImportedFileCacheTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `active directory names are sanitized stable and marked active`() {
val first = ImportedFileCache.activeBookDirName("Book:/One?*")
val second = ImportedFileCache.activeBookDirName("Book:/One?*")
assertTrue(first.startsWith("imported_file_"))
assertFalse(first.contains(":"))
assertFalse(first.contains("/"))
assertFalse(first.contains("?"))
assertFalse(first.contains("*"))
assertTrue(ImportedFileCache.isActiveBookDir(first))
assertFalse(ImportedFileCache.isTemporaryBookDir(first))
assertTrue(first == second)
}
@Test
fun `prepareDirectory clears stale contents before reusing directory`() {
val dir = temp.newFolder("active")
File(dir, "old.xhtml").writeText("stale")
val prepared = ImportedFileCache.prepareDirectory(dir)
assertTrue(prepared.isDirectory)
assertTrue(prepared.listFiles().isNullOrEmpty())
}
@Test
fun `ensureActiveBookDir preserves active contents and resetActiveBookDir clears them`() {
val context = contextWithCache(temp.newFolder("ensure-active-cache"))
val active = ImportedFileCache.ensureActiveBookDir(context, "Book")
File(active, "book_metadata.json").writeText("cached")
val ensuredAgain = ImportedFileCache.ensureActiveBookDir(context, "Book")
assertEquals("cached", File(ensuredAgain, "book_metadata.json").readText())
val reset = ImportedFileCache.resetActiveBookDir(context, "Book")
assertTrue(reset.isDirectory)
assertTrue(reset.listFiles().isNullOrEmpty())
}
@Test
fun `temporary directory creation and targeted cleanup only remove matching book marker`() {
val context = contextWithCache(temp.newFolder("cache"))
val firstBookTemp = ImportedFileCache.createTemporaryBookDir(context, "Book One", "preview/import")
val secondBookTemp = ImportedFileCache.createTemporaryBookDir(context, "Book Two", "preview/import")
File(firstBookTemp, "file.txt").writeText("one")
File(secondBookTemp, "file.txt").writeText("two")
ImportedFileCache.clearTemporaryBookDirs(context, "Book One")
assertFalse(firstBookTemp.exists())
assertTrue(secondBookTemp.exists())
assertTrue(ImportedFileCache.isTemporaryBookDir(secondBookTemp.name))
assertFalse(ImportedFileCache.isActiveBookDir(secondBookTemp.name))
}
@Test
fun `deleteStaleTemporaryBookDirs removes old temporary dirs and keeps fresh and active dirs`() {
val cacheDir = temp.newFolder("stale-cache")
val context = contextWithCache(cacheDir)
val staleTemp = ImportedFileCache.createTemporaryBookDir(context, "Book", "stale")
val freshTemp = ImportedFileCache.createTemporaryBookDir(context, "Book", "fresh")
val activeDir = ImportedFileCache.prepareActiveBookDir(context, "Book")
val now = 10_000L
staleTemp.setLastModified(1_000L)
freshTemp.setLastModified(9_500L)
activeDir.setLastModified(1_000L)
ImportedFileCache.deleteStaleTemporaryBookDirs(context, olderThanMillis = 5_000L, nowMillis = now)
assertFalse(staleTemp.exists())
assertTrue(freshTemp.exists())
assertTrue(activeDir.exists())
}
@Test
fun `clearBookCache removes active legacy and temporary cache directories`() {
val cacheDir = temp.newFolder("clear-book-cache")
val context = contextWithCache(cacheDir)
val active = ImportedFileCache.prepareActiveBookDir(context, "Book")
val legacy = File(cacheDir, "imported_file_Book").apply { mkdirs() }
val temporary = ImportedFileCache.createTemporaryBookDir(context, "Book", "tmp")
File(active, "active.txt").writeText("active")
File(legacy, "legacy.txt").writeText("legacy")
File(temporary, "temporary.txt").writeText("temporary")
ImportedFileCache.clearBookCache(context, "Book")
assertFalse(active.exists())
assertFalse(legacy.exists())
assertFalse(temporary.exists())
}
private fun contextWithCache(cacheDir: File): Context {
val context = mockk<Context>()
every { context.cacheDir } returns cacheDir
return context
}
}

View file

@ -0,0 +1,146 @@
package com.aryan.reader.epub
import android.content.Context
import com.aryan.reader.FileType
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayInputStream
import java.io.File
class SingleFileImporterTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `metadata-only import returns lightweight book for supported text formats`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("metadata-cache")))
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream("ignored".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Notes.txt",
bookId = "notes",
parseContent = false
)
assertEquals("Notes.txt", book.fileName)
assertEquals("Notes", book.title)
assertEquals("Unknown", book.author)
assertEquals("en", book.language)
assertEquals(emptyList<EpubChapter>(), book.chapters)
assertEquals("", book.extractionBasePath)
}
@Test
fun `plain text import escapes html groups paragraphs and writes cached metadata`() = runTest {
val cache = temp.newFolder("txt-cache")
val importer = SingleFileImporter(contextWithCache(cache))
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream("First <line>\ncontinues\n\nSecond & final".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Plain.txt",
bookId = "plain-book"
)
assertEquals("Plain", book.title)
assertEquals(1, book.chapters.size)
assertEquals("Part 1", book.chapters.single().title)
assertTrue(book.chapters.single().plainTextContent.contains("First <line> continues"))
assertTrue(File(book.extractionBasePath, "part_1.html").readText().contains("First &lt;line&gt;"))
assertTrue(File(book.extractionBasePath, "book_metadata.json").isFile)
}
@Test
fun `plain text import reuses cached metadata before reading the stream`() = runTest {
val cache = temp.newFolder("txt-cache-reuse")
val importer = SingleFileImporter(contextWithCache(cache))
val first = importer.importSingleFile(
inputStream = ByteArrayInputStream("Cached content".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Cached.txt",
bookId = "cached-book"
)
val second = importer.importSingleFile(
inputStream = ByteArrayInputStream("Different content that should not be parsed".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Cached.txt",
bookId = "cached-book"
)
assertEquals(first.title, second.title)
assertEquals(first.chapters.single().plainTextContent, second.chapters.single().plainTextContent)
assertTrue(second.chapters.single().plainTextContent.contains("Cached content"))
}
@Test
fun `html import extracts title author style skips scripts and splits page breaks`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("html-cache")))
val html = """
<html>
<head>
<title>HTML Title</title>
<meta name="author" content="HTML Author">
<style>p { color: red; }</style>
</head>
<body>
<p>First page</p>
<script>bad()</script>
<page-break></page-break>
<p>Second page</p>
</body>
</html>
""".trimIndent()
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream(html.toByteArray()),
type = FileType.HTML,
originalBookNameHint = "fallback.html",
bookId = "html-book"
)
assertEquals("HTML Title", book.title)
assertEquals("HTML Author", book.author)
assertEquals(2, book.chapters.size)
assertEquals("HTML Title", book.chapters[0].title)
assertEquals("Page 2", book.chapters[1].title)
assertTrue(book.chapters[0].plainTextContent.contains("First page"))
assertTrue(book.chapters[1].plainTextContent.contains("Second page"))
assertFalse(File(book.extractionBasePath, "page_1.html").readText().contains("bad()"))
assertTrue(File(book.extractionBasePath, "page_1.html").readText().contains("p { color: red; }"))
}
@Test
fun `csv txt wrapper imports as html table`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("csv-cache")))
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream("Name,Value\nA & B,<tag>".toByteArray()),
type = FileType.HTML,
originalBookNameHint = "data.csv.txt",
bookId = "csv-book"
)
val html = File(book.extractionBasePath, "page_1.html").readText()
assertEquals("data.csv", book.title)
assertTrue(html.contains("<table>"))
assertTrue(html.contains("A &amp; B"))
assertTrue(html.contains("&lt;tag&gt;"))
}
private fun contextWithCache(cacheDir: File): Context {
val context = mockk<Context>()
every { context.cacheDir } returns cacheDir
return context
}
}

View file

@ -0,0 +1,200 @@
package com.aryan.reader.epubreader
import android.webkit.WebView
import com.aryan.reader.RenderMode
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class EpubReaderBridgeAndControlsTest {
@Test
fun `sanitizePlaceholders keeps one header per toolbar section and inserts empty placeholders`() {
val input = listOf(
FlatToolItem("old_header", FlatItemType.SECTION_HEADER, section = ToolbarSection.BOTTOM),
FlatToolItem("format", FlatItemType.TOOL, tool = ReaderTool.FORMAT, section = ToolbarSection.BOTTOM),
FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More"),
FlatToolItem("reading_mode", FlatItemType.MORE_TOOL, tool = ReaderTool.READING_MODE)
)
val sanitized = sanitizePlaceholders(input)
assertEquals(
listOf(
FlatItemType.SECTION_HEADER,
FlatItemType.EMPTY_PLACEHOLDER,
FlatItemType.SECTION_HEADER,
FlatItemType.TOOL,
FlatItemType.SECTION_HEADER,
FlatItemType.EMPTY_PLACEHOLDER,
FlatItemType.MORE_HEADER,
FlatItemType.MORE_TOOL
),
sanitized.map { it.type }
)
assertEquals(listOf(ToolbarSection.TOP, ToolbarSection.BOTTOM, ToolbarSection.HIDDEN), sanitized.filter { it.type == FlatItemType.SECTION_HEADER }.map { it.section })
assertEquals(ReaderTool.FORMAT, sanitized.single { it.type == FlatItemType.TOOL }.tool)
}
@Test
fun `auto scroll bridge invokes chapter end callback`() {
var calls = 0
AutoScrollJsBridge { calls++ }.onChapterEnd()
assertEquals(1, calls)
}
@Test
fun `tts bridge relays nonblank structured text and normalizes blank payloads`() = runTest {
val received = CompletableDeferred<String>()
val bridge = TtsJsBridge(scope = this, ttsStructuredTextHandler = { received.complete(it) })
bridge.onStructuredTextExtracted("[{\"text\":\"Hello\"}]")
assertEquals("[{\"text\":\"Hello\"}]", received.await())
val blankReceived = CompletableDeferred<String>()
TtsJsBridge(scope = this, ttsStructuredTextHandler = { blankReceived.complete(it) }).onStructuredTextExtracted(" ")
assertEquals("[]", blankReceived.await())
}
@Test
fun `highlight bridge forwards create and click events`() {
var created: Triple<String, String, String>? = null
var clicked: List<Any>? = null
val bridge = HighlightJsBridge(
onCreateCallback = { cfi, text, color -> created = Triple(cfi, text, color) },
onClickCallback = { cfi, text, left, top, right, bottom ->
clicked = listOf(cfi, text, left, top, right, bottom)
}
)
bridge.onHighlightCreated("/4", "Text", "yellow")
bridge.onHighlightClicked("/4", "Text", 1, 2, 3, 4)
assertEquals(Triple("/4", "Text", "yellow"), created)
assertEquals(listOf("/4", "Text", 1, 2, 3, 4), clicked)
}
@Test
fun `content snippet progress footnote and ai bridges forward callbacks`() = runTest {
var requestedChunk = -1
var snippet = "" to ""
var progressCalls = 0
var lastChunk = -1
var footnote = ""
val aiContent = CompletableDeferred<String>()
ContentBridge { requestedChunk = it }.requestChunk(7)
SnippetJsBridge { cfi, text -> snippet = cfi to text }.onSnippetExtracted("/6", "Snippet")
val progress = ProgressJsBridge {
progressCalls++
lastChunk = it
}
progress.updateTopChunk(2)
progress.updateTopChunk(2)
progress.updateTopChunk(3)
FootnoteJsBridge { footnote = it }.onFootnoteRequested("<p>Note</p>")
AiJsBridge(scope = this, onContentReady = { aiContent.complete(it) }).onContentExtractedForSummarization("Chapter text")
assertEquals(7, requestedChunk)
assertEquals("/6" to "Snippet", snippet)
assertEquals(2, progressCalls)
assertEquals(3, lastChunk)
assertEquals("<p>Note</p>", footnote)
assertEquals("Chapter text", aiContent.await())
}
@Test
fun `ai bridge ignores blank content`() = runTest {
var called = false
AiJsBridge(scope = this, onContentReady = { called = true }).onContentExtractedForSummarization(" ")
assertFalse(called)
}
@Test
fun `cfi bridge parses save bookmark and scroll callbacks with fallback for invalid save json`() {
val saved = mutableListOf<String>()
val bookmark = mutableListOf<String>()
val scrollResults = mutableListOf<Boolean>()
val bridge = CfiJsBridge(
onCfiReady = { saved.add(it) },
onCfiForBookmarkReady = { bookmark.add(it) },
onScrollFinishedCallback = { scrollResults.add(it) }
)
bridge.onCfiExtracted(JSONObject().put("cfi", "/4/2:8").put("log", JSONArray()).toString())
bridge.onCfiExtracted(JSONObject().put("cfi", "").toString())
bridge.onCfiExtracted("broken")
bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", "/6/4:1").toString())
bridge.onCfiForBookmarkExtracted("broken")
bridge.onScrollFinished(true)
bridge.onScrollFinished(false)
assertEquals(listOf("/4/2:8", "/4"), saved)
assertEquals(listOf("/6/4:1"), bookmark)
assertEquals(listOf(true, false), scrollResults)
}
@Test
fun `cfi bridge preserves full reading position cfi payloads for save and bookmark callbacks`() {
val saved = mutableListOf<String>()
val bookmark = mutableListOf<String>()
val bridge = CfiJsBridge(
onCfiReady = { saved.add(it) },
onCfiForBookmarkReady = { bookmark.add(it) },
onScrollFinishedCallback = {}
)
val cfi = "/6/4[chapter]!/4/2/8:137"
bridge.onCfiExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray().put("exact")).toString())
bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray()).toString())
assertEquals(listOf(cfi), saved)
assertEquals(listOf(cfi), bookmark)
}
@Test
fun `updateAutoScrollJs emits start and stop commands`() {
val webView = mockk<WebView>(relaxed = true)
updateAutoScrollJs(webView, playing = true, speed = 1.25f)
updateAutoScrollJs(webView, playing = false, speed = 9f)
verify { webView.evaluateJavascript("javascript:window.autoScroll.start(1.25);", null) }
verify { webView.evaluateJavascript("javascript:window.autoScroll.stop();", null) }
}
@Test
fun `initiateTtsPlayback chooses web extraction for vertical mode and callback for paginated mode`() {
val webView = mockk<WebView>(relaxed = true)
var paginatedStarts = 0
initiateTtsPlayback(RenderMode.VERTICAL_SCROLL, webView) { paginatedStarts++ }
initiateTtsPlayback(RenderMode.PAGINATED, webView) { paginatedStarts++ }
verify { webView.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null) }
assertEquals(1, paginatedStarts)
}
@Test
fun `reader tool metadata has stable unique names and categories`() {
assertEquals(ReaderTool.entries.size, ReaderTool.entries.map { it.name }.toSet().size)
assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
}
}

View file

@ -0,0 +1,161 @@
package com.aryan.reader.epubreader
import android.content.Context
import com.aryan.reader.R
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.LocatorConverter
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class EpubReaderContentTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `loadChapterContent removes scripts keeps head and chunks body nodes by twenty`() = runTest {
val root = temp.newFolder("content")
val body = (1..21).joinToString("") { index ->
if (index == 3) "<script>bad()</script><p>Paragraph $index</p>" else "<p>Paragraph $index</p>"
}
writeChapter(root, "chapter.xhtml", "<html><head><style>.x{}</style></head><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val result = loadChapterContent(
context = contextWithStrings(),
epubBook = book,
chapterIndex = 0,
chunkTargetOverride = null,
isInitialCfiLoad = false,
cfiToLoad = null,
locatorConverter = mockk()
)
assertTrue(result.isSuccess)
assertEquals("<style>.x{}</style>", result.head.trim())
assertEquals(2, result.chunks.size)
assertFalse(result.chunks.joinToString().contains("<script>"))
assertTrue(result.chunks[0].contains("Paragraph 20"))
assertTrue(result.chunks[1].contains("Paragraph 21"))
assertEquals(0, result.startChunkIndex)
}
@Test
fun `loadChapterContent clamps explicit chunk override into available chunk range`() = runTest {
val root = temp.newFolder("override")
val body = (1..5).joinToString("") { "<p>Only $it</p>" }
writeChapter(root, "chapter.xhtml", "<html><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val high = loadChapterContent(contextWithStrings(), book, 0, 99, false, null, mockk())
val low = loadChapterContent(contextWithStrings(), book, 0, -5, false, null, mockk())
assertEquals(0, high.startChunkIndex)
assertEquals(0, low.startChunkIndex)
}
@Test
fun `loadChapterContent calculates initial chunk from cfi locator block index`() = runTest {
val root = temp.newFolder("cfi")
val body = (1..60).joinToString("") { "<p>Paragraph $it</p>" }
writeChapter(root, "chapter.xhtml", "<html><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val locatorConverter = mockk<LocatorConverter>()
coEvery { locatorConverter.getLocatorFromCfi(book, 0, "/4/2:10") } returns Locator(0, blockIndex = 45, charOffset = 0)
val result = loadChapterContent(
context = contextWithStrings(),
epubBook = book,
chapterIndex = 0,
chunkTargetOverride = null,
isInitialCfiLoad = true,
cfiToLoad = "/4/2:10",
locatorConverter = locatorConverter
)
assertEquals(2, result.startChunkIndex)
}
@Test
fun `loadChapterContent falls back to last chunk when cfi cannot be resolved`() = runTest {
val root = temp.newFolder("cfi-missing")
val body = (1..45).joinToString("") { "<p>Paragraph $it</p>" }
writeChapter(root, "chapter.xhtml", "<html><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val locatorConverter = mockk<LocatorConverter>()
coEvery { locatorConverter.getLocatorFromCfi(book, 0, "/missing") } returns null
val result = loadChapterContent(contextWithStrings(), book, 0, null, true, "/missing", locatorConverter)
assertEquals(2, result.startChunkIndex)
}
@Test
fun `loadChapterContent returns localized empty and missing chapter placeholders`() = runTest {
val root = temp.newFolder("placeholders")
writeChapter(root, "empty.xhtml", "<html><body></body></html>")
val book = epubBook(root, listOf(chapter("empty.xhtml"), chapter("missing.xhtml")))
val empty = loadChapterContent(contextWithStrings(), book, 0, null, false, null, mockk())
val missing = loadChapterContent(contextWithStrings(), book, 1, null, false, null, mockk())
assertEquals(listOf("<body><p>Empty chapter</p></body>"), empty.chunks)
assertEquals(listOf("<h1>Chapter not found</h1>"), missing.chunks)
assertTrue(missing.isSuccess)
}
@Test
fun `loadChapterContent reports out of bounds chapter index`() = runTest {
val root = temp.newFolder("bounds")
val result = loadChapterContent(contextWithStrings(), epubBook(root, emptyList()), 0, null, false, null, mockk())
assertFalse(result.isSuccess)
assertEquals("Chapter index out of bounds", result.errorMessage)
assertEquals(emptyList<String>(), result.chunks)
}
private fun writeChapter(root: java.io.File, relativePath: String, html: String) {
val file = java.io.File(root, relativePath)
file.parentFile?.mkdirs()
file.writeText(html)
}
private fun epubBook(root: java.io.File, chapters: List<EpubChapter>): EpubBook =
EpubBook(
fileName = "book.epub",
title = "Book",
author = "Author",
language = "en",
coverImage = null,
chapters = chapters,
extractionBasePath = root.absolutePath
)
private fun chapter(path: String): EpubChapter =
EpubChapter(
chapterId = path,
absPath = path,
title = path,
htmlFilePath = path,
plainTextContent = "",
htmlContent = ""
)
private fun contextWithStrings(): Context {
val context = mockk<Context>()
every { context.getString(R.string.chapter_empty) } returns "Empty chapter"
every { context.getString(R.string.chapter_not_found) } returns "Chapter not found"
every { context.getString(R.string.error_loading_chapter) } returns "Error loading chapter"
return context
}
}

View file

@ -0,0 +1,352 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.epub.EpubChapter
import io.mockk.every
import io.mockk.mockk
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EpubReaderPreferencesAndAnnotationsTest {
@Test
fun `reader settings defaults and invalid persisted enum values fall back safely`() {
val prefs = TestSharedPreferences(
"reader_system_ui_mode" to Int.MIN_VALUE,
"reader_page_info_mode" to Int.MAX_VALUE,
"reader_page_info_position" to -20,
"reader_font_family" to "missing",
"reader_text_align" to "diagonal"
)
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
val format = loadFormatSettings(context, bookId = "book", isLocal = false)
assertEquals(SystemUiMode.DEFAULT, loadSystemUiMode(context))
assertEquals(PageInfoMode.DEFAULT, loadPageInfoMode(context))
assertEquals(PageInfoPosition.BOTTOM, loadPageInfoPosition(context))
assertEquals(DEFAULT_FONT_SIZE_VAL, format.fontSize, 0.0001f)
assertEquals(DEFAULT_LINE_HEIGHT_VAL, format.lineHeight, 0.0001f)
assertEquals(DEFAULT_PARAGRAPH_GAP_VAL, format.paragraphGap, 0.0001f)
assertEquals(DEFAULT_IMAGE_SIZE_VAL, format.imageSize, 0.0001f)
assertEquals(ReaderFont.ORIGINAL, format.font)
assertEquals(ReaderTextAlign.DEFAULT, format.textAlign)
assertNull(format.customPath)
}
@Test
fun `global and local format settings round trip including custom fonts`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveReaderSettings(
context = context,
fontSize = 1.4f,
lineHeight = 1.6f,
paragraphGap = 0.7f,
imageSize = 1.2f,
horizontalMargin = 1.8f,
verticalMargin = 0.4f,
fontFamily = ReaderFont.LORA,
customFontPath = null,
textAlign = ReaderTextAlign.JUSTIFY
)
saveLocalReaderSettings(
context = context,
bookId = "book",
fontSize = 0.9f,
lineHeight = 1.1f,
paragraphGap = 1.3f,
imageSize = 1.5f,
horizontalMargin = 0.2f,
verticalMargin = 2.2f,
fontFamily = ReaderFont.MERRIWEATHER,
customFontPath = "/fonts/custom.ttf",
textAlign = ReaderTextAlign.LEFT
)
val global = loadFormatSettings(context, bookId = "book", isLocal = false)
val local = loadFormatSettings(context, bookId = "book", isLocal = true)
assertEquals(1.4f, global.fontSize, 0.0001f)
assertEquals(ReaderFont.LORA, global.font)
assertEquals(ReaderTextAlign.JUSTIFY, global.textAlign)
assertNull(global.customPath)
assertEquals(0.9f, local.fontSize, 0.0001f)
assertEquals(1.1f, local.lineHeight, 0.0001f)
assertEquals(1.3f, local.paragraphGap, 0.0001f)
assertEquals(1.5f, local.imageSize, 0.0001f)
assertEquals(0.2f, local.horizontalMargin, 0.0001f)
assertEquals(2.2f, local.verticalMargin, 0.0001f)
assertEquals(ReaderFont.ORIGINAL, local.font)
assertEquals("/fonts/custom.ttf", local.customPath)
assertEquals(ReaderTextAlign.LEFT, local.textAlign)
}
@Test
fun `local format settings fall back to global values per missing local field`() {
val prefs = TestSharedPreferences(
"reader_font_size" to 1.8f,
"reader_line_height" to 1.7f,
"reader_paragraph_gap" to 1.6f,
"reader_image_size" to 1.5f,
"reader_horizontal_margin" to 1.4f,
"reader_vertical_margin" to 1.3f,
"reader_font_family" to ReaderFont.LEXEND.id,
"reader_text_align" to ReaderTextAlign.JUSTIFY.id,
"local_font_size_book" to 0.8f,
"local_font_family_book" to ReaderFont.LATO.id
)
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
val local = loadFormatSettings(context, bookId = "book", isLocal = true)
assertEquals(0.8f, local.fontSize, 0.0001f)
assertEquals(1.7f, local.lineHeight, 0.0001f)
assertEquals(1.6f, local.paragraphGap, 0.0001f)
assertEquals(1.5f, local.imageSize, 0.0001f)
assertEquals(1.4f, local.horizontalMargin, 0.0001f)
assertEquals(1.3f, local.verticalMargin, 0.0001f)
assertEquals(ReaderFont.LATO, local.font)
assertEquals(ReaderTextAlign.JUSTIFY, local.textAlign)
}
@Test
fun `simple reader preference toggles and numeric settings round trip`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveTtsSpeechRate(context, 1.35f)
saveTtsPitch(context, 0.85f)
saveSystemUiMode(context, SystemUiMode.HIDDEN)
savePageInfoMode(context, PageInfoMode.SYNC)
savePageInfoPosition(context, PageInfoPosition.TOP)
savePullToTurn(context, false)
savePullToTurnMultiplier(context, 1.75f)
saveAutoScrollSpeed(context, 2.5f)
saveTapToNavigateSetting(context, true)
saveVolumeScrollSetting(context, true)
saveRemoveEdgePadding(context, true)
saveFormatIsLocal(context, "book", true)
assertEquals(1.35f, loadTtsSpeechRate(context), 0.0001f)
assertEquals(0.85f, loadTtsPitch(context), 0.0001f)
assertEquals(SystemUiMode.HIDDEN, loadSystemUiMode(context))
assertEquals(PageInfoMode.SYNC, loadPageInfoMode(context))
assertEquals(PageInfoPosition.TOP, loadPageInfoPosition(context))
assertFalse(loadPullToTurn(context))
assertEquals(1.75f, loadPullToTurnMultiplier(context), 0.0001f)
assertEquals(2.5f, loadAutoScrollSpeed(context), 0.0001f)
assertTrue(loadTapToNavigateSetting(context))
assertTrue(loadVolumeScrollSetting(context))
assertTrue(loadRemoveEdgePadding(context))
assertTrue(loadFormatIsLocal(context, "book"))
assertEquals(0f, loadHorizontalMargin(context), 0.0001f)
}
@Test
fun `explicit horizontal margin wins over remove edge padding migration fallback`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveRemoveEdgePadding(context, true)
saveReaderSettings(
context = context,
fontSize = 1f,
lineHeight = 1f,
paragraphGap = 1f,
imageSize = 1f,
horizontalMargin = 2.4f,
verticalMargin = 1f,
fontFamily = ReaderFont.ORIGINAL,
customFontPath = null,
textAlign = ReaderTextAlign.DEFAULT
)
assertEquals(2.4f, loadHorizontalMargin(context), 0.0001f)
}
@Test
fun `highlight palette saves exactly four known colors and falls back otherwise`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveHighlightPalette(context, listOf(HighlightColor.CYAN, HighlightColor.MAGENTA, HighlightColor.LIME, HighlightColor.PINK))
assertEquals(
listOf(HighlightColor.CYAN, HighlightColor.MAGENTA, HighlightColor.LIME, HighlightColor.PINK),
loadHighlightPalette(context)
)
val invalidContext = contextWithPrefs(
SETTINGS_PREFS_NAME to TestSharedPreferences("highlight_palette_ids" to "yellow,unknown,blue")
)
assertEquals(
listOf(HighlightColor.YELLOW, HighlightColor.GREEN, HighlightColor.BLUE, HighlightColor.RED),
loadHighlightPalette(invalidContext)
)
}
@Test
fun `highlight JSON round trips notes escapes unknown colors and invalid JSON`() {
val highlights = listOf(
UserHighlight(id = "h1", cfi = "/4/2:1", text = "Quote", color = HighlightColor.BLUE, chapterIndex = 2, note = "Remember"),
UserHighlight(id = "h2", cfi = "/4/4:3", text = "Plain", color = HighlightColor.RED, chapterIndex = 3, note = null)
)
val parsed = parseHighlightsJson(highlightsToJson(highlights))
assertEquals(highlights, parsed)
assertNull(parsed[1].note)
val unknownColorJson = JSONArray().put(
JSONObject()
.put("id", "h3")
.put("cfi", "/4")
.put("text", "Text")
.put("colorId", "infrared")
.put("chapterIndex", 1)
).toString()
assertEquals(HighlightColor.YELLOW, parseHighlightsJson(unknownColorJson).single().color)
assertEquals(emptyList<UserHighlight>(), parseHighlightsJson("{broken"))
assertEquals(emptyList<UserHighlight>(), parseHighlightsJson(null))
}
@Test
fun `highlight preference storage uses sanitized title and can be cleared`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
val highlight = UserHighlight(id = "h1", cfi = "/4", text = "Text", color = HighlightColor.GREEN, chapterIndex = 0, note = "Note")
saveHighlightsToPrefs(context, "Book: One!", listOf(highlight))
assertEquals(listOf(highlight), loadHighlightsFromPrefs(context, "Book One"))
clearHighlightsFromPrefs(context, "Book One")
assertEquals(emptyList<UserHighlight>(), loadHighlightsFromPrefs(context, "Book One"))
}
@Test
fun `processAndAddHighlight updates exact cfi match and appends overlaps independently`() {
val highlights = mutableListOf(
UserHighlight(id = "existing", cfi = "/4/2:10", text = "Old", color = HighlightColor.YELLOW, chapterIndex = 1, note = "keep")
)
val updatedCfi = processAndAddHighlight("/4/2:10", "New", HighlightColor.PURPLE, chapterIndex = 1, currentList = highlights)
val addedCfi = processAndAddHighlight("/4/2:11", "Overlap", HighlightColor.CYAN, chapterIndex = 1, currentList = highlights)
assertEquals("/4/2:10", updatedCfi)
assertEquals("/4/2:11", addedCfi)
assertEquals(2, highlights.size)
assertEquals("existing", highlights[0].id)
assertEquals("New", highlights[0].text)
assertEquals(HighlightColor.PURPLE, highlights[0].color)
assertEquals("keep", highlights[0].note)
assertEquals("Overlap", highlights[1].text)
}
@Test
fun `bookmarks parse current and legacy payloads with optional label and pages`() {
val chapters = listOf(
chapter("Chapter 1"),
chapter("Chapter 2")
)
val current = JSONObject()
.put("cfi", "/4")
.put("chapterTitle", "Chapter 1")
.put("label", "Named mark")
.put("snippet", "Snippet")
.put("pageInChapter", 2)
.put("totalPagesInChapter", 9)
.put("chapterIndex", 0)
val legacy = JSONObject()
.put("cfi", "/6")
.put("chapterTitle", "Chapter 2")
.put("snippet", "Legacy")
val context = contextWithPrefs()
val bookmarks = loadBookmarks(context, "Book", chapters, JSONArray(listOf(current.toString(), legacy.toString())).toString())
assertEquals(
setOf(
Bookmark("/4", "Chapter 1", "Named mark", "Snippet", 2, 9, 0),
Bookmark("/6", "Chapter 2", null, "Legacy", null, null, 1)
),
bookmarks
)
}
@Test
fun `bookmarks fall back to shared preferences using sanitized book title`() {
val bookmarkPrefs = TestSharedPreferences(
"bookmarks_cfi_BookOne" to setOf(
JSONObject()
.put("cfi", "/4")
.put("chapterTitle", "Chapter")
.put("snippet", "Saved")
.put("chapterIndex", 0)
.toString()
)
)
val context = contextWithPrefs("epub_reader_bookmarks" to bookmarkPrefs)
val bookmarks = loadBookmarks(context, "Book: One!", listOf(chapter("Chapter")), bookmarksJson = null)
assertEquals(setOf(Bookmark("/4", "Chapter", null, "Saved", null, null, 0)), bookmarks)
}
@Test
fun `bookmarks ignore malformed entries while keeping valid ones from view model json`() {
val valid = JSONObject()
.put("cfi", "/8")
.put("chapterTitle", "Chapter")
.put("snippet", "Valid")
.put("chapterIndex", 0)
.toString()
val malformed = "{\"cfi\":\"/broken\""
val context = contextWithPrefs()
val bookmarks = loadBookmarks(context, "Book", listOf(chapter("Chapter")), JSONArray(listOf(valid, malformed)).toString())
assertEquals(setOf(Bookmark("/8", "Chapter", null, "Valid", null, null, 0)), bookmarks)
}
@Test
fun `escapeJsString escapes all characters that break JavaScript string literals`() {
val raw = "\\ ' \" \n \r \t \u2028 \u2029"
assertEquals("\\\\ \\' \\\" \\n \\r \\t \\u2028 \\u2029", escapeJsString(raw))
}
@Test
fun `highlight color metadata stays unique and maps to concrete argb colors`() {
assertEquals(HighlightColor.entries.size, HighlightColor.entries.map { it.id }.toSet().size)
assertEquals(HighlightColor.entries.size, HighlightColor.entries.map { it.cssClass }.toSet().size)
assertEquals(Color(0xFFFBC02D).toArgb(), HighlightColor.YELLOW.color.toArgb())
}
private fun chapter(title: String): EpubChapter =
EpubChapter(
chapterId = title,
absPath = "$title.xhtml",
title = title,
htmlFilePath = "$title.xhtml",
plainTextContent = "",
htmlContent = ""
)
private fun contextWithPrefs(vararg prefsByName: Pair<String, SharedPreferences>): Context {
val context = mockk<Context>()
val prefsMap = prefsByName.toMap()
every { context.getSharedPreferences(any<String>(), Context.MODE_PRIVATE) } answers {
prefsMap[firstArg<String>()] ?: TestSharedPreferences()
}
return context
}
}

View file

@ -0,0 +1,271 @@
package com.aryan.reader.epubreader
import androidx.compose.ui.text.buildAnnotatedString
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchState
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.Page
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class EpubReaderSearchTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `search scans existing chapter files case-insensitively and skips missing chapters`() = runTest {
val root = temp.newFolder("book")
writeChapter(root, "chapter1.xhtml", "<html><body><p>Alpha needle.</p></body></html>")
writeChapter(root, "chapter2.xhtml", "<html><body><p>Needle one.</p><p>needle two.</p></body></html>")
val book = epubBook(
root = root,
chapters = listOf(
chapter("ch1", "One", "chapter1.xhtml"),
chapter("missing", "Missing", "missing.xhtml"),
chapter("ch2", "Two", "chapter2.xhtml")
)
)
val results = createEpubSearcher(book)("NEEDLE")
assertEquals(listOf("One", "Two", "Two"), results.map { it.locationTitle })
assertEquals(listOf(0, 2, 2), results.map { it.locationInSource })
assertEquals(listOf(0, 0, 1), results.map { it.occurrenceIndexInLocation })
assertTrue(results.all { it.query == "NEEDLE" })
}
@Test
fun `search records chunk index after body children are chunked in groups of twenty`() = runTest {
val root = temp.newFolder("chunked")
val paragraphs = (1..25).joinToString("") { index ->
if (index == 22) "<p>late target appears here</p>" else "<p>filler $index</p>"
}
writeChapter(root, "chapter.xhtml", "<html><body>$paragraphs</body></html>")
val book = epubBook(root, listOf(chapter("ch1", "Chunky", "chapter.xhtml")))
val result = createEpubSearcher(book)("target").single()
assertEquals(1, result.chunkIndex)
assertEquals(0, result.occurrenceIndexInLocation)
assertEquals("Chunky", result.locationTitle)
}
@Test
fun `search currently requires only a word start and highlights the matched substring`() = runTest {
val root = temp.newFolder("word-start")
writeChapter(root, "chapter.xhtml", "<html><body><p>cart art artist</p></body></html>")
val book = epubBook(root, listOf(chapter("ch1", "Words", "chapter.xhtml")))
val results = createEpubSearcher(book)("art")
assertEquals(2, results.size)
assertEquals(listOf("art", "art"), results.map { result ->
val style = result.snippet.spanStyles.single()
result.snippet.substring(style.start, style.end)
})
}
@Test
fun `vertical navigation changes chapter when needed and scrolls in place when chunk is loaded`() {
val result = searchResult(chapter = 1, chunk = 2, occurrence = 3)
val searchState = SearchState(CoroutineScope(UnconfinedTestDispatcher())) { emptyList() }.apply {
searchResults = listOf(result)
}
val webView = mockk<android.webkit.WebView>(relaxed = true)
val chapterChanges = mutableListOf<Triple<Int, Int, SearchResult>>()
val inPlaceScrolls = mutableListOf<SearchResult>()
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 0,
loadedChunkCount = 10,
webView = webView,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { chapterIndex, chunkIndex, navResult ->
chapterChanges.add(Triple(chapterIndex, chunkIndex, navResult))
},
onVerticalScrollToResult = { inPlaceScrolls.add(it) },
onPaginatedScrollToPage = {}
)
assertEquals(listOf(Triple(1, 2, result)), chapterChanges)
assertTrue(inPlaceScrolls.isEmpty())
assertEquals(0, searchState.currentSearchResultIndex)
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 1,
loadedChunkCount = 3,
webView = webView,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { chapterIndex, chunkIndex, navResult ->
chapterChanges.add(Triple(chapterIndex, chunkIndex, navResult))
},
onVerticalScrollToResult = { inPlaceScrolls.add(it) },
onPaginatedScrollToPage = {}
)
assertEquals(listOf(result), inPlaceScrolls)
verify { webView.evaluateJavascript("javascript:window.scrollToOccurrence(3);", null) }
}
@Test
fun `vertical navigation reloads same chapter when target chunk has not been loaded`() {
val result = searchResult(chapter = 0, chunk = 5, occurrence = 0)
val searchState = SearchState(CoroutineScope(UnconfinedTestDispatcher())) { emptyList() }.apply {
searchResults = listOf(result)
}
val chapterChanges = mutableListOf<Pair<Int, Int>>()
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 0,
loadedChunkCount = 5,
webView = null,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { chapterIndex, chunkIndex, _ -> chapterChanges.add(chapterIndex to chunkIndex) },
onVerticalScrollToResult = {},
onPaginatedScrollToPage = {}
)
assertEquals(listOf(0 to 5), chapterChanges)
}
@Test
fun `paginated navigation asks paginator for target page and invokes suspend scroll callback`() = runTest {
val result = searchResult(chapter = 0, chunk = 0, occurrence = 0)
val searchState = SearchState(this) { emptyList() }.apply { searchResults = listOf(result) }
val paginator = FakePaginator(pageForResult = 42)
val pages = mutableListOf<Int>()
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.PAGINATED,
currentChapterIndex = 0,
loadedChunkCount = 0,
webView = null,
paginator = paginator,
coroutineScope = this,
onVerticalChapterChange = { _, _, _ -> error("Unexpected vertical navigation") },
onVerticalScrollToResult = { error("Unexpected vertical scroll") },
onPaginatedScrollToPage = { pages.add(it) }
)
advanceUntilIdle()
assertEquals(result, paginator.lastSearchResult)
assertEquals(listOf(42), pages)
}
@Test
fun `navigation ignores out of bounds search result index`() {
val searchState = SearchState(CoroutineScope(UnconfinedTestDispatcher())) { emptyList() }
var called = false
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 0,
loadedChunkCount = 0,
webView = null,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { _, _, _ -> called = true },
onVerticalScrollToResult = { called = true },
onPaginatedScrollToPage = { called = true }
)
assertTrue(!called)
assertEquals(-1, searchState.currentSearchResultIndex)
}
private fun writeChapter(root: java.io.File, relativePath: String, html: String) {
val file = java.io.File(root, relativePath)
file.parentFile?.mkdirs()
file.writeText(html)
}
private fun epubBook(root: java.io.File, chapters: List<EpubChapter>): EpubBook =
EpubBook(
fileName = "test.epub",
title = "Test",
author = "Author",
language = "en",
coverImage = null,
chapters = chapters,
extractionBasePath = root.absolutePath
)
private fun chapter(id: String, title: String, path: String): EpubChapter =
EpubChapter(
chapterId = id,
absPath = path,
title = title,
htmlFilePath = path,
plainTextContent = "",
htmlContent = ""
)
private fun searchResult(chapter: Int, chunk: Int, occurrence: Int): SearchResult =
SearchResult(
locationInSource = chapter,
locationTitle = "Chapter $chapter",
snippet = buildAnnotatedString { append("snippet") },
query = "needle",
occurrenceIndexInLocation = occurrence,
chunkIndex = chunk
)
private class FakePaginator(private val pageForResult: Int) : IPaginator {
var lastSearchResult: SearchResult? = null
override val totalPageCount: Int = 0
override val isLoading: Boolean = false
override val generation: Int = 0
override val pageShiftRequest: Flow<Int> = emptyFlow()
override fun getPageContent(pageIndex: Int): Page? = null
override fun getChapterPathForPage(pageIndex: Int): String? = null
override fun getPlainTextForChapter(chapterIndex: Int): String? = null
override fun navigateToHref(currentChapterAbsPath: String, href: String, onNavigationComplete: (pageIndex: Int) -> Unit) = Unit
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
lastSearchResult = result
onResult(pageForResult)
}
override fun findPageForAnchor(chapterIndex: Int, anchor: String?, onResult: (pageIndex: Int) -> Unit) = Unit
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit
override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
override fun getCfiForPage(pageIndex: Int): String? = null
override fun onUserScrolledTo(pageIndex: Int) = Unit
override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String? = null
}
}

View file

@ -0,0 +1,57 @@
package com.aryan.reader.epubreader
import android.content.SharedPreferences
internal class TestSharedPreferences(vararg initial: Pair<String, Any?>) : SharedPreferences {
private val values = initial.toMap().toMutableMap()
override fun getAll(): MutableMap<String, *> = values
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
val value = values[key] as? Set<*> ?: return defValues
return value.filterIsInstance<String>().toMutableSet()
}
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String?): Boolean = values.containsKey(key)
override fun edit(): SharedPreferences.Editor = Editor()
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
private inner class Editor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearRequested = false
override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value)
override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor =
applyPut(key, values?.toSet())
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value)
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value)
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value)
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value)
override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null)
override fun clear(): SharedPreferences.Editor {
clearRequested = true
return this
}
override fun commit(): Boolean {
flush()
return true
}
override fun apply() = flush()
private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor {
if (key != null) pending[key] = value
return this
}
private fun flush() {
if (clearRequested) values.clear()
pending.forEach { (key, value) ->
if (value == null) values.remove(key) else values[key] = value
}
}
}
}

View file

@ -0,0 +1,208 @@
package com.aryan.reader.opds
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class OpdsParserTest {
@Test
fun `parse OPDS 2 feed resolves links facets navigation publications and metadata`() {
val feed = OpdsParser().parse(
bodyString = """
{
"metadata": {"title": "Catalog"},
"links": [
{"rel": "next", "href": "page/2"},
{"rel": ["search"], "href": "search{?query}"}
],
"facets": [
{
"metadata": {"title": "Format"},
"links": [
{"title": "EPUB", "href": "?format=epub", "properties": {"active": true}}
]
}
],
"navigation": [
{"title": "Authors", "href": "../authors", "description": "Browse authors"}
],
"publications": [
{
"metadata": {
"identifier": "pub-1",
"title": "Example Book",
"description": "Long summary",
"author": [{"name": "Ada Writer", "links": [{"href": "/authors/ada"}]}],
"language": "en",
"publisher": "Example Press",
"published": "2026-01-02",
"subject": [{"name": "Fiction"}],
"belongsTo": {"series": {"name": "Series", "position": 2}}
},
"images": [
{"href": "images/thumb.jpg"},
{"rel": "cover", "href": "images/cover.jpg"}
],
"links": [
{
"rel": "http://opds-spec.org/acquisition",
"href": "downloads/book.epub",
"type": "application/epub+zip"
},
{
"rel": ["http://vaemendis.net/opds-pse/stream"],
"href": "stream/{page}",
"properties": {"numberOfItems": 12}
}
]
}
]
}
""".trimIndent(),
baseUrl = "https://example.org/opds/catalog/index.json"
)
assertEquals("Catalog", feed.title)
assertEquals("https://example.org/opds/catalog/page/2", feed.nextUrl)
assertEquals("https://example.org/opds/catalog/search{?query}", feed.searchUrl)
assertEquals(OpdsFacet("EPUB", "Format", "https://example.org/opds/catalog/?format=epub", true), feed.facets.single())
val navigation = feed.entries.first { it.isNavigation }
assertEquals("Authors", navigation.title)
assertEquals("https://example.org/opds/authors", navigation.navigationUrl)
val publication = feed.entries.first { it.isAcquisition }
assertEquals("pub-1", publication.id)
assertEquals("Example Book", publication.title)
assertEquals("Ada Writer", publication.author)
assertEquals("https://example.org/authors/ada", publication.authors.single().url)
assertEquals("Long summary", publication.summary)
assertEquals("https://example.org/opds/catalog/images/cover.jpg", publication.coverUrl)
assertEquals("Example Press", publication.publisher)
assertEquals("2026-01-02", publication.published)
assertEquals("en", publication.language)
assertEquals("Series", publication.series)
assertEquals("2", publication.seriesIndex)
assertEquals(listOf("Fiction"), publication.categories)
assertEquals("https://example.org/opds/catalog/downloads/book.epub", publication.bestAcquisition?.url)
assertEquals("EPUB", publication.bestAcquisition?.formatName)
assertEquals(12, publication.pseCount)
assertEquals("https://example.org/opds/catalog/stream/{page}", publication.pseUrlTemplate)
assertTrue(publication.isStreamable)
}
@Test
fun `parse OPDS 1 feed extracts catalog links entry metadata acquisitions and stream info`() {
val feed = OpdsParser().parse(
bodyString = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:opds="http://opds-spec.org/2010/catalog"
xmlns:pse="http://vaemendis.net/opds-pse/ns">
<title>XML Catalog</title>
<link rel="next" href="next.xml" />
<link rel="search" href="/search.xml" />
<link rel="facet" title="English" href="?lang=en" opds:facetGroup="Language" opds:activeFacet="true" />
<entry>
<id>xml-1</id>
<title>XML Book</title>
<summary>Summary text</summary>
<author>
<name>XML Author</name>
<uri>/people/xml-author</uri>
</author>
<publisher>XML Press</publisher>
<language>en</language>
<published>2025-12-31</published>
<category term="fiction" label="Fiction" />
<meta property="calibre:series">XML Series</meta>
<meta property="calibre:series_index">3</meta>
<link rel="http://opds-spec.org/image/thumbnail" href="thumb.jpg" />
<link rel="http://opds-spec.org/image" href="cover.jpg" />
<link rel="http://opds-spec.org/acquisition" type="application/pdf" href="book.pdf" />
<link rel="http://vaemendis.net/opds-pse/stream" href="stream/{page}" pse:count="8" />
</entry>
</feed>
""".trimIndent(),
baseUrl = "https://example.org/root/feed.xml"
)
assertEquals("XML Catalog", feed.title)
assertEquals("https://example.org/root/next.xml", feed.nextUrl)
assertEquals("https://example.org/search.xml", feed.searchUrl)
assertEquals(OpdsFacet("English", "Language", "https://example.org/root/?lang=en", true), feed.facets.single())
val entry = feed.entries.single()
assertEquals("xml-1", entry.id)
assertEquals("XML Book", entry.title)
assertEquals("Summary text", entry.summary)
assertEquals(OpdsAuthor("XML Author", "https://example.org/people/xml-author"), entry.authors.single())
assertEquals("https://example.org/root/thumb.jpg", entry.coverUrl)
assertEquals("XML Press", entry.publisher)
assertEquals("2025-12-31", entry.published)
assertEquals("en", entry.language)
assertEquals("XML Series", entry.series)
assertEquals("3", entry.seriesIndex)
assertEquals(listOf("Fiction"), entry.categories)
assertEquals(OpdsAcquisition("https://example.org/root/book.pdf", "application/pdf"), entry.acquisitions.single())
assertEquals(8, entry.pseCount)
assertEquals("https://example.org/root/stream/{page}", entry.pseUrlTemplate)
}
@Test
fun `parse OPDS 2 groups and fallback metadata produce navigation entries`() {
val feed = OpdsParser().parse(
bodyString = """
{
"groups": [
{
"metadata": {"title": "Group Title"},
"links": [{"href": "group-feed"}],
"navigation": [{"title": "Nested Nav", "href": "nested"}],
"publications": [{"links": [], "metadata": {"title": "No Identifier"}}]
}
]
}
""".trimIndent(),
baseUrl = "https://example.org/catalog/"
)
assertEquals("OPDS 2.0 Feed", feed.title)
assertEquals("Nested Nav", feed.entries[0].title)
assertEquals("https://example.org/catalog/nested", feed.entries[0].navigationUrl)
assertEquals("Group Title", feed.entries[2].title)
assertEquals("https://example.org/catalog/group-feed", feed.entries[2].navigationUrl)
assertEquals("No Identifier", feed.entries[1].title)
assertFalse(feed.entries[1].isAcquisition)
assertNull(feed.entries[1].bestAcquisition)
}
@Test
fun `acquisition format names and priority prefer richer reader formats`() {
val acquisitions = listOf(
OpdsAcquisition("txt", "text/plain"),
OpdsAcquisition("pdf", "application/pdf"),
OpdsAcquisition("epub", "application/epub+zip"),
OpdsAcquisition("unknown", "application/octet-stream")
)
val entry = OpdsEntry(
id = "id",
title = "Book",
summary = null,
coverUrl = null,
acquisitions = acquisitions,
navigationUrl = null
)
assertEquals("EPUB", acquisitions[2].formatName)
assertEquals("TXT", acquisitions[0].formatName)
assertEquals("OCTET-STREAM", acquisitions[3].formatName)
assertEquals(acquisitions[2], entry.bestAcquisition)
}
}

View file

@ -0,0 +1,134 @@
package com.aryan.reader.opds
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class OpdsRepositoryTest {
@Test
fun `getCatalogs seeds default catalogs only once`() {
val repository = repositoryWithFreshPrefs()
val first = repository.getCatalogs()
val second = repository.getCatalogs()
assertEquals(2, first.size)
assertEquals(first, second)
assertTrue(first.all { it.isDefault })
assertTrue(first.any { it.title == "Project Gutenberg" })
assertTrue(first.any { it.title == "Standard Ebooks" })
}
@Test
fun `add update and remove catalog preserve defaults and trim editable credentials`() {
val repository = repositoryWithFreshPrefs()
repository.getCatalogs()
repository.addCatalog(" Custom ", " https://example.org/opds ", " user ", " pass ")
val added = repository.getCatalogs().single { !it.isDefault }
repository.updateCatalog(
id = added.id,
title = " Updated ",
url = " https://example.org/new ",
username = " ",
password = " token "
)
val updated = repository.getCatalogs().single { !it.isDefault }
assertEquals("Updated", updated.title)
assertEquals("https://example.org/new", updated.url)
assertNull(updated.username)
assertEquals("token", updated.password)
val defaultId = repository.getCatalogs().first { it.isDefault }.id
repository.removeCatalog(defaultId)
assertEquals(3, repository.getCatalogs().size)
repository.removeCatalog(updated.id)
assertTrue(repository.getCatalogs().all { it.isDefault })
}
@Test
fun `basic authenticator adds authorization once and ignores missing credentials`() {
val request = Request.Builder().url("https://example.org/feed").build()
val response = responseFor(request, "Basic realm=\"Catalog\"")
val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, response)
val missingCredentials = OpdsRepository.OpdsAuthenticator("", "pass")
.authenticate(null, response)
val alreadyAuthorized = OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, responseFor(request.newBuilder().header("Authorization", "old").build(), "Basic"))
assertEquals("Basic dXNlcjpwYXNz", authenticated?.header("Authorization"))
assertNull(missingCredentials)
assertNull(alreadyAuthorized)
}
@Test
fun `digest authenticator builds digest header with qop opaque and request uri`() {
val request = Request.Builder()
.url("https://example.org/catalog/feed?x=1")
.build()
val response = responseFor(
request,
"Digest realm=\"realm\", nonce=\"abc\", qop=\"auth\", opaque=\"opaque-token\""
)
val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, response)
val header = authenticated?.header("Authorization").orEmpty()
assertTrue(header.startsWith("Digest "))
assertTrue(header.contains("""username="user""""))
assertTrue(header.contains("""realm="realm""""))
assertTrue(header.contains("""nonce="abc""""))
assertTrue(header.contains("""uri="/catalog/feed?x=1""""))
assertTrue(header.contains("qop=auth"))
assertTrue(header.contains("nc=00000001"))
assertTrue(header.contains("""cnonce=""""))
assertTrue(header.contains("""opaque="opaque-token""""))
assertNotNull(Regex("""response="[a-f0-9]{32}"""").find(header))
}
@Test
fun `authenticator ignores unsupported challenge`() {
val request = Request.Builder().url("https://example.org/feed").build()
assertNull(
OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, responseFor(request, "Bearer realm=\"x\""))
)
}
private fun repositoryWithFreshPrefs(): OpdsRepository {
val context = RuntimeEnvironment.getApplication()
context.getSharedPreferences("reader_opds_prefs", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
return OpdsRepository(context)
}
private fun responseFor(request: Request, challenge: String): Response {
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(401)
.message("Unauthorized")
.header("WWW-Authenticate", challenge)
.body("".toResponseBody(null))
.build()
}
}

View file

@ -0,0 +1,30 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CfiUtilsTest {
@Test
fun `getPath strips offsets while preserving full cfi path`() {
assertEquals("/4/2/6", CfiUtils.getPath("/4/2/6:13"))
assertEquals("/4/2/6", CfiUtils.getPath("/4/2/6"))
}
@Test
fun `getOffset parses valid offsets and defaults invalid or missing offsets to zero`() {
assertEquals(13, CfiUtils.getOffset("/4/2/6:13"))
assertEquals(0, CfiUtils.getOffset("/4/2/6:0"))
assertEquals(0, CfiUtils.getOffset("/4/2/6"))
assertEquals(0, CfiUtils.getOffset("/4/2/6:bad"))
}
@Test
fun `compare sorts numeric cfi paths before character offsets`() {
assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0)
assertTrue(CfiUtils.compare("/4/2/6", "/4/2/6/2") < 0)
assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0)
assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6"))
}
}

View file

@ -0,0 +1,201 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ContentStylerTest {
@Test
fun `paragraph styling applies user text alignment and preserves cfi metadata`() {
val styler = styler(userTextAlign = TextAlign.Justify)
val block = styler.style(
listOf(
SemanticParagraph(
text = "Aligned text",
spans = emptyList(),
style = CssStyle(),
elementId = "p1",
cfi = "/4/2",
startCharOffsetInSource = 7,
blockIndex = 10
)
)
).single() as ParagraphBlock
assertEquals(TextAlign.Justify, block.textAlign)
assertEquals("p1", block.elementId)
assertEquals("/4/2", block.cfi)
assertEquals(7, block.startCharOffsetInSource)
assertEquals(10, block.blockIndex)
assertEquals("Aligned text", block.content.text)
}
@Test
fun `floating image is grouped with following paragraphs until clear`() {
val blocks = styler().style(
listOf(
SemanticImage(
path = "image.png",
altText = "Cover",
intrinsicWidth = 120f,
intrinsicHeight = 200f,
style = CssStyle(blockStyle = BlockStyle(float = "left")),
elementId = "img",
cfi = "/4/4",
blockIndex = 1
),
paragraph("Wrapped one", blockIndex = 2),
paragraph("Wrapped two", blockIndex = 3),
paragraph(
"After clear",
blockIndex = 4,
style = CssStyle(blockStyle = BlockStyle(clear = "left"))
)
)
)
val wrapping = blocks[0] as WrappingContentBlock
assertEquals("image.png", wrapping.floatedImage.path)
assertEquals(listOf("Wrapped one", "Wrapped two"), wrapping.paragraphsToWrap.map { it.content.text })
assertEquals("After clear", (blocks[1] as ParagraphBlock).content.text)
}
@Test
fun `ordered list items receive decimal markers and nested text styles`() {
val list = SemanticList(
items = listOf(
SemanticListItem(
text = "First",
spans = listOf(
SemanticSpan(
start = 0,
end = 5,
style = CssStyle(spanStyle = SpanStyle(color = Color.Red)),
tag = "span",
linkHref = "https://example.org",
elementId = "link"
)
),
style = CssStyle(),
elementId = "li1",
cfi = "/4/2/2",
startCharOffsetInSource = 0,
itemMarkerImage = null,
blockIndex = 11
),
SemanticListItem(
text = "Second",
spans = emptyList(),
style = CssStyle(),
elementId = "li2",
cfi = "/4/2/4",
startCharOffsetInSource = 6,
itemMarkerImage = null,
blockIndex = 12
)
),
isOrdered = true,
style = CssStyle(blockStyle = BlockStyle(listStyleType = "decimal-leading-zero")),
elementId = "list",
cfi = "/4/2",
blockIndex = 10
)
val flex = styler().style(listOf(list)).single() as FlexContainerBlock
val first = flex.children[0] as ListItemBlock
val second = flex.children[1] as ListItemBlock
assertEquals("01. ", first.itemMarker)
assertEquals("02. ", second.itemMarker)
assertEquals("li1", first.elementId)
assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item)
assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item)
}
@Test
fun `math svg is themed and external images are embedded when resolvable`() {
val root = kotlin.io.path.createTempDirectory("content-styler-svg").toFile()
val chapterDir = java.io.File(root, "chapters").apply { mkdirs() }
val image = java.io.File(chapterDir, "pixel.png")
image.writeBytes(
java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
)
val styler = styler(
extractionBasePath = root.absolutePath,
chapterAbsPath = "chapters/chapter.xhtml",
baseTextStyle = TextStyle(fontSize = 16.sp, color = Color.Black)
)
val math = styler.style(
listOf(
SemanticMath(
svgContent = """<svg><text fill="#fff">x</text><image href="pixel.png"/></svg>""",
altText = "x",
svgWidth = null,
svgHeight = null,
svgViewBox = null,
isFromMathJax = false,
style = CssStyle(),
elementId = null,
cfi = "/math",
blockIndex = 1
)
)
).single() as MathBlock
val svgContent = math.svgContent!!
assertTrue(svgContent.contains("fill:#000000"))
assertTrue(svgContent.contains("data:image/png;base64,"))
}
private fun paragraph(
text: String,
blockIndex: Int,
style: CssStyle = CssStyle()
): SemanticParagraph {
return SemanticParagraph(
text = text,
spans = emptyList(),
style = style,
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = blockIndex
)
}
private fun styler(
userTextAlign: TextAlign? = null,
extractionBasePath: String = "",
chapterAbsPath: String = "chapter.xhtml",
baseTextStyle: TextStyle = TextStyle(fontSize = 16.sp, color = Color.Black)
): ContentStyler {
return ContentStyler(
baseTextStyle = baseTextStyle,
fontFamilyMap = emptyMap(),
density = Density(1f),
isDarkTheme = false,
themeBackgroundColor = Color.White,
themeTextColor = Color.Black,
chapterAbsPath = chapterAbsPath,
extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign,
paragraphGapMultiplier = 1f
)
}
}

View file

@ -0,0 +1,51 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Constraints
import org.junit.Assert.assertEquals
import org.junit.Test
class CssParserThemeModeTest {
@Test
fun parseCanPreserveRawPaintColorsForLayoutCaches() {
val result = CssParser.parse(
cssContent = "p { color: #000000; background-color: #ffffff; border-top-width: 1px; border-top-style: solid; border-top-color: #000000; }",
cssPath = null,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 400, maxHeight = 800),
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White,
adaptThemeColors = false
)
val style = result.rules.byTag.getValue("p").single().style
assertEquals(Color.Black, style.spanStyle.color)
assertEquals(Color.White, style.blockStyle.backgroundColor)
assertEquals(Color.Black, style.blockStyle.borderTop?.color)
}
@Test
fun parseStillAdaptsPaintColorsWhenThemeModeIsEnabled() {
val result = CssParser.parse(
cssContent = "p { color: #000000; background-color: #ffffff; border-top-width: 1px; border-top-style: solid; border-top-color: #000000; }",
cssPath = null,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 400, maxHeight = 800),
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White,
adaptThemeColors = true
)
val style = result.rules.byTag.getValue("p").single().style
assertEquals(Color.White, style.spanStyle.color)
assertEquals(Color.Transparent, style.blockStyle.backgroundColor)
assertEquals(Color.White, style.blockStyle.borderTop?.color)
}
}

View file

@ -0,0 +1,277 @@
package com.aryan.reader.paginatedreader
import android.content.Context
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.data.AnchorIndexEntry
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.ConfigurationCache
import com.aryan.reader.paginatedreader.data.PageCacheChunk
import com.aryan.reader.paginatedreader.data.PageCacheMetadata
import com.aryan.reader.paginatedreader.data.PageIndexEntry
import com.aryan.reader.paginatedreader.data.ProcessedBook
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import com.aryan.reader.paginatedreader.data.ProcessedChapterChunk
import com.aryan.reader.paginatedreader.data.ProcessedChapterMetadata
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalSerializationApi::class)
class LocatorConverterTest {
private val proto = ProtoBuf {
serializersModule = semanticBlockModule
}
@Test
fun `getLocatorFromCfi resolves best cached semantic block and preserves character offset`() = runTest {
val blocks = semanticBlocks()
val converter = converterFor(blocks)
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:13")
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 13), locator)
}
@Test
fun `zero estimate semantic cache remains usable`() = runTest {
val converter = converterFor(semanticBlocks(), estimatedPageCount = 0)
val locator = converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2/6:7")
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 7), locator)
}
@Test
fun `stable book id is used for locator cache lookups`() = runTest {
val chapter = ProcessedChapter(
bookId = "stable-book-id",
chapterIndex = 0,
contentBlocksProto = proto.encodeToByteArray(semanticBlocks()),
estimatedPageCount = 1
)
val dao = FakeBookCacheDao(chapter)
val converter = LocatorConverter(dao, proto, mockk<Context>(relaxed = true), stableBookId = "stable-book-id")
converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2")
assertEquals("stable-book-id", dao.requestedBookIds.single())
}
@Test
fun `cfi locator cfi round trip preserves exact block and offset across reader modes`() = runTest {
val converter = converterFor(
listOf(
paragraph("Outer text", blockIndex = 10, cfi = "/4/2"),
paragraph("Nested candidate", blockIndex = 11, cfi = "/4/2/6"),
paragraph("Deep exact candidate", blockIndex = 12, cfi = "/4/2/6/10")
)
)
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6/10:19")
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
assertEquals(Locator(chapterIndex = 0, blockIndex = 12, charOffset = 19), locator)
assertEquals("/4/2/6/10:19", cfi)
}
@Test
fun `zero offset cfi round trip canonicalizes to base path without losing locator`() = runTest {
val converter = converterFor(semanticBlocks())
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:0")
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 0), locator)
assertEquals("/4/2/6", cfi)
}
@Test
fun `malformed cfi offset is treated as block start and remains restorable`() = runTest {
val converter = converterFor(semanticBlocks())
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2:not-a-number")
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
assertEquals(Locator(chapterIndex = 0, blockIndex = 1, charOffset = 0), locator)
assertEquals("/4/2", cfi)
}
@Test
fun `getCfiFromLocator finds nested block and appends positive offset`() = runTest {
val converter = converterFor(semanticBlocks())
assertEquals(
"/6/4:8",
converter.getCfiFromLocator(book(), Locator(chapterIndex = 0, blockIndex = 3, charOffset = 8))
)
assertEquals(
"/4/2",
converter.getCfiFromLocator(book(), Locator(chapterIndex = 0, blockIndex = 1, charOffset = 0))
)
assertNull(converter.getCfiFromLocator(book(), Locator(chapterIndex = 0, blockIndex = 404, charOffset = 0)))
}
@Test
fun `getTextOffset sums preceding text blocks including nested containers`() = runTest {
val converter = converterFor(semanticBlocks())
val offset = converter.getTextOffset(book(), Locator(chapterIndex = 0, blockIndex = 3, charOffset = 4))
assertEquals("First paragraph".length + 1 + "Second paragraph".length + 1 + 4, offset)
}
@Test
fun `getTtsChunksForChapter traverses cached semantic text blocks with source cfi`() = runTest {
val converter = converterFor(
listOf(
paragraph("First sentence. Second sentence.", blockIndex = 1, cfi = "/4/2", offset = 5),
SemanticFlexContainer(
children = listOf(paragraph("Nested text.", blockIndex = 2, cfi = "/6/2", offset = 40)),
style = CssStyle(),
elementId = null,
cfi = null,
blockIndex = 10
)
)
)
val chunks = converter.getTtsChunksForChapter(book(), chapterIndex = 0)!!
assertTrue(chunks.isNotEmpty())
assertEquals("/4/2", chunks.first().sourceCfi)
assertEquals(5, chunks.first().startOffsetInSource)
assertTrue(chunks.any { it.text.contains("Nested text") && it.sourceCfi == "/6/2" })
}
@Test
fun `invalid cached proto returns null instead of processing when chapter has no html`() = runTest {
val dao = FakeBookCacheDao(
ProcessedChapter(
bookId = "Book",
chapterIndex = 0,
contentBlocksProto = byteArrayOf(1, 2, 3),
estimatedPageCount = 1
)
)
val converter = LocatorConverter(dao, proto, mockk<Context>(relaxed = true))
assertNull(converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2"))
}
private fun converterFor(blocks: List<SemanticBlock>, estimatedPageCount: Int = 1): LocatorConverter {
val chapter = ProcessedChapter(
bookId = "Book",
chapterIndex = 0,
contentBlocksProto = proto.encodeToByteArray(blocks),
estimatedPageCount = estimatedPageCount
)
return LocatorConverter(FakeBookCacheDao(chapter), proto, mockk<Context>(relaxed = true))
}
private fun semanticBlocks(): List<SemanticBlock> {
return listOf(
paragraph("First paragraph", blockIndex = 1, cfi = "/4/2"),
paragraph("Second paragraph", blockIndex = 2, cfi = "/4/2/6"),
SemanticFlexContainer(
children = listOf(paragraph("Nested paragraph", blockIndex = 3, cfi = "/6/4")),
style = CssStyle(),
elementId = null,
cfi = null,
blockIndex = 20
)
)
}
private fun paragraph(
text: String,
blockIndex: Int,
cfi: String,
offset: Int = 0
): SemanticParagraph {
return SemanticParagraph(
text = text,
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = cfi,
startCharOffsetInSource = offset,
blockIndex = blockIndex
)
}
private fun book(): EpubBook {
return EpubBook(
fileName = "book.epub",
title = "Book",
author = "Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "c1",
absPath = "c1.xhtml",
title = "Chapter",
htmlFilePath = "c1.xhtml",
plainTextContent = "",
htmlContent = ""
)
),
extractionBasePath = ""
)
}
private class FakeBookCacheDao(
private val chapter: ProcessedChapter?
) : BookCacheDao() {
val requestedBookIds = mutableListOf<String>()
override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
requestedBookIds += bookId
return chapter
}
override suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) = Unit
override suspend fun getProcessedBook(bookId: String): ProcessedBook? = null
override suspend fun insertProcessedBook(book: ProcessedBook) = Unit
override suspend fun deleteBook(bookId: String) = Unit
override suspend fun clearProcessedBooks() = Unit
override suspend fun insertAnchorIndices(anchors: List<AnchorIndexEntry>) = Unit
override suspend fun getAnchorIndex(bookId: String, anchorId: String): AnchorIndexEntry? = null
override suspend fun deleteAnchorsForBook(bookId: String) = Unit
override suspend fun deleteConfigurationCacheForBook(bookId: String) = Unit
override suspend fun clearAnchors() = Unit
override suspend fun clearConfigurationCache() = Unit
override suspend fun getConfigurationCache(bookId: String, configHash: Int): ConfigurationCache? = null
override suspend fun insertConfigurationCache(cache: ConfigurationCache) = Unit
override suspend fun cleanupOldConfigurations(bookId: String) = Unit
override suspend fun insertPageIndexEntries(entries: List<PageIndexEntry>) = Unit
override suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List<PageIndexEntry> = emptyList()
override suspend fun cleanupOldPageCaches(bookId: String) = Unit
protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null
protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray> = emptyList()
protected override suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) = Unit
protected override suspend fun insertChapterChunks(chunks: List<ProcessedChapterChunk>) = Unit
protected override suspend fun deleteChapterMetadataForBook(bookId: String) = Unit
protected override suspend fun deleteAllChapterMetadata() = Unit
protected override suspend fun deletePageCacheMetadataForBook(bookId: String) = Unit
protected override suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int) = Unit
protected override suspend fun clearPageCacheMetadata() = Unit
protected override suspend fun getPageCacheMetadata(bookId: String, configHash: Int, chapterIndex: Int): PageCacheMetadata? = null
protected override suspend fun getPageCacheChunks(bookId: String, configHash: Int, chapterIndex: Int): List<ByteArray> = emptyList()
protected override suspend fun insertPageCacheMetadata(metadata: PageCacheMetadata) = Unit
protected override suspend fun insertPageCacheChunks(chunks: List<PageCacheChunk>) = Unit
}
}

View file

@ -0,0 +1,81 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.aryan.reader.epub.EpubChapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PageCountEstimatorTest {
@Test
fun `estimateChapterPageCount always returns at least one page`() {
assertEquals(
1,
PageCountEstimator.estimateChapterPageCount(
chapter = chapter(html = ""),
constraints = Constraints(maxWidth = 0, maxHeight = 0),
textStyle = TextStyle(fontSize = 16.sp),
density = Density(1f)
)
)
}
@Test
fun `estimateChapterPageCount increases as visible content grows`() {
val constraints = Constraints(maxWidth = 400, maxHeight = 600)
val style = TextStyle(fontSize = 16.sp)
val density = Density(1f)
val short = PageCountEstimator.estimateChapterPageCount(
chapter = chapter(html = "a".repeat(400)),
constraints = constraints,
textStyle = style,
density = density
)
val long = PageCountEstimator.estimateChapterPageCount(
chapter = chapter(html = "a".repeat(40_000)),
constraints = constraints,
textStyle = style,
density = density
)
assertTrue(long > short)
}
@Test
fun `larger font and line height estimate more pages`() {
val constraints = Constraints(maxWidth = 500, maxHeight = 700)
val density = Density(1f)
val content = chapter(html = "reader ".repeat(5000))
val compact = PageCountEstimator.estimateChapterPageCount(
chapter = content,
constraints = constraints,
textStyle = TextStyle(fontSize = 12.sp, lineHeight = 14.sp),
density = density
)
val large = PageCountEstimator.estimateChapterPageCount(
chapter = content,
constraints = constraints,
textStyle = TextStyle(fontSize = 24.sp, lineHeight = 32.sp),
density = density
)
assertTrue(large > compact)
}
private fun chapter(html: String): EpubChapter {
return EpubChapter(
chapterId = "chapter",
absPath = "chapter.xhtml",
title = "Chapter",
htmlFilePath = "chapter.xhtml",
plainTextContent = "",
htmlContent = html
)
}
}

View file

@ -0,0 +1,43 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PaginatedReconfigurationTest {
@Test
fun `visible page locator is preferred for reconfiguration restore`() {
val visiblePageLocator = Locator(chapterIndex = 2, blockIndex = 40, charOffset = 12)
val fallbackLocator = Locator(chapterIndex = 1, blockIndex = 10, charOffset = 3)
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = visiblePageLocator,
fallbackLocator = fallbackLocator
)
assertEquals(visiblePageLocator, anchor)
}
@Test
fun `last known locator is used when current page is temporarily unavailable`() {
val fallbackLocator = Locator(chapterIndex = 3, blockIndex = 90, charOffset = 24)
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = null,
fallbackLocator = fallbackLocator
)
assertEquals(fallbackLocator, anchor)
}
@Test
fun `missing page and fallback locators leave restore unset`() {
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = null,
fallbackLocator = null
)
assertNull(anchor)
}
}

View file

@ -0,0 +1,75 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Test
class RenderThemeApplierTest {
@Test
fun displayThemeRecolorsCachedPageWithoutMutatingSource() {
val source = Page(
content = listOf(
ParagraphBlock(
content = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Black)) {
append("Hello")
}
},
style = BlockStyle(
backgroundColor = Color.White,
borderTop = BorderStyle(width = 1.dp, color = Color.Black)
),
blockIndex = 1
)
)
)
val themed = source.applyReaderThemeForDisplay(
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White
)
val sourceParagraph = source.content.single() as ParagraphBlock
val themedParagraph = themed.content.single() as ParagraphBlock
assertEquals(Color.Black, sourceParagraph.content.spanStyles.single().item.color)
assertEquals(Color.White, themedParagraph.content.spanStyles.single().item.color)
assertEquals(Color.White, sourceParagraph.style.backgroundColor)
assertEquals(Color.Transparent, themedParagraph.style.backgroundColor)
assertEquals(Color.Black, sourceParagraph.style.borderTop?.color)
assertEquals(Color.White, themedParagraph.style.borderTop?.color)
}
@Test
fun displayThemeRecolorsCustomUnderlineAnnotations() {
val underlineColor = Color.Black.value.toString()
val source = Page(
content = listOf(
ParagraphBlock(
content = buildAnnotatedString {
append("Hello")
addStringAnnotation("CustomUnderline", "solid|$underlineColor|0", 0, 5)
},
blockIndex = 1
)
)
)
val themed = source.applyReaderThemeForDisplay(
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White
)
val themedParagraph = themed.content.single() as ParagraphBlock
val annotation = themedParagraph.content.getStringAnnotations("CustomUnderline", 0, 5).single()
assertEquals("solid|${Color.White.value}|0", annotation.item)
}
}

View file

@ -0,0 +1,167 @@
package com.aryan.reader.paginatedreader.data
import androidx.room.Room
import com.aryan.reader.paginatedreader.Page
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class BookCacheDaoTest {
private lateinit var db: BookCacheDatabase
private lateinit var dao: BookCacheDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
BookCacheDatabase::class.java
).allowMainThreadQueries().build()
dao = db.bookCacheDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun `processed chapters round trip empty and chunked proto payloads`() = runTest {
val largePayload = ByteArray(950 * 1024) { index -> (index % 251).toByte() }
val chapters = listOf(
ProcessedChapter(
bookId = "book",
chapterIndex = 0,
contentBlocksProto = ByteArray(0),
estimatedPageCount = 1
),
ProcessedChapter(
bookId = "book",
chapterIndex = 1,
contentBlocksProto = largePayload,
estimatedPageCount = 12
)
)
dao.insertProcessedChapters(chapters)
val empty = dao.getProcessedChapter("book", 0)!!
val large = dao.getProcessedChapter("book", 1)!!
assertEquals(1, empty.estimatedPageCount)
assertEquals(0, empty.contentBlocksProto.size)
assertEquals(12, large.estimatedPageCount)
assertArrayEquals(largePayload, large.contentBlocksProto)
}
@Test
fun `delete and clear operations remove book chapters anchors and configuration cache`() = runTest {
dao.insertProcessedBook(ProcessedBook("book", LATEST_PROCESSING_VERSION, 10))
dao.insertProcessedChapters(
listOf(ProcessedChapter("book", 0, byteArrayOf(1, 2, 3), estimatedPageCount = 2))
)
dao.insertAnchorIndices(listOf(AnchorIndexEntry("book", "anchor", 0, 99)))
dao.insertConfigurationCache(ConfigurationCache("book", configHash = 123, chapterPageCounts = "0:2"))
dao.insertPageCache(
PageCacheEntry(
bookId = "book",
configHash = 123,
chapterIndex = 0,
processingVersion = LATEST_PROCESSING_VERSION,
pageCacheVersion = LATEST_PAGE_CACHE_VERSION,
contentVersion = 456,
pageCount = 1,
pagesProto = byteArrayOf(9, 8, 7)
),
pageIndexEntries = listOf(
PageIndexEntry(
bookId = "book",
configHash = 123,
chapterIndex = 0,
pageInChapter = 0,
firstBlockIndex = 1,
lastBlockIndex = 2,
firstTextBlockIndex = 1,
firstTextCharOffset = 0,
firstTextEndOffset = 10,
firstCfi = "/4/2",
anchors = "anchor"
)
)
)
dao.deleteEntireBookCache("book")
assertNull(dao.getProcessedBook("book"))
assertNull(dao.getProcessedChapter("book", 0))
assertNull(dao.getAnchorIndex("book", "anchor"))
assertNull(dao.getConfigurationCache("book", 123))
assertNull(dao.getPageCache("book", 123, 0))
}
@Test
fun `configuration cleanup keeps only the three most recent hashes for a book`() = runTest {
(1..5).forEach { hash ->
dao.insertConfigurationCache(ConfigurationCache("book", hash, "0:$hash"))
}
dao.cleanupOldConfigurations("book")
assertNull(dao.getConfigurationCache("book", 1))
assertNull(dao.getConfigurationCache("book", 2))
assertEquals("0:3", dao.getConfigurationCache("book", 3)?.chapterPageCounts)
assertEquals("0:4", dao.getConfigurationCache("book", 4)?.chapterPageCounts)
assertEquals("0:5", dao.getConfigurationCache("book", 5)?.chapterPageCounts)
}
@OptIn(ExperimentalSerializationApi::class)
@Test
fun `page cache round trips chunked measured pages and page index entries`() = runTest {
val proto = ProtoBuf
val pagesProto = proto.encodeToByteArray(listOf(Page(content = emptyList())))
val largePayload = pagesProto + ByteArray(950 * 1024) { index -> (index % 127).toByte() }
val entry = PageCacheEntry(
bookId = "book",
configHash = 321,
chapterIndex = 2,
processingVersion = LATEST_PROCESSING_VERSION,
pageCacheVersion = LATEST_PAGE_CACHE_VERSION,
contentVersion = 654,
pageCount = 1,
pagesProto = largePayload
)
val indexEntry = PageIndexEntry(
bookId = "book",
configHash = 321,
chapterIndex = 2,
pageInChapter = 0,
firstBlockIndex = 4,
lastBlockIndex = 9,
firstTextBlockIndex = 4,
firstTextCharOffset = 12,
firstTextEndOffset = 80,
firstCfi = "/4/2",
anchors = "chapter-start"
)
dao.insertPageCache(entry, listOf(indexEntry))
val cached = dao.getPageCache("book", 321, 2)!!
val cachedIndex = dao.getPageIndexEntries("book", 321, 2)
assertEquals(LATEST_PAGE_CACHE_VERSION, cached.pageCacheVersion)
assertEquals(654, cached.contentVersion)
assertArrayEquals(largePayload, cached.pagesProto)
assertEquals(listOf(indexEntry), cachedIndex)
}
}

View file

@ -0,0 +1,229 @@
package com.aryan.reader.pdf
import android.graphics.RectF
import android.graphics.Rect
import com.aryan.reader.pdf.ocr.OcrBlock
import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine
import com.aryan.reader.pdf.ocr.OcrResult
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PdfReaderCoreLogicTest {
@Test
fun `generateShortId creates a four digit sync suffix`() {
repeat(100) {
val id = generateShortId()
assertTrue(id, id.matches(Regex("\\d{4}")))
assertTrue(id, id.toInt() in 1000..9998)
}
}
@Test
fun `resolveEraserStrokeWidth uses eraser size only for stylus override`() {
assertEquals(
0.08f,
resolveEraserStrokeWidth(
isEraserOverride = true,
activeToolThickness = 0.005f,
eraserToolThickness = 0.08f
),
0.0001f
)
assertEquals(
0.005f,
resolveEraserStrokeWidth(
isEraserOverride = false,
activeToolThickness = 0.005f,
eraserToolThickness = 0.08f
),
0.0001f
)
}
@Test
fun `getSuggestedFilename sanitizes truncates and marks annotated copies`() {
val filename = getSuggestedFilename(
originalName = "A very long odd @name with spaces and symbols that should be truncated eventually.pdf",
isAnnotated = true
)
assertTrue(filename, filename.matches(Regex("A_very_long_odd__name_with_spaces_and_symbols_that_annotated_\\d{4}\\.pdf")))
assertTrue(filename.length <= "A_very_long_odd__name_with_spaces_and_symbols_that_annotated_0000.pdf".length)
}
@Test
fun `getSuggestedFilename uses Document when original name is missing`() {
val filename = getSuggestedFilename(originalName = null, isAnnotated = false)
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
}
@Test
fun `preprocessTextForTts returns empty processed text for blank input`() {
val processed = preprocessTextForTts(" \n\t ")
assertEquals("", processed.cleanText)
assertEquals(emptyList<Int>(), processed.indexMap)
}
@Test
fun `preprocessTextForTts turns layout newlines inside sentences into spaces`() {
val processed = preprocessTextForTts("Hello\nworld\r\nagain")
assertEquals("Hello world again", processed.cleanText)
assertEquals(processed.cleanText.length, processed.indexMap.size)
assertEquals(5, processed.indexMap[5])
assertEquals(12, processed.indexMap[11])
}
@Test
fun `preprocessTextForTts keeps current punctuation newline behavior`() {
val processed = preprocessTextForTts("End.\nNext?\nLast!")
assertEquals("End.Next?Last!", processed.cleanText)
}
@Test
fun `mergeRectsIntoLines combines rectangles with vertical overlap and keeps separate lines sorted`() {
val rects = listOf(
Rect(40, 50, 60, 70),
Rect(10, 10, 20, 30),
Rect(22, 15, 35, 31),
Rect(8, 55, 30, 75)
)
val merged = mergeRectsIntoLines(rects)
assertEquals(
listOf(
Rect(10, 10, 35, 31),
Rect(8, 50, 60, 75)
),
merged
)
}
@Test
fun `mergeRectsIntoLines treats touching vertical edges as separate lines`() {
val merged = mergeRectsIntoLines(
listOf(
Rect(0, 0, 10, 10),
Rect(0, 10, 10, 20)
)
)
assertEquals(listOf(Rect(0, 0, 10, 10), Rect(0, 10, 10, 20)), merged)
}
@Test
fun `mergePdfRectsIntoLines normalizes inverted pdf rects and merges slight line overlap`() {
val merged = mergePdfRectsIntoLines(
listOf(
RectF(0f, 100f, 20f, 90f),
RectF(22f, 99f, 40f, 91f),
RectF(0f, 70f, 10f, 60f)
)
)
assertEquals(2, merged.size)
assertRectFEquals(RectF(0f, 100f, 40f, 90f), merged[0])
assertRectFEquals(RectF(0f, 70f, 10f, 60f), merged[1])
}
@Test
fun `findRectsForTextChunkInOcrVisual matches words case-insensitively across elements`() {
val result = ocrResult(
OcrElement("The", Rect(0, 0, 10, 10), emptyList()),
OcrElement("Quick", Rect(12, 0, 30, 10), emptyList()),
OcrElement("Brown.", Rect(32, 0, 55, 10), emptyList()),
OcrElement("Fox", Rect(57, 0, 70, 10), emptyList())
)
val rects = findRectsForTextChunkInOcrVisual(result, "quick brown")
assertEquals(listOf(Rect(12, 0, 30, 10), Rect(32, 0, 55, 10)), rects)
}
@Test
fun `findRectsForTextChunkInOcrVisual returns empty for blank text missing words and missing OCR elements`() {
val result = ocrResult(OcrElement("Only", Rect(0, 0, 10, 10), emptyList()))
assertEquals(emptyList<Rect>(), findRectsForTextChunkInOcrVisual(result, ""))
assertEquals(emptyList<Rect>(), findRectsForTextChunkInOcrVisual(result, "missing"))
assertEquals(emptyList<Rect>(), findRectsForTextChunkInOcrVisual(OcrResult("", emptyList()), "Only"))
}
@Test
fun `findWordBoundaries expands from middle of word across letters and digits`() = runTest {
val textPage = FakeReaderTextPage("Start A1b2 end")
val bounds = findWordBoundaries(textPage, initialCharIndex = 8, pageCharCount = textPage.source.length)
assertEquals(6 to 10, bounds)
}
@Test
fun `findWordBoundaries stops at punctuation boundaries`() = runTest {
val textPage = FakeReaderTextPage("can't stop")
val leftSide = findWordBoundaries(textPage, initialCharIndex = 2, pageCharCount = textPage.source.length)
val rightSide = findWordBoundaries(textPage, initialCharIndex = 4, pageCharCount = textPage.source.length)
assertEquals(0 to 3, leftSide)
assertEquals(4 to 5, rightSide)
}
@Test
fun `findWordBoundaries returns null for punctuation and out of bounds selection`() = runTest {
val textPage = FakeReaderTextPage("word.")
assertNull(findWordBoundaries(textPage, initialCharIndex = 4, pageCharCount = textPage.source.length))
assertNull(findWordBoundaries(textPage, initialCharIndex = -1, pageCharCount = textPage.source.length))
assertNull(findWordBoundaries(textPage, initialCharIndex = 5, pageCharCount = textPage.source.length))
}
private class FakeReaderTextPage(val source: String) : ReaderTextPage {
override suspend fun textPageCountChars(): Int = source.length
override suspend fun textPageGetText(startIndex: Int, count: Int): String? =
source.substring(startIndex, (startIndex + count).coerceAtMost(source.length))
override suspend fun textPageGetRectsForRanges(ranges: IntArray): List<ReaderTextRect>? = null
override suspend fun textPageGetCharIndexAtPos(
x: Double,
y: Double,
xTolerance: Double,
yTolerance: Double
): Int = -1
override suspend fun textPageGetCharBox(index: Int): RectF? = null
override suspend fun textPageGetUnicode(index: Int): Int = source[index].code
override suspend fun loadWebLink(): ReaderWebLinks? = null
override fun close() = Unit
}
private fun ocrResult(vararg elements: OcrElement): OcrResult {
val line = OcrLine(
text = elements.joinToString(" ") { it.text },
boundingBox = null,
elements = elements.toList()
)
val block = OcrBlock(text = line.text, boundingBox = null, lines = listOf(line))
return OcrResult(text = line.text, textBlocks = listOf(block))
}
private fun assertRectFEquals(expected: RectF, actual: RectF) {
assertEquals(expected.left, actual.left, 0.0001f)
assertEquals(expected.top, actual.top, 0.0001f)
assertEquals(expected.right, actual.right, 0.0001f)
assertEquals(expected.bottom, actual.bottom, 0.0001f)
}
}

View file

@ -0,0 +1,219 @@
package com.aryan.reader.pdf
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.epubreader.SystemUiMode
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PdfReaderPreferencesTest {
@Test
fun `tool preferences load defaults and preserve saved order with unknowns removed`() {
val prefs = InMemorySharedPreferences(
PDF_TOOL_ORDER_KEY to "SEARCH,NO_SUCH_TOOL,TOC,SEARCH",
PDF_BOTTOM_TOOLS_KEY to setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.TOC.name),
PDF_HIDDEN_TOOLS_KEY to setOf(PdfReaderTool.PRINT.name)
)
val context = contextWithPrefs(prefs)
val order = loadPdfToolOrder(context)
assertEquals(listOf(PdfReaderTool.SEARCH, PdfReaderTool.TOC), order.take(2))
assertEquals(PdfReaderTool.entries.size, order.size)
assertEquals(PdfReaderTool.entries.toSet(), order.toSet())
assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.TOC.name), loadPdfBottomTools(context))
assertEquals(setOf(PdfReaderTool.PRINT.name), loadPdfHiddenTools(context))
}
@Test
fun `tool preferences save hidden bottom and explicit order`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
savePdfHiddenTools(context, setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name))
savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name))
savePdfToolOrder(context, listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH))
assertEquals(setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name), loadPdfHiddenTools(context))
assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context))
assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2))
}
@Test
fun `reader mode and enum preferences default safely when saved values are invalid`() {
val prefs = InMemorySharedPreferences(
DISPLAY_MODE_KEY to "BROKEN",
DOCK_LOCATION_KEY to "MISSING",
DOCK_OFFSET_X_KEY to 12.5f,
DOCK_OFFSET_Y_KEY to -7.25f,
OCR_LANGUAGE_KEY to "UNKNOWN",
PDF_SYSTEM_UI_MODE_KEY to Int.MIN_VALUE
)
val context = contextWithPrefs(prefs)
assertEquals(DisplayMode.VERTICAL_SCROLL, loadDisplayMode(context))
assertEquals(DockLocation.BOTTOM to Offset(12.5f, -7.25f), loadDockState(context))
assertEquals(OcrLanguage.LATIN, loadOcrLanguage(context))
assertEquals(SystemUiMode.SYNC, loadPdfSystemUiMode(context))
assertFalse(hasUserSelectedOcrLanguage(context))
}
@Test
fun `reader mode and enum preferences save and load selected values`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
saveDisplayMode(context, DisplayMode.PAGINATION)
saveDockState(context, DockLocation.FLOATING, Offset(3f, 4f))
saveOcrLanguage(context, OcrLanguage.JAPANESE)
savePdfSystemUiMode(context, SystemUiMode.HIDDEN)
assertEquals(DisplayMode.PAGINATION, loadDisplayMode(context))
assertEquals(DockLocation.FLOATING to Offset(3f, 4f), loadDockState(context))
assertEquals(OcrLanguage.JAPANESE, loadOcrLanguage(context))
assertTrue(hasUserSelectedOcrLanguage(context))
assertEquals(SystemUiMode.HIDDEN, loadPdfSystemUiMode(context))
}
@Test
fun `theme dictionary and simple boolean preferences round trip`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
savePdfThemeId(context, "sepia")
saveKeepScreenOn(context, true)
saveUseOnlineDict(context, false)
saveExternalDictPackage(context, "com.example.dict")
saveExternalTranslatePackage(context, "com.example.translate")
saveExternalSearchPackage(context, "com.example.search")
savePdfMusicianMode(context, true)
savePdfScrollLocked(context, "book/one", true)
savePdfLockedState(context, "book/one", scale = 2.25f, offsetX = -10f, offsetY = 42f)
saveStylusOnlyMode(context, true)
savePdfDarkMode(context, true)
assertEquals("sepia", loadPdfThemeId(context))
assertTrue(loadKeepScreenOn(context))
assertFalse(loadUseOnlineDict(context))
assertEquals("com.example.dict", loadExternalDictPackage(context))
assertEquals("com.example.translate", loadExternalTranslatePackage(context))
assertEquals("com.example.search", loadExternalSearchPackage(context))
assertTrue(loadPdfMusicianMode(context))
assertTrue(loadPdfScrollLocked(context, "book/one"))
assertEquals(Triple(2.25f, -10f, 42f), loadPdfLockedState(context, "book/one"))
assertNull(loadPdfLockedState(context, "missing"))
assertTrue(loadStylusOnlyMode(context))
assertTrue(loadPdfDarkMode(context))
}
@Test
fun `auto scroll global and per book preferences round trip with null local settings until speed exists`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
assertNull(loadPdfAutoScrollLocalSettings(context, "book"))
savePdfAutoScrollSpeed(context, 4.5f)
savePdfAutoScrollMinSpeed(context, 0.25f)
savePdfAutoScrollMaxSpeed(context, 8.75f)
savePdfAutoScrollUseSlider(context, true)
savePdfAutoScrollLocalMode(context, "book", true)
savePdfAutoScrollLocalSettings(context, "book", speed = 5.5f, min = 0.5f, max = 9f)
assertEquals(4.5f, loadPdfAutoScrollSpeed(context), 0.0001f)
assertEquals(0.25f, loadPdfAutoScrollMinSpeed(context), 0.0001f)
assertEquals(8.75f, loadPdfAutoScrollMaxSpeed(context), 0.0001f)
assertTrue(loadPdfAutoScrollUseSlider(context))
assertTrue(loadPdfAutoScrollLocalMode(context, "book"))
assertEquals(Triple(5.5f, 0.5f, 9f), loadPdfAutoScrollLocalSettings(context, "book"))
}
@Test
fun `custom highlight colors round trip while missing colors fall back to defaults`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
saveCustomHighlightColors(
context,
mapOf(
PdfHighlightColor.YELLOW to Color(0xFF010203),
PdfHighlightColor.RED to Color(0xFF0A0B0C)
)
)
val colors = loadCustomHighlightColors(context)
assertEquals(Color(0xFF010203).toArgb(), colors.getValue(PdfHighlightColor.YELLOW).toArgb())
assertEquals(Color(0xFF0A0B0C).toArgb(), colors.getValue(PdfHighlightColor.RED).toArgb())
assertEquals(PdfHighlightColor.GREEN.color.toArgb(), colors.getValue(PdfHighlightColor.GREEN).toArgb())
}
private fun contextWithPrefs(prefs: SharedPreferences): Context {
val context = mockk<Context>()
every { context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) } returns prefs
return context
}
private class InMemorySharedPreferences(vararg initial: Pair<String, Any?>) : SharedPreferences {
private val values = initial.toMap().toMutableMap()
override fun getAll(): MutableMap<String, *> = values
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
val value = values[key] as? Set<*> ?: return defValues
return value.filterIsInstance<String>().toMutableSet()
}
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String?): Boolean = values.containsKey(key)
override fun edit(): SharedPreferences.Editor = Editor()
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
private inner class Editor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearRequested = false
override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value)
override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor =
applyPut(key, values?.toSet())
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value)
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value)
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value)
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value)
override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null)
override fun clear(): SharedPreferences.Editor {
clearRequested = true
return this
}
override fun commit(): Boolean {
flush()
return true
}
override fun apply() = flush()
private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor {
if (key != null) pending[key] = value
return this
}
private fun flush() {
if (clearRequested) values.clear()
pending.forEach { (key, value) ->
if (value == null) values.remove(key) else values[key] = value
}
}
}
}
}

View file

@ -0,0 +1,165 @@
package com.aryan.reader.pdf
import android.content.Context
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PdfHighlightRepository
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.VirtualPage
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class PdfReaderRepositoryTest {
@Test
fun `PdfAnnotationRepository saves loads and exposes non empty sync file`() = runTest {
val context = contextWithFilesDir(tempRoot("annotation"))
val repository = PdfAnnotationRepository(context)
val annotations = mapOf(
1 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = 1,
points = listOf(PdfPoint(0.1f, 0.2f, 123L)),
color = Color.Red,
strokeWidth = 0.01f
)
)
)
repository.saveAnnotations("folder/book.pdf", annotations)
val loaded = repository.loadAnnotations("folder/book.pdf")
assertEquals(1, loaded.getValue(1).single().pageIndex)
assertEquals(InkType.PEN, loaded.getValue(1).single().inkType)
assertNotNull(repository.getAnnotationFileForSync("folder/book.pdf"))
assertTrue(File(context.filesDir, "annotations/annotation_folder_book.pdf.json").exists())
}
@Test
fun `PdfAnnotationRepository keeps empty save as no syncable file`() = runTest {
val context = contextWithFilesDir(tempRoot("annotation-empty"))
val repository = PdfAnnotationRepository(context)
repository.saveAnnotations("book", emptyMap())
assertEquals(emptyMap<Int, List<PdfAnnotation>>(), repository.loadAnnotations("book"))
assertNull(repository.getAnnotationFileForSync("book"))
}
@Test
fun `PdfHighlightRepository saves loads deletes empty highlights and clears all`() = runTest {
val context = contextWithFilesDir(tempRoot("highlights"))
val repository = PdfHighlightRepository(context)
val highlight = PdfUserHighlight(
id = "h1",
pageIndex = 2,
bounds = emptyList(),
color = PdfHighlightColor.GREEN,
text = "quote",
range = 5 to 10
)
repository.saveHighlights("book/one", listOf(highlight))
assertEquals(listOf(highlight), repository.loadHighlights("book/one"))
assertTrue(repository.getFileForSync("book/one").exists())
repository.saveHighlights("book/one", emptyList())
assertEquals(emptyList<PdfUserHighlight>(), repository.loadHighlights("book/one"))
assertFalse(repository.getFileForSync("book/one").exists())
repository.saveHighlights("book/two", listOf(highlight.copy(id = "h2")))
repository.clearAll()
assertFalse(File(context.filesDir, "pdf_highlights").exists())
}
@Test
fun `PdfTextBoxRepository saves loads deletes and clears files`() = runTest {
val context = contextWithFilesDir(tempRoot("textboxes"))
val repository = PdfTextBoxRepository(context)
val box = PdfTextBox(
id = "box",
pageIndex = 0,
relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f),
text = "Text box",
color = Color.Black,
backgroundColor = Color.White,
fontSize = 16f
)
repository.saveTextBoxes("book/one", listOf(box))
assertEquals(listOf(box), repository.loadTextBoxes("book/one"))
assertTrue(repository.getFileForSync("book/one").exists())
repository.deleteForBook("book/one")
assertEquals(emptyList<PdfTextBox>(), repository.loadTextBoxes("book/one"))
repository.saveTextBoxes("book/two", listOf(box.copy(id = "box2")))
repository.clearAll()
assertTrue(File(context.filesDir, "textboxes").listFiles().orEmpty().isEmpty())
}
@Test
fun `PageLayoutRepository returns default pdf pages when no layout exists`() = runTest {
val repository = PageLayoutRepository(contextWithFilesDir(tempRoot("layout-default")))
assertEquals(
listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1), VirtualPage.PdfPage(2)),
repository.loadLayout("missing", totalPdfPages = 3)
)
assertNull(repository.getLayoutOrNull("missing"))
}
@Test
fun `PageLayoutRepository round trips pdf and blank virtual pages`() = runTest {
val context = contextWithFilesDir(tempRoot("layout"))
val repository = PageLayoutRepository(context)
val pages = listOf(
VirtualPage.PdfPage(0),
VirtualPage.BlankPage(id = "blank-1", width = 612, height = 792, wasManuallyAdded = true),
VirtualPage.PdfPage(3)
)
repository.saveLayout("folder/book.pdf", pages)
assertEquals(pages, repository.loadLayout("folder/book.pdf", totalPdfPages = 10))
assertNotNull(repository.getLayoutOrNull("folder/book.pdf"))
assertTrue(File(context.filesDir, "page_layouts/layout_folder_book.pdf.json").exists())
}
@Test
fun `PageLayoutRepository falls back for corrupt loadLayout but returns null for corrupt optional lookup`() = runTest {
val context = contextWithFilesDir(tempRoot("layout-corrupt"))
val repository = PageLayoutRepository(context)
repository.getLayoutFile("book").writeText("not json")
assertEquals(
listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1)),
repository.loadLayout("book", totalPdfPages = 2)
)
assertNull(repository.getLayoutOrNull("book"))
}
private fun contextWithFilesDir(filesDir: File): Context {
val context = mockk<Context>()
every { context.filesDir } returns filesDir
return context
}
private fun tempRoot(name: String): File {
return File("build/test-tmp/pdf-reader/$name-${System.nanoTime()}").apply { mkdirs() }
}
}

View file

@ -0,0 +1,240 @@
package com.aryan.reader.pdf
import android.content.Context
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class PdfReaderRichTextTest {
@Test
fun `RichTextMapper toAnnotatedString clips global spans into requested local range`() {
val document = GlobalRichDocument(
text = "0123456789",
spans = listOf(
GlobalRichSpan(
start = 2,
end = 6,
color = Color.Red.toArgb(),
backgroundColor = Color.Yellow.toArgb(),
fontSizeNorm = 0.02f,
isBold = true,
isItalic = true,
isUnderline = true,
isStrikethrough = true
)
)
)
val annotated = RichTextMapper.toAnnotatedString(
document = document,
pageHeightPx = 1_000f,
rangeStart = 4,
rangeEnd = 8
)
assertEquals("4567", annotated.text)
val range = annotated.spanStyles.single()
assertEquals(0, range.start)
assertEquals(2, range.end)
assertEquals(Color.Red, range.item.color)
assertEquals(Color.Yellow, range.item.background)
assertEquals(20.sp, range.item.fontSize)
assertEquals(FontWeight.Bold, range.item.fontWeight)
assertEquals(FontStyle.Italic, range.item.fontStyle)
assertTrue(range.item.textDecoration!!.contains(TextDecoration.Underline))
assertTrue(range.item.textDecoration!!.contains(TextDecoration.LineThrough))
}
@Test
fun `RichTextMapper toAnnotatedString clamps invalid ranges and falls back to 16sp when page height is invalid`() {
val document = GlobalRichDocument(
text = "abcdef",
spans = listOf(
GlobalRichSpan(
start = 0,
end = 6,
color = Color.Blue.toArgb(),
backgroundColor = Color.Transparent.toArgb(),
fontSizeNorm = 0.5f,
isBold = false,
isItalic = false,
isUnderline = false,
isStrikethrough = false
)
)
)
val empty = RichTextMapper.toAnnotatedString(document, pageHeightPx = 500f, rangeStart = 10, rangeEnd = 1)
val full = RichTextMapper.toAnnotatedString(document, pageHeightPx = 0f, rangeStart = -10, rangeEnd = 99)
assertEquals("", empty.text)
assertEquals("abcdef", full.text)
assertEquals(16.sp, full.spanStyles.single().item.fontSize)
}
@Test
fun `RichTextMapper fromAnnotatedString splits overlapping styles and preserves page breaks`() {
val text = "Hello${PAGE_BREAK_CHAR}World"
val annotated = buildAnnotatedString {
append(text)
addStyle(
SpanStyle(
color = Color.Black,
background = Color.Transparent,
fontSize = 20.sp
),
start = 0,
end = text.length
)
addStyle(
SpanStyle(
color = Color.Magenta,
background = Color.Cyan,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
textDecoration = TextDecoration.combine(
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
)
),
start = 0,
end = 5
)
}
val document = RichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f)
assertEquals(text, document.text)
assertEquals(2, document.spans.size)
val first = document.spans[0]
assertEquals(0, first.start)
assertEquals(5, first.end)
assertEquals(Color.Magenta.toArgb(), first.color)
assertEquals(Color.Cyan.toArgb(), first.backgroundColor)
assertEquals(0.024f, first.fontSizeNorm, 0.0001f)
assertTrue(first.isBold)
assertTrue(first.isItalic)
assertTrue(first.isUnderline)
assertTrue(first.isStrikethrough)
val second = document.spans[1]
assertEquals(5, second.start)
assertEquals(text.length, second.end)
assertEquals(Color.Black.toArgb(), second.color)
assertFalse(second.isBold)
}
@Test
fun `RichTextMapper fromAnnotatedString merges adjacent identical effective styles`() {
val annotated = buildAnnotatedString {
append("abcd")
val style = SpanStyle(
color = Color.Green,
background = Color.Transparent,
fontSize = 18.sp
)
addStyle(style, start = 0, end = 2)
addStyle(style, start = 2, end = 4)
}
val document = RichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 900f)
assertEquals(1, document.spans.size)
val span = document.spans.single()
assertEquals(0, span.start)
assertEquals(4, span.end)
assertEquals(Color.Green.toArgb(), span.color)
assertEquals(Color.Transparent.toArgb(), span.backgroundColor)
assertEquals(0.02f, span.fontSizeNorm, 0.0001f)
assertFalse(span.isBold)
assertFalse(span.isItalic)
assertFalse(span.isUnderline)
assertFalse(span.isStrikethrough)
}
@Test
fun `RichTextMapper fromAnnotatedString returns empty rich document for empty text`() {
assertEquals(
GlobalRichDocument("", emptyList()),
RichTextMapper.fromAnnotatedString(AnnotatedString(""), pageHeightPx = 1_000f)
)
}
@Test
fun `hasRenderableRichText ignores whitespace and explicit page breaks`() {
assertFalse(" \n\t${PAGE_BREAK_CHAR}".hasRenderableRichText())
assertTrue("${PAGE_BREAK_CHAR}\nVisible".hasRenderableRichText())
}
@Test
fun `PdfRichTextRepository saves and loads rich document with sanitized book id`() = runTest {
val context = contextWithFilesDir(tempRoot("rich-save-load"))
val repository = PdfRichTextRepository(context)
val document = GlobalRichDocument(
text = "Saved rich text",
spans = listOf(
GlobalRichSpan(
start = 0,
end = 5,
color = Color.Red.toArgb(),
backgroundColor = Color.Transparent.toArgb(),
fontSizeNorm = 0.018f,
isBold = true,
isItalic = false,
isUnderline = true,
isStrikethrough = false,
fontPath = "asset:fonts/lora.ttf"
)
)
)
repository.save("folder/book:name?.pdf", document)
val file = repository.getFileForSync("folder/book:name?.pdf")
assertTrue(file.name.matches(Regex("rich_doc_folder_book_name_\\.pdf\\.json")))
assertTrue(file.exists())
assertEquals(document, repository.document.value)
val reloaded = PdfRichTextRepository(context)
reloaded.load("folder/book:name?.pdf")
assertEquals(document, reloaded.document.value)
}
@Test
fun `PdfRichTextRepository load returns empty document for missing and corrupt files`() = runTest {
val context = contextWithFilesDir(tempRoot("rich-corrupt"))
val repository = PdfRichTextRepository(context)
repository.load("missing")
assertEquals(GlobalRichDocument("", emptyList()), repository.document.value)
repository.getFileForSync("corrupt").writeText("{not json")
repository.load("corrupt")
assertEquals(GlobalRichDocument("", emptyList()), repository.document.value)
}
private fun contextWithFilesDir(filesDir: File): Context {
val context = mockk<Context>()
every { context.filesDir } returns filesDir
return context
}
private fun tempRoot(name: String): File {
return File("build/test-tmp/pdf-reader/$name-${System.nanoTime()}").apply { mkdirs() }
}
}

View file

@ -0,0 +1,218 @@
package com.aryan.reader.pdf
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.AnnotationSerializer
import com.aryan.reader.pdf.data.HighlightSerializer
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.TextBoxSerializer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PdfReaderSerializerTest {
@Test
fun `AnnotationSerializer round trips multi page annotations with precision and style`() {
val annotations = mapOf(
0 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.FOUNTAIN_PEN,
pageIndex = 0,
points = listOf(
PdfPoint(0.123456f, 0.987654f, 10L),
PdfPoint(0.2f, 0.3f, 11L)
),
color = Color(0xFF336699),
strokeWidth = 0.0125f
)
),
2 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.HIGHLIGHTER_ROUND,
pageIndex = 2,
points = emptyList(),
color = Color(0x8CFFEB3B),
strokeWidth = 0.035f
)
)
)
val decoded = AnnotationSerializer.fromJson(AnnotationSerializer.toJson(annotations))
assertEquals(setOf(0, 2), decoded.keys)
val first = decoded.getValue(0).single()
assertEquals(AnnotationType.INK, first.type)
assertEquals(InkType.FOUNTAIN_PEN, first.inkType)
assertEquals(Color(0xFF336699).toArgb(), first.color.toArgb())
assertEquals(0.0125f, first.strokeWidth, 0.00001f)
assertEquals(0.12346f, first.points[0].x, 0.00001f)
assertEquals(0.98765f, first.points[0].y, 0.00001f)
assertEquals(10L, first.points[0].timestamp)
assertEquals(InkType.HIGHLIGHTER_ROUND, decoded.getValue(2).single().inkType)
}
@Test
fun `AnnotationSerializer supports legacy type field and malformed input fallback`() {
val legacyJson = """
[
{
"pageIndex": 4,
"annotationType": "BROKEN",
"type": "PENCIL",
"color": -65536,
"strokeWidth": 0.5,
"points": [{ "x": 0.1, "y": 0.2 }]
}
]
""".trimIndent()
val decoded = AnnotationSerializer.fromJson(legacyJson).getValue(4).single()
assertEquals(AnnotationType.INK, decoded.type)
assertEquals(InkType.PENCIL, decoded.inkType)
assertEquals(0L, decoded.points.single().timestamp)
assertTrue(AnnotationSerializer.fromJson("not json").isEmpty())
assertTrue(AnnotationSerializer.fromJson("").isEmpty())
}
@Test
fun `TextBoxSerializer round trips bounds text styling and optional font data`() {
val boxes = listOf(
PdfTextBox(
id = "box-1",
pageIndex = 3,
relativeBounds = Rect(0.1f, 0.2f, 0.8f, 0.4f),
text = "Hello annotations",
color = Color(0xFF112233),
backgroundColor = Color(0x66123456),
fontSize = 18f,
isBold = true,
isItalic = true,
isUnderline = true,
isStrikeThrough = true,
fontPath = "/fonts/test.ttf",
fontName = "Test Font"
)
)
val decoded = TextBoxSerializer.fromJson(TextBoxSerializer.toJson(boxes)).single()
assertEquals("box-1", decoded.id)
assertEquals(3, decoded.pageIndex)
assertEquals(Rect(0.1f, 0.2f, 0.8f, 0.4f), decoded.relativeBounds)
assertEquals("Hello annotations", decoded.text)
assertEquals(Color(0xFF112233).toArgb(), decoded.color.toArgb())
assertEquals(Color(0x66123456).toArgb(), decoded.backgroundColor.toArgb())
assertEquals(18f, decoded.fontSize, 0.0001f)
assertTrue(decoded.isBold)
assertTrue(decoded.isItalic)
assertTrue(decoded.isUnderline)
assertTrue(decoded.isStrikeThrough)
assertEquals("/fonts/test.ttf", decoded.fontPath)
assertEquals("Test Font", decoded.fontName)
}
@Test
fun `TextBoxSerializer defaults missing optional style fields and rejects malformed json`() {
val legacyJson = """
[
{
"id": "legacy-box",
"pageIndex": 1,
"text": "Legacy",
"color": -16777216,
"backgroundColor": 0,
"fontSize": 14.0,
"bounds": { "left": 0.0, "top": 0.1, "right": 0.8, "bottom": 0.2 }
}
]
""".trimIndent()
val decoded = TextBoxSerializer.fromJson(legacyJson).single()
assertEquals("legacy-box", decoded.id)
assertEquals("Legacy", decoded.text)
assertEquals(Rect(0f, 0.1f, 0.8f, 0.2f), decoded.relativeBounds)
assertFalse(decoded.isBold)
assertFalse(decoded.isItalic)
assertFalse(decoded.isUnderline)
assertFalse(decoded.isStrikeThrough)
assertNull(decoded.fontPath)
assertNull(decoded.fontName)
assertTrue(TextBoxSerializer.fromJson("broken").isEmpty())
}
@Test
fun `HighlightSerializer round trips highlights and falls back on invalid color`() {
val highlights = listOf(
PdfUserHighlight(
id = "highlight-1",
pageIndex = 5,
bounds = listOf(RectF(0f, 0f, 1f, 1f)),
color = PdfHighlightColor.BLUE,
text = "Selected text",
range = 7 to 20,
note = "Important"
)
)
val decoded = HighlightSerializer.fromJson(HighlightSerializer.toJson(highlights)).single()
assertEquals("highlight-1", decoded.id)
assertEquals(5, decoded.pageIndex)
assertEquals(PdfHighlightColor.BLUE, decoded.color)
assertEquals("Selected text", decoded.text)
assertEquals(7 to 20, decoded.range)
assertEquals("Important", decoded.note)
assertEquals(1, decoded.bounds.size)
assertRectFEquals(RectF(0f, 0f, 1f, 1f), decoded.bounds.single())
val invalidColor = """[{"pageIndex":0,"bounds":[],"color":"NOPE","text":"x"}]"""
assertEquals(PdfHighlightColor.YELLOW, HighlightSerializer.fromJson(invalidColor).single().color)
assertTrue(HighlightSerializer.fromJson("bad").isEmpty())
}
@Test
fun `HighlightSerializer omits blank notes and defaults missing legacy values`() {
val json = HighlightSerializer.toJson(
listOf(
PdfUserHighlight(
id = "blank-note",
pageIndex = 0,
bounds = emptyList(),
color = PdfHighlightColor.RED,
text = "Text",
range = 2 to 4,
note = " "
)
)
)
val decodedBlankNote = HighlightSerializer.fromJson(json).single()
assertNull(decodedBlankNote.note)
val legacyJson = """[{"pageIndex":2,"bounds":[],"color":"GREEN"}]"""
val decodedLegacy = HighlightSerializer.fromJson(legacyJson).single()
assertTrue(decodedLegacy.id.isNotBlank())
assertEquals("", decodedLegacy.text)
assertEquals(0 to 0, decodedLegacy.range)
}
private fun assertRectFEquals(expected: RectF, actual: RectF) {
assertEquals(expected.left, actual.left, 0.0001f)
assertEquals(expected.top, actual.top, 0.0001f)
assertEquals(expected.right, actual.right, 0.0001f)
assertEquals(expected.bottom, actual.bottom, 0.0001f)
}
}

View file

@ -0,0 +1,147 @@
package com.aryan.reader.pdf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.AnnotationToolSettings
import com.aryan.reader.pdf.data.TextStyleConfig
import com.aryan.reader.pdf.data.ToolConfig
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.PdfPageBounds
import com.aryan.reader.shared.pdf.PdfPagePoint
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PdfReaderSettingsAndSharedModelsTest {
@Test
fun `AnnotationToolSettings falls back safely for invalid selected and last tools`() {
val settings = AnnotationToolSettings(
selectedToolName = "BROKEN",
lastActivePenType = "MISSING",
lastActiveHighlighterType = "NOPE"
)
assertEquals(InkType.PEN, settings.getActiveTool())
assertEquals(InkType.PEN, settings.getLastPenTool())
assertEquals(InkType.HIGHLIGHTER, settings.getLastHighlighterTool())
}
@Test
fun `AnnotationToolSettings returns custom configs and palettes`() {
val settings = AnnotationToolSettings(
selectedToolName = InkType.TEXT.name,
toolConfigs = mapOf(InkType.PEN.name to ToolConfig(Color.Cyan.toArgb(), 0.25f)),
penPaletteArgb = listOf(Color.Red.toArgb(), Color.Green.toArgb()),
highlighterPaletteArgb = listOf(Color.Yellow.toArgb()),
textStyle = TextStyleConfig(colorArgb = Color.Magenta.toArgb(), fontSize = 22f),
isHighlighterSnapEnabled = true
)
assertEquals(InkType.TEXT, settings.getActiveTool())
assertEquals(Color.Cyan.toArgb(), settings.getToolColor(InkType.PEN).toArgb())
assertEquals(0.25f, settings.getToolThickness(InkType.PEN), 0.0001f)
assertEquals(Color.Red.toArgb(), settings.getPenPalette().first().toArgb())
assertEquals(Color.Yellow.toArgb(), settings.getHighlighterPalette().single().toArgb())
assertTrue(settings.isHighlighterSnapEnabled)
assertEquals(22f, settings.textStyle.fontSize, 0.0001f)
}
@Test
fun `AnnotationSettingsRepository default configs cover every ink type`() {
InkType.entries.forEach { type ->
val config = AnnotationSettingsRepository.getDefaultConfig(type)
assertTrue("Expected positive thickness for $type", config.thickness > 0f)
}
}
@Test
fun `SharedPdfAnnotationSerializer round trips current store shape`() {
val annotation = SharedPdfAnnotation(
id = "ann-1",
pageIndex = 7,
kind = PdfAnnotationKind.TEXT,
tool = PdfInkTool.TEXT,
points = listOf(PdfPagePoint(0.1f, 0.2f, 100L)),
bounds = PdfPageBounds(0.1f, 0.2f, 0.3f, 0.4f),
text = "Margin note",
colorArgb = 0xFF112233.toInt(),
backgroundArgb = 0x66112233,
strokeWidth = 1.5f,
fontSize = 19f,
isBold = true,
isItalic = true,
createdAt = 1234L
)
val decoded = SharedPdfAnnotationSerializer.decode(
SharedPdfAnnotationSerializer.encode(listOf(annotation))
)
assertEquals(listOf(annotation), decoded)
assertEquals(emptyList<SharedPdfAnnotation>(), SharedPdfAnnotationSerializer.decode(""))
assertEquals(emptyList<SharedPdfAnnotation>(), SharedPdfAnnotationSerializer.decode("bad json"))
}
@Test
fun `SharedPdfAnnotationSerializer decodes legacy bare annotation array`() {
val legacyJson = """
[
{
"id": "legacy",
"pageIndex": 1,
"kind": "INK",
"tool": "PEN",
"points": [{"x":0.2,"y":0.3,"timestamp":9}],
"colorArgb": -1
}
]
""".trimIndent()
val decoded = SharedPdfAnnotationSerializer.decode(legacyJson).single()
assertEquals("legacy", decoded.id)
assertEquals(1, decoded.pageIndex)
assertEquals(PdfAnnotationKind.INK, decoded.kind)
assertEquals(PdfInkTool.PEN, decoded.tool)
assertEquals(PdfPagePoint(0.2f, 0.3f, 9L), decoded.points.single())
}
@Test
fun `SharedPdfAnnotationDefaults supplies expected tool defaults and palettes`() {
assertEquals(5, SharedPdfAnnotationDefaults.penPalette.size)
assertEquals(5, SharedPdfAnnotationDefaults.highlighterPalette.size)
val pen = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN)
val eraser = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER)
val highlighter = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER)
assertTrue(pen.strokeWidth > 0f)
assertEquals(0x00000000, eraser.colorArgb)
assertTrue(highlighter.strokeWidth > pen.strokeWidth)
}
@Test
fun `PdfZoomSpec clamps scale and keeps render size under pixel budget`() {
val spec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1f, maxRenderPixels = 1_000_000)
assertEquals(0.5f, spec.clamp(0.1f), 0.0001f)
assertEquals(4f, spec.clamp(10f), 0.0001f)
val safeScale = spec.safeRenderScale(pageWidth = 2_000f, pageHeight = 2_000f, requestedScale = 4f)
assertTrue(safeScale < 1f)
assertTrue(safeScale >= 0.1f)
val (width, height) = spec.renderSize(pageWidth = 2_000f, pageHeight = 2_000f, requestedScale = 4f)
assertTrue(width * height <= 1_000_000)
assertTrue(width >= 1)
assertTrue(height >= 1)
}
}

View file

@ -0,0 +1,139 @@
package com.aryan.reader.pdf
import android.content.Context
import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.data.PdfMetaDao
import com.aryan.reader.pdf.data.PdfMetadata
import com.aryan.reader.pdf.data.PdfSearchMatch
import com.aryan.reader.pdf.data.PdfTextDao
import com.aryan.reader.pdf.data.PdfTextDatabase
import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.SmartSearchResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class PdfTextRepositoryTest {
private lateinit var dao: PdfTextDao
private lateinit var metaDao: PdfMetaDao
private lateinit var repository: PdfTextRepository
@Before
fun setUp() {
dao = mockk(relaxed = true)
metaDao = mockk(relaxed = true)
val db = mockk<PdfTextDatabase>()
every { db.pdfTextDao() } returns dao
every { db.pdfMetaDao() } returns metaDao
mockkObject(PdfTextDatabase.Companion)
every { PdfTextDatabase.getDatabase(any()) } returns db
repository = PdfTextRepository(mockk<Context>(relaxed = true))
}
@After
fun tearDown() {
unmockkObject(PdfTextDatabase.Companion)
}
@Test
fun `page ratios load parse failures and save preserves existing OCR language`() = runTest {
coEvery { metaDao.getMetadata("missing") } returns null
coEvery { metaDao.getMetadata("bad") } returns PdfMetadata("bad", 1, "not json", "LATIN")
coEvery { metaDao.getMetadata("book") } returnsMany listOf(
PdfMetadata("book", 2, "[1.25,0.75]", "DEVANAGARI"),
PdfMetadata("book", 2, "[1.25,0.75]", "DEVANAGARI")
)
val inserted = slot<PdfMetadata>()
coEvery { metaDao.insertMetadata(capture(inserted)) } returns Unit
assertNull(repository.getPageRatios("missing"))
assertNull(repository.getPageRatios("bad"))
assertEquals(listOf(1.25f, 0.75f), repository.getPageRatios("book"))
repository.savePageRatios("book", listOf(2f, 3.5f))
assertEquals("book", inserted.captured.bookId)
assertEquals(2, inserted.captured.totalPages)
assertEquals("[2,3.5]", inserted.captured.ratiosJson)
assertEquals("DEVANAGARI", inserted.captured.ocrLanguage)
}
@Test
fun `searchBookFlow sanitizes FTS query and filters exact phrase punctuation`() = runTest {
every { dao.searchBookFlow("book", "content:hello* content:world*") } returns flowOf(
listOf(
PdfSearchMatch(0, "", "hello, world appears here"),
PdfSearchMatch(1, "", "hello world without comma")
)
)
val results = repository.searchBookFlow("book", "hello, world").first()
assertEquals(listOf(0), results.map { it.pageIndex })
}
@Test
fun `smart search emits exact results with occurrence indexes and highlighted snippets`() = runTest {
coEvery { dao.countMatches("book", "content:needle*") } returns 2
coEvery { dao.getAllMatches("book", "content:needle*") } returns listOf(
PdfSearchMatch(4, "", "needle one and needle two")
)
val result = repository.searchBookSmart("book", "needle").first()
assertTrue(result is SmartSearchResult.Exact)
val matches = (result as SmartSearchResult.Exact).matches
assertEquals(2, matches.size)
assertEquals(4, matches[0].locationInSource)
assertEquals("Page 5", matches[0].locationTitle)
assertEquals(0, matches[0].occurrenceIndexInLocation)
assertEquals(1, matches[1].occurrenceIndexInLocation)
assertEquals("needle", matches[0].query)
}
@Test
fun `smart search emits paged result when page match count is large`() = runTest {
coEvery { dao.countMatches("book", "content:common*") } returns 51
every { dao.searchBookPagingSource("book", "content:common*") } returns mockk(relaxed = true)
val result = repository.searchBookSmart("book", "common").first()
assertTrue(result is SmartSearchResult.Paged)
assertEquals(51, (result as SmartSearchResult.Paged).totalPageCount)
}
@Test
fun `next and previous search result navigate within current page before querying adjacent pages`() = runTest {
val current = SearchResult(
locationInSource = 0,
locationTitle = "Page 1",
snippet = androidx.compose.ui.text.AnnotatedString("first"),
query = "needle",
occurrenceIndexInLocation = 0,
chunkIndex = 0
)
coEvery { dao.getPageText("book", 0) } returns "needle then needle again"
val next = repository.getNextResult("book", "needle", current)
val prev = repository.getPrevResult("book", "needle", current.copy(occurrenceIndexInLocation = 1))
assertEquals(1, next?.occurrenceIndexInLocation)
assertEquals(0, prev?.occurrenceIndexInLocation)
coVerify(exactly = 0) { dao.getNextPageWithMatch(any(), any(), any()) }
coVerify(exactly = 0) { dao.getPrevPageWithMatch(any(), any(), any()) }
}
}

View file

@ -0,0 +1,171 @@
package com.aryan.reader.pdf
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PdfiumAnnotationExporterTest {
@Test
fun `buildPayload flattens ink annotations and skips unsupported ink tools`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = mapOf(
2 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = 99,
points = listOf(PdfPoint(0.1f, 0.2f), PdfPoint(0.3f, 0.4f)),
color = Color(0xFF336699),
strokeWidth = 0.0125f
),
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.ERASER,
pageIndex = 2,
points = listOf(PdfPoint(0.5f, 0.6f), PdfPoint(0.7f, 0.8f)),
color = Color.Black,
strokeWidth = 0.1f
)
)
),
textBoxes = emptyList(),
highlights = emptyList()
)
assertArrayEquals(intArrayOf(2), payload.inkPageIndices)
assertArrayEquals(intArrayOf(InkType.PEN.ordinal), payload.inkTypes)
assertArrayEquals(intArrayOf(Color(0xFF336699).toArgb()), payload.inkColors)
assertArrayEquals(floatArrayOf(0.0125f), payload.inkStrokeWidths, 0.0001f)
assertArrayEquals(intArrayOf(0), payload.inkPointOffsets)
assertArrayEquals(intArrayOf(2), payload.inkPointCounts)
assertArrayEquals(floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f), payload.inkPoints, 0.0001f)
}
@Test
fun `buildPayload preserves highlight pdf rects and content notes`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = emptyMap(),
textBoxes = emptyList(),
highlights = listOf(
PdfUserHighlight(
id = "highlight-1",
pageIndex = 1,
bounds = listOf(RectF(10f, 90f, 40f, 80f), RectF(50f, 70f, 60f, 65f)),
color = PdfHighlightColor.BLUE,
text = "Selected text",
range = 0 to 13,
note = "Important"
)
)
)
assertArrayEquals(intArrayOf(1), payload.highlightPageIndices)
assertArrayEquals(intArrayOf(PdfHighlightColor.BLUE.color.toArgb()), payload.highlightColors)
assertArrayEquals(intArrayOf(0), payload.highlightRectOffsets)
assertArrayEquals(intArrayOf(2), payload.highlightRectCounts)
assertArrayEquals(
floatArrayOf(10f, 90f, 40f, 80f, 50f, 70f, 60f, 65f),
payload.highlightRects,
0.0001f
)
assertEquals("Important", payload.highlightContents.single())
}
@Test
fun `buildPayload flattens raster text overlays and leaves native text empty`() {
val pixels = intArrayOf(
0x00000000,
0xFF112233.toInt(),
0x80123456.toInt(),
0x00000000
)
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = emptyMap(),
textBoxes = emptyList(),
highlights = emptyList(),
rasterOverlays = listOf(
PdfiumRasterOverlay(
pageIndex = 3,
left = 0.1f,
top = 0.2f,
right = 0.8f,
bottom = 0.4f,
width = 2,
height = 2,
pixels = pixels
)
)
)
assertTrue(payload.textPageIndices.isEmpty())
assertTrue(payload.textValues.isEmpty())
assertArrayEquals(intArrayOf(3), payload.rasterPageIndices)
assertArrayEquals(floatArrayOf(0.1f, 0.2f, 0.8f, 0.4f), payload.rasterBounds, 0.0001f)
assertArrayEquals(intArrayOf(2), payload.rasterWidths)
assertArrayEquals(intArrayOf(2), payload.rasterHeights)
assertArrayEquals(intArrayOf(0), payload.rasterPixelOffsets)
assertArrayEquals(pixels, payload.rasterPixels)
assertTrue(payload.hasAnnotations())
}
@Test
fun `buildPayload omits blank text boxes and empty highlight bounds`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = emptyMap(),
textBoxes = listOf(
PdfTextBox(
id = "blank-box",
pageIndex = 0,
relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f),
text = " ",
color = Color.Black,
backgroundColor = Color.Transparent,
fontSize = 12f
)
),
highlights = listOf(
PdfUserHighlight(
pageIndex = 0,
bounds = emptyList(),
color = PdfHighlightColor.YELLOW,
text = "Selected",
range = 0 to 8
)
)
)
assertFalse(payload.hasAnnotations())
assertTrue(payload.textValues.isEmpty())
assertTrue(payload.rasterPixels.isEmpty())
assertTrue(payload.highlightContents.isEmpty())
}
@Test
fun `supportsOriginalPageOrder rejects reordered or blank virtual layouts`() {
assertTrue(PdfiumAnnotationExporter.supportsOriginalPageOrder(null))
assertTrue(
PdfiumAnnotationExporter.supportsOriginalPageOrder(
listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1))
)
)
assertFalse(PdfiumAnnotationExporter.supportsOriginalPageOrder(listOf(VirtualPage.PdfPage(1))))
assertFalse(
PdfiumAnnotationExporter.supportsOriginalPageOrder(
listOf(VirtualPage.PdfPage(0), VirtualPage.BlankPage("blank", 300, 400))
)
)
}
}

View file

@ -6,4 +6,5 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.kover) apply false
}

View file

@ -21,6 +21,14 @@ kotlin {
implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("net.java.dev.jna:jna:5.17.0")
implementation("org.apache.commons:commons-compress:1.28.0")
implementation("org.tukaani:xz:1.10")
implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1")
}
}
val desktopTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
}

View file

@ -0,0 +1,527 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
import com.aryan.reader.shared.ReaderAiByokSettings
import com.sun.jna.Memory
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
import com.sun.jna.WString
import com.sun.jna.ptr.PointerByReference
import com.sun.jna.win32.StdCallLibrary
import java.io.File
import java.util.Base64
import java.util.Properties
private const val WINDOWS_CRED_TYPE_GENERIC = 1
private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2
private const val WINDOWS_ERROR_NOT_FOUND = 1168
internal class DesktopAiByokStore(
private val settingsFile: File = defaultSettingsFile(),
private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform()
) {
val isSecureStorageAvailable: Boolean
get() = secretCodec.isAvailable.also { available ->
logDesktopTts("settings_secure_available codec=${secretCodec.name} available=$available")
}
fun load(): ReaderAiByokSettings {
logDesktopTts(
"settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
"exists=${settingsFile.exists()} secureStorage=${secretCodec.isAvailable}"
)
if (!settingsFile.exists()) {
logDesktopTts("settings_load_empty reason=file_missing")
return ReaderAiByokSettings()
}
val properties = Properties()
return runCatching {
settingsFile.inputStream().use(properties::load)
val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "")
val legacyGroqKey = properties.getProperty(LegacyGroqKey, "")
val loadedSettings = ReaderAiByokSettings(
geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey),
groqKey = loadSecret(properties, GroqKey, legacyGroqKey),
useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true,
modelForAll = properties.getProperty("modelForAll", ""),
defineModel = properties.getProperty("defineModel", ""),
summarizeModel = properties.getProperty("summarizeModel", ""),
recapModel = properties.getProperty("recapModel", ""),
ttsModel = properties.getProperty("ttsModel", ""),
hideReaderAiFeatures = properties.getProperty("hideReaderAiFeatures", "false").toBooleanStrictOrNull() ?: false,
ttsSpeakerId = properties.getProperty("ttsSpeakerId", DEFAULT_CLOUD_TTS_SPEAKER_ID)
).sanitized()
val settings = if (loadedSettings.geminiKey.isNotBlank() && loadedSettings.ttsModel.isBlank()) {
loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID)
} else {
loadedSettings
}
if (secretCodec.isAvailable &&
(legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings)
) {
logDesktopTts(
"settings_load_migrate legacyGemini=${legacyGeminiKey.isNotBlank()} " +
"legacyGroq=${legacyGroqKey.isNotBlank()} autoTtsModel=${settings != loadedSettings}"
)
runCatching { save(settings) }
}
logDesktopTts(
"settings_load_complete geminiKey=${settings.geminiKey.isNotBlank()} groqKey=${settings.groqKey.isNotBlank()} " +
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" cloudAvailable=${settings.isCloudTtsAvailable}"
)
settings
}.getOrElse { error ->
logDesktopTts("settings_load_failed error=\"${error.desktopTtsSummary()}\"")
ReaderAiByokSettings()
}
}
fun save(settings: ReaderAiByokSettings) {
val sanitized = settings.sanitized()
logDesktopTts(
"settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
"secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " +
"groqKey=${sanitized.groqKey.isNotBlank()} ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\""
)
val properties = Properties().apply {
setProtectedSecret(GeminiKey, sanitized.geminiKey)
setProtectedSecret(GroqKey, sanitized.groqKey)
setProperty("useOneModel", sanitized.useOneModel.toString())
setProperty("modelForAll", sanitized.modelForAll)
setProperty("defineModel", sanitized.defineModel)
setProperty("summarizeModel", sanitized.summarizeModel)
setProperty("recapModel", sanitized.recapModel)
setProperty("ttsModel", sanitized.ttsModel)
setProperty("hideReaderAiFeatures", sanitized.hideReaderAiFeatures.toString())
setProperty("ttsSpeakerId", sanitized.ttsSpeakerId)
}
settingsFile.parentFile?.mkdirs()
settingsFile.outputStream().use { output ->
properties.store(output, "Episteme desktop AI keys and models")
}
logDesktopTts(
"settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " +
"groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}"
)
}
private fun loadSecret(properties: Properties, key: String, legacyPlaintext: String): String {
val protectedValue = properties.getProperty(key, "")
val decrypted = protectedValue
.takeIf { it.isNotBlank() }
?.let {
runCatching { secretCodec.unprotect(key, it) }
.onFailure { error -> logDesktopTts("settings_secret_unprotect_failed key=$key error=\"${error.desktopTtsSummary()}\"") }
.getOrDefault("")
}
.orEmpty()
if (decrypted.isNotBlank()) return decrypted
return legacyPlaintext.takeIf { secretCodec.isAvailable }.orEmpty()
}
private fun Properties.setProtectedSecret(key: String, value: String) {
val trimmed = value.trim()
if (trimmed.isBlank()) {
secretCodec.delete(key)
return
}
runCatching { secretCodec.protect(key, trimmed) }
.onSuccess { protectedValue ->
if (protectedValue.isBlank()) {
logDesktopTts("settings_secret_protect_empty key=$key codec=${secretCodec.name}")
} else {
setProperty(key, protectedValue)
logDesktopTts("settings_secret_protect_success key=$key codec=${secretCodec.name} prefix=\"${protectedValue.substringBefore(':', protectedValue)}\"")
}
}
.onFailure { error ->
logDesktopTts("settings_secret_protect_failed key=$key codec=${secretCodec.name} error=\"${error.desktopTtsSummary()}\"")
}
}
companion object {
private const val GeminiKey = "geminiKeyProtected"
private const val GroqKey = "groqKeyProtected"
private const val LegacyGeminiKey = "geminiKey"
private const val LegacyGroqKey = "groqKey"
fun defaultSettingsFile(): File {
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
return File(baseDir, "Episteme/ai-byok.properties")
}
}
}
internal interface DesktopSecretCodec {
val name: String get() = this::class.java.simpleName.ifBlank { "DesktopSecretCodec" }
val isAvailable: Boolean
fun protect(value: String): String
fun unprotect(value: String): String
fun protect(keyName: String, value: String): String = protect(value)
fun unprotect(keyName: String, value: String): String = unprotect(value)
fun delete(keyName: String) = Unit
companion object {
fun platform(): DesktopSecretCodec {
val osName = System.getProperty("os.name").orEmpty()
val codec = if (osName.startsWith("Windows", ignoreCase = true)) {
WindowsSecretCodec
} else {
UnavailableDesktopSecretCodec
}
logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}")
return codec
}
}
}
private object UnavailableDesktopSecretCodec : DesktopSecretCodec {
override val name: String = "unavailable"
override val isAvailable: Boolean = false
override fun protect(value: String): String {
throw IllegalStateException("Secure key storage is unavailable on this operating system.")
}
override fun unprotect(value: String): String = ""
}
private object WindowsSecretCodec : DesktopSecretCodec {
override val name: String = "windows"
override val isAvailable: Boolean
get() {
val wincred = WindowsCredentialSecretCodec.isAvailable
val dpapi = WindowsDpapiSecretCodec.isAvailable
logDesktopTts("settings_windows_available wincred=$wincred dpapi=$dpapi")
return wincred || dpapi
}
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 {
val wincredFailure = runCatching { return WindowsCredentialSecretCodec.protect(keyName, value) }
.exceptionOrNull()
?.also { error -> logDesktopTts("settings_wincred_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
val dpapiFailure = runCatching { return WindowsDpapiSecretCodec.protect(value) }
.exceptionOrNull()
?.also { error -> logDesktopTts("settings_dpapi_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
throw IllegalStateException(
"No Windows secure key store write succeeded. " +
"Credential Manager: ${wincredFailure?.desktopTtsSummary() ?: "not attempted"}; " +
"DPAPI: ${dpapiFailure?.desktopTtsSummary() ?: "not attempted"}"
)
}
override fun unprotect(keyName: String, value: String): String {
return when {
value.startsWith(WindowsCredentialSecretCodec.Prefix) -> WindowsCredentialSecretCodec.unprotect(keyName, value)
value.startsWith(WindowsDpapiSecretCodec.Prefix) -> WindowsDpapiSecretCodec.unprotect(value)
else -> ""
}
}
override fun delete(keyName: String) {
if (WindowsCredentialSecretCodec.isAvailable) WindowsCredentialSecretCodec.delete(keyName)
}
}
private object WindowsCredentialSecretCodec : DesktopSecretCodec {
override val name: String = "wincred"
const val Prefix = "wincred:"
override val isAvailable: Boolean by lazy {
val probeKey = "probe"
val probe = "episteme-wincred-probe"
logDesktopTts("settings_wincred_probe_start")
runCatching {
val reference = protect(probeKey, probe)
val restored = unprotect(probeKey, reference)
val matches = restored == probe
logDesktopTts(
"settings_wincred_probe_result matches=$matches referencePrefix=\"${reference.substringBefore(':', reference)}\" " +
"restoredChars=${restored.length}"
)
matches
}.onFailure { error ->
logDesktopTts("settings_wincred_unavailable error=\"${error.desktopTtsSummary()}\"")
}.also {
runCatching { delete(probeKey) }
}.getOrDefault(false)
}
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 {
val target = credentialTarget(keyName)
logDesktopTts("settings_wincred_write_start key=$keyName target=\"$target\" valueChars=${value.length}")
val credential = WindowsCredential(target, value)
credential.write()
val ok = Advapi32.INSTANCE.CredWriteW(credential, 0)
val errorCode = Native.getLastError()
logDesktopTts("settings_wincred_write_result key=$keyName ok=$ok error=$errorCode")
if (!ok) throw IllegalStateException("Windows Credential Manager write failed: $errorCode")
return Prefix + target
}
override fun unprotect(keyName: String, value: String): String {
if (!value.startsWith(Prefix)) return ""
val target = value.removePrefix(Prefix).ifBlank { credentialTarget(keyName) }
logDesktopTts("settings_wincred_read_start key=$keyName target=\"$target\"")
val credentialPointer = PointerByReference()
val ok = Advapi32.INSTANCE.CredReadW(WString(target), WINDOWS_CRED_TYPE_GENERIC, 0, credentialPointer)
val errorCode = Native.getLastError()
logDesktopTts("settings_wincred_read_result key=$keyName ok=$ok error=$errorCode hasPointer=${credentialPointer.value != null}")
if (!ok) throw IllegalStateException("Windows Credential Manager read failed: $errorCode")
val pointer = credentialPointer.value ?: return ""
return try {
val credential = WindowsCredential(pointer)
val blobPointer = credential.CredentialBlob ?: return ""
logDesktopTts("settings_wincred_read_blob key=$keyName bytes=${credential.CredentialBlobSize}")
String(blobPointer.getByteArray(0, credential.CredentialBlobSize), Charsets.UTF_8)
} finally {
Advapi32.INSTANCE.CredFree(pointer)
}
}
override fun delete(keyName: String) {
val ok = Advapi32.INSTANCE.CredDeleteW(WString(credentialTarget(keyName)), WINDOWS_CRED_TYPE_GENERIC, 0)
val errorCode = Native.getLastError()
logDesktopTts("settings_wincred_delete_result key=$keyName ok=$ok error=$errorCode")
if (!ok && errorCode != WINDOWS_ERROR_NOT_FOUND) {
logDesktopTts("settings_wincred_delete_failed key=$keyName error=$errorCode")
}
}
private fun credentialTarget(keyName: String): String {
return "Episteme.Reader.AI.$keyName"
}
private interface Advapi32 : StdCallLibrary {
fun CredWriteW(credential: WindowsCredential, flags: Int): Boolean
fun CredReadW(targetName: WString, type: Int, flags: Int, credential: PointerByReference): Boolean
fun CredDeleteW(targetName: WString, type: Int, flags: Int): Boolean
fun CredFree(buffer: Pointer?)
companion object {
val INSTANCE: Advapi32 by lazy {
Native.load("Advapi32", Advapi32::class.java) as Advapi32
}
}
}
}
private object WindowsDpapiSecretCodec : DesktopSecretCodec {
override val name: String = "dpapi"
const val Prefix = "dpapi:"
override val isAvailable: Boolean by lazy {
logDesktopTts("settings_dpapi_probe_start")
runCatching {
Crypt32.INSTANCE
Kernel32.INSTANCE
val probe = "episteme-dpapi-probe"
val encrypted = protect(probe)
val restored = unprotect(encrypted)
val matches = restored == probe
logDesktopTts("settings_dpapi_probe_result matches=$matches encryptedChars=${encrypted.length} restoredChars=${restored.length}")
matches
}.onFailure { error ->
logDesktopTts("settings_dpapi_unavailable error=\"${error.desktopTtsSummary()}\"")
}.getOrDefault(false)
}
override fun protect(value: String): String {
val input = DataBlob(value.toByteArray(Charsets.UTF_8))
val output = DataBlob()
logDesktopTts("settings_dpapi_protect_start bytes=${value.toByteArray(Charsets.UTF_8).size}")
val ok = Crypt32.INSTANCE.CryptProtectData(input, null, null, null, null, 0, output)
val errorCode = Native.getLastError()
logDesktopTts("settings_dpapi_protect_result ok=$ok error=$errorCode")
if (!ok) throw IllegalStateException("Windows DPAPI protect failed: $errorCode")
return try {
Prefix + Base64.getEncoder().encodeToString(output.toByteArray())
} finally {
output.free()
}
}
override fun unprotect(value: String): String {
if (!value.startsWith(Prefix)) return ""
val encrypted = Base64.getDecoder().decode(value.removePrefix(Prefix))
val input = DataBlob(encrypted)
val output = DataBlob()
logDesktopTts("settings_dpapi_unprotect_start bytes=${encrypted.size}")
val ok = Crypt32.INSTANCE.CryptUnprotectData(input, null, null, null, null, 0, output)
val errorCode = Native.getLastError()
logDesktopTts("settings_dpapi_unprotect_result ok=$ok error=$errorCode")
if (!ok) throw IllegalStateException("Windows DPAPI unprotect failed: $errorCode")
return try {
String(output.toByteArray(), Charsets.UTF_8)
} finally {
output.free()
}
}
}
@Structure.FieldOrder("dwLowDateTime", "dwHighDateTime")
internal class WindowsFileTime : Structure() {
@JvmField
var dwLowDateTime: Int = 0
@JvmField
var dwHighDateTime: Int = 0
}
@Structure.FieldOrder(
"Flags",
"Type",
"TargetName",
"Comment",
"LastWritten",
"CredentialBlobSize",
"CredentialBlob",
"Persist",
"AttributeCount",
"Attributes",
"TargetAlias",
"UserName"
)
internal open class WindowsCredential : Structure {
@JvmField
var Flags: Int = 0
@JvmField
var Type: Int = WINDOWS_CRED_TYPE_GENERIC
@JvmField
var TargetName: WString? = null
@JvmField
var Comment: WString? = null
@JvmField
var LastWritten: WindowsFileTime = WindowsFileTime()
@JvmField
var CredentialBlobSize: Int = 0
@JvmField
var CredentialBlob: Pointer? = null
@JvmField
var Persist: Int = WINDOWS_CRED_PERSIST_LOCAL_MACHINE
@JvmField
var AttributeCount: Int = 0
@JvmField
var Attributes: Pointer? = null
@JvmField
var TargetAlias: WString? = null
@JvmField
var UserName: WString? = WString("Episteme")
private var blobMemory: Memory? = null
constructor() : super()
constructor(pointer: Pointer) : super(pointer) {
read()
}
constructor(target: String, secret: String) : super() {
val bytes = secret.toByteArray(Charsets.UTF_8)
TargetName = WString(target)
CredentialBlobSize = bytes.size
blobMemory = Memory(bytes.size.toLong()).also { memory ->
memory.write(0, bytes, 0, bytes.size)
CredentialBlob = memory
}
}
}
@Structure.FieldOrder("cbData", "pbData")
internal open class DataBlob() : Structure() {
@JvmField
var cbData: Int = 0
@JvmField
var pbData: Pointer? = null
private var memory: Memory? = null
constructor(bytes: ByteArray) : this() {
cbData = bytes.size
memory = Memory(bytes.size.toLong()).also { allocated ->
allocated.write(0, bytes, 0, bytes.size)
pbData = allocated
}
}
fun toByteArray(): ByteArray {
read()
return pbData?.getByteArray(0, cbData) ?: ByteArray(0)
}
fun free() {
pbData?.let { Kernel32.INSTANCE.LocalFree(it) }
pbData = null
cbData = 0
}
}
private interface Crypt32 : StdCallLibrary {
fun CryptProtectData(
pDataIn: DataBlob,
szDataDescr: String?,
pOptionalEntropy: DataBlob?,
pvReserved: Pointer?,
pPromptStruct: Pointer?,
dwFlags: Int,
pDataOut: DataBlob
): Boolean
fun CryptUnprotectData(
pDataIn: DataBlob,
ppszDataDescr: Pointer?,
pOptionalEntropy: DataBlob?,
pvReserved: Pointer?,
pPromptStruct: Pointer?,
dwFlags: Int,
pDataOut: DataBlob
): Boolean
companion object {
val INSTANCE: Crypt32 by lazy {
Native.load("Crypt32", Crypt32::class.java) as Crypt32
}
}
}
private interface Kernel32 : StdCallLibrary {
fun LocalFree(hMem: Pointer?): Pointer?
companion object {
val INSTANCE: Kernel32 by lazy {
Native.load("Kernel32", Kernel32::class.java) as Kernel32
}
}
}

View file

@ -0,0 +1,310 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.AiAdapter
import com.aryan.reader.shared.AiDefinitionResult
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderAiFeature
import com.aryan.reader.shared.ReaderByokTextRequest
import com.aryan.reader.shared.ReaderByokTextRequestResult
import com.aryan.reader.shared.ReaderByokTextRequests
import com.aryan.reader.shared.RecapResult
import com.aryan.reader.shared.SummarizationResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.HttpURLConnection
import java.net.URL
class DesktopByokAiAdapter(
private val settingsProvider: () -> ReaderAiByokSettings
) : AiAdapter {
override val isAvailable: Boolean
get() = settingsProvider().sanitized().areReaderAiFeaturesAvailable
override suspend fun define(text: String, context: String?): AiDefinitionResult {
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun summarize(text: String): SummarizationResult {
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text)
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
override suspend fun recap(textBeforeCurrentLocation: String): RecapResult {
val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation)
return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message)
}
suspend fun callTextAi(
feature: ReaderAiFeature,
text: String,
context: String? = null
): Result<String> = withContext(Dispatchers.IO) {
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) {
ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden."))
is ReaderByokTextRequestResult.MissingKey -> {
Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models."))
}
is ReaderByokTextRequestResult.MissingModel -> {
Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models."))
}
is ReaderByokTextRequestResult.Ready -> runCatching {
requestResult.request.execute()
}
}
}
private fun ReaderByokTextRequest.execute(): String {
var connection: HttpURLConnection? = null
try {
val url = if (model.provider == "groq") {
URL("https://api.groq.com/openai/v1/chat/completions")
} else {
URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey")
}
connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
setRequestProperty("Accept", "application/json")
if (model.provider == "groq") {
setRequestProperty("Authorization", "Bearer $apiKey")
}
connectTimeout = 15_000
readTimeout = 120_000
doOutput = true
doInput = true
}
val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this)
connection.outputStream.use { output ->
output.write(payload.toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
val errorBody = runCatching {
connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
}.getOrNull()
throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
}
val text = if (model.provider == "groq") {
streamGroqResponse(connection)
} else {
streamGeminiResponse(connection)
}.trim()
if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.")
return text
} finally {
connection?.disconnect()
}
}
private fun buildGroqPayload(request: ReaderByokTextRequest): String {
return buildJsonObject {
put("model", JsonPrimitive(request.model.name))
put(
"messages",
buildJsonArray {
add(buildJsonObject {
put("role", JsonPrimitive("system"))
put("content", JsonPrimitive(request.systemInstruction))
})
add(buildJsonObject {
put("role", JsonPrimitive("user"))
put("content", JsonPrimitive(request.userPrompt))
})
}
)
put("temperature", JsonPrimitive(request.temperature))
put("top_p", JsonPrimitive(0.95))
put("max_tokens", JsonPrimitive(request.maxTokens))
put("stream", JsonPrimitive(true))
if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none"))
}.toString()
}
private fun buildGeminiPayload(request: ReaderByokTextRequest): String {
return buildJsonObject {
put(
"contents",
buildJsonArray {
add(buildJsonObject {
put("parts", buildJsonArray {
add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) })
})
})
}
)
put(
"systemInstruction",
buildJsonObject {
put("parts", buildJsonArray {
add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) })
})
}
)
put(
"generationConfig",
buildJsonObject {
put("temperature", JsonPrimitive(request.temperature))
put("topP", JsonPrimitive(0.95))
put("topK", JsonPrimitive(40))
put("maxOutputTokens", JsonPrimitive(request.maxTokens))
put("response_mime_type", JsonPrimitive("text/plain"))
if (request.model.name.startsWith("gemini")) {
put(
"thinkingConfig",
buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) }
)
}
}
)
}.toString()
}
private fun streamGeminiResponse(connection: HttpURLConnection): String {
val output = StringBuilder()
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var buffer = ""
var line: String?
while (reader.readLine().also { line = it } != null) {
buffer += line
while (true) {
val start = buffer.indexOf('{')
if (start == -1) {
buffer = ""
break
}
var depth = 0
var end = -1
scan@ for (index in start until buffer.length) {
when (buffer[index]) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) {
end = index
break@scan
}
}
}
}
if (end == -1) break
val jsonObject = buffer.substring(start, end + 1)
buffer = buffer.substring(end + 1)
val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull()
output.append(parsed.geminiTextChunk())
if (parsed.geminiFinishReason() == "SAFETY") {
throw IllegalStateException("Blocked for safety reasons.")
}
}
}
}
return output.toString()
}
private fun streamGroqResponse(connection: HttpURLConnection): String {
val output = StringBuilder()
var inThink = false
var thinkBuffer = ""
fun cleanChunk(text: String): String {
thinkBuffer += text
val cleaned = StringBuilder()
while (true) {
if (inThink) {
val end = thinkBuffer.indexOf("</think>")
if (end == -1) {
if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
break
}
inThink = false
thinkBuffer = thinkBuffer.substring(end + 8)
} else {
val start = thinkBuffer.indexOf("<think>")
if (start == -1) {
if (thinkBuffer.length > 6) {
cleaned.append(thinkBuffer.dropLast(6))
thinkBuffer = thinkBuffer.takeLast(6)
}
break
}
cleaned.append(thinkBuffer.substring(0, start))
inThink = true
thinkBuffer = thinkBuffer.substring(start + 7)
}
}
return cleaned.toString()
}
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
val trimmed = line!!.trim()
if (!trimmed.startsWith("data: ")) continue
val data = trimmed.removePrefix("data: ").trim()
if (data == "[DONE]") continue
val chunk = runCatching {
DesktopAiJson.parseToJsonElement(data).jsonObject["choices"]
?.jsonArray
?.firstOrNull()
?.jsonObject
?.get("delta")
?.jsonObject
?.get("content")
?.jsonPrimitive
?.contentOrNull
}.getOrNull().orEmpty()
output.append(cleanChunk(chunk))
}
}
if (!inThink && thinkBuffer.isNotBlank()) output.append(thinkBuffer)
return output.toString()
}
}
private val DesktopAiJson = Json { ignoreUnknownKeys = true }
private fun JsonObject?.geminiTextChunk(): String {
if (this == null) return ""
return this["candidates"]
?.jsonArrayOrNull()
?.firstOrNull()
?.jsonObjectOrNull()
?.get("content")
?.jsonObjectOrNull()
?.get("parts")
?.jsonArrayOrNull()
?.firstOrNull()
?.jsonObjectOrNull()
?.get("text")
?.jsonPrimitiveOrNull()
?.contentOrNull
.orEmpty()
}
private fun JsonObject?.geminiFinishReason(): String? {
if (this == null) return null
return this["candidates"]
?.jsonArrayOrNull()
?.firstOrNull()
?.jsonObjectOrNull()
?.get("finishReason")
?.jsonPrimitiveOrNull()
?.contentOrNull
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive

View file

@ -0,0 +1,586 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.opds.OpdsCatalog
import com.aryan.reader.shared.opds.OpdsStreamReference
import com.sun.jna.Library
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 java.awt.Color
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.OutputStream
import java.net.URL
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
import javax.imageio.ImageIO
import kotlin.math.roundToInt
internal object DesktopComicArchive {
private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7)
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
fun canLoad(type: FileType): Boolean = type in comicTypes
fun load(file: File, type: FileType): DesktopComicDocument {
require(file.isFile) { "Missing comic archive: ${file.absolutePath}" }
require(canLoad(type)) { "${type.name} is not a comic archive type." }
return when (type) {
FileType.CBZ -> loadZip(file)
FileType.CBR -> loadRar(file)
FileType.CB7 -> loadSevenZ(file)
else -> error("${type.name} is not a comic archive type.")
}
}
fun loadOpdsStream(
path: String,
title: String,
reference: OpdsStreamReference,
catalog: OpdsCatalog?
): DesktopComicDocument {
val cacheDir = File(
DesktopLibraryDatabase.defaultDatabaseFile().parentFile,
"opds_stream_cache/${reference.id.hashCode()}"
).apply { mkdirs() }
val pages = (0 until reference.count).map { pageIndex ->
DesktopComicPage(
name = "opds_${pageIndex + 1}.jpg",
width = 800,
height = 1200,
source = OpdsStreamComicPageSource(
pageIndex = pageIndex,
urlTemplate = reference.urlTemplate,
catalog = catalog,
cacheDir = cacheDir
)
)
}
return DesktopComicDocument(
path = path,
title = title,
pages = pages,
closeAction = {}
)
}
private fun loadZip(file: File): DesktopComicDocument {
val zip = ZipFile(file)
return try {
val pages = zip.entries()
.asSequence()
.filter { entry -> !entry.isDirectory && entry.name.isComicImageName() }
.sortedBy { entry -> entry.name.comicSortKey() }
.mapNotNull { entry ->
val bytes = zip.getInputStream(entry).use { it.readBytes() }
val size = decodeImageSize(bytes) ?: return@mapNotNull null
DesktopComicPage(
name = entry.name,
width = size.first,
height = size.second,
source = ZipComicPageSource(zip, entry.name)
)
}
.toList()
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
DesktopComicDocument(
path = file.absolutePath,
title = file.nameWithoutExtension,
pages = pages,
closeAction = { zip.close() }
)
} catch (throwable: Throwable) {
runCatching { zip.close() }
throw throwable
}
}
private fun loadRar(file: File): DesktopComicDocument {
val nativeResult = runCatching { loadRarWithNativeLibarchive(file) }
if (nativeResult.isSuccess) return nativeResult.getOrThrow()
return runCatching { loadWithArchiveCommand(file) }
.getOrElse { commandError ->
error(
"Could not open CBR with libarchive. " +
"Bundle archive.dll/libarchive for desktop, or keep Windows tar/bsdtar available. " +
"Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
"Command: ${commandError.shortMessage()}"
)
}
}
private fun loadRarWithNativeLibarchive(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
return try {
val extracted = DesktopLibarchive.extractImagePages(file, tempDir, imageExtensions)
documentFromExtracted(file, extracted, tempDir)
} catch (throwable: Throwable) {
runCatching { tempDir.deleteRecursively() }
throw throwable
}
}
private fun loadSevenZ(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
val commonsResult = runCatching {
val extracted = mutableListOf<ExtractedComicPage>()
@Suppress("DEPRECATION")
SevenZFile(file).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.copyCurrentEntryTo(output)
}
extracted += ExtractedComicPage(name = name, file = target)
}
entry = archive.nextEntry
}
}
documentFromExtracted(file, extracted, tempDir)
}
if (commonsResult.isSuccess) return commonsResult.getOrThrow()
runCatching { tempDir.deleteRecursively() }
return runCatching { loadWithArchiveCommand(file) }
.getOrElse { commandError ->
error(
"Could not open CB7 with Commons Compress or system tar/bsdtar. " +
"Commons: ${commonsResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
"Command: ${commandError.shortMessage()}"
)
}
}
private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
return try {
val extracted = DesktopArchiveCommand.extractImagePages(file, tempDir, imageExtensions)
documentFromExtracted(file, extracted, tempDir)
} catch (throwable: Throwable) {
runCatching { tempDir.deleteRecursively() }
throw throwable
}
}
private fun documentFromExtracted(
file: File,
extracted: List<ExtractedComicPage>,
tempDir: File
): DesktopComicDocument {
val pages = extracted
.sortedBy { page -> page.name.comicSortKey() }
.mapNotNull { page ->
val bytes = page.file.readBytes()
val size = decodeImageSize(bytes) ?: return@mapNotNull null
DesktopComicPage(
name = page.name,
width = size.first,
height = size.second,
source = FileComicPageSource(page.file)
)
}
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
return DesktopComicDocument(
path = file.absolutePath,
title = file.nameWithoutExtension,
pages = pages,
closeAction = { tempDir.deleteRecursively() }
)
}
private fun SevenZFile.copyCurrentEntryTo(output: OutputStream) {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = read(buffer, 0, buffer.size)
if (read < 0) break
if (read > 0) output.write(buffer, 0, read)
}
}
private fun decodeImageSize(bytes: ByteArray): Pair<Int, Int>? {
val image = ByteArrayInputStream(bytes).use { input ->
ImageIO.read(input)
} ?: return null
return image.width.coerceAtLeast(1) to image.height.coerceAtLeast(1)
}
internal fun String.isComicImageName(): Boolean {
return imageExtension() in imageExtensions
}
internal fun String.imageExtension(): String {
return substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "img")
.lowercase()
.takeIf { it in imageExtensions }
?: "img"
}
internal fun String.comicSortKey(): String {
return replace('\\', '/').lowercase()
}
internal fun Throwable.shortMessage(): String {
return message
?.replace(Regex("\\s+"), " ")
?.take(240)
?.ifBlank { null }
?: javaClass.simpleName
}
internal data class ExtractedComicPage(
val name: String,
val file: File
)
}
internal class DesktopComicDocument(
val path: String,
val title: String,
pages: List<DesktopComicPage>,
private val closeAction: () -> Unit
) {
private val pages = pages.toList()
val pageCount: Int = pages.size
val pageSizes: List<DesktopPdfPageSize> = pages.map { page ->
DesktopPdfPageSize(page.width.toFloat(), page.height.toFloat())
}
fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage {
val page = pages.getOrNull(pageIndex) ?: error("Invalid comic page index $pageIndex.")
val sourceImage = ByteArrayInputStream(page.source.readBytes()).use { input ->
ImageIO.read(input)
} ?: error("Could not decode comic page ${pageIndex + 1}.")
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
val sourceWidth = sourceImage.width.coerceAtLeast(1)
val sourceHeight = sourceImage.height.coerceAtLeast(1)
val width = (sourceWidth * safeScale).roundToInt().coerceAtLeast(1)
val height = (sourceHeight * safeScale).roundToInt().coerceAtLeast(1)
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
try {
graphics.color = Color.WHITE
graphics.fillRect(0, 0, width, height)
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
graphics.drawImage(sourceImage, 0, 0, width, height, null)
} finally {
graphics.dispose()
sourceImage.flush()
}
return image
}
fun close() {
closeAction()
}
}
internal data class DesktopComicPage(
val name: String,
val width: Int,
val height: Int,
val source: ComicPageSource
)
internal interface ComicPageSource {
fun readBytes(): ByteArray
}
private class ZipComicPageSource(
private val zip: ZipFile,
private val entryName: String
) : ComicPageSource {
override fun readBytes(): ByteArray {
val entry = zip.getEntry(entryName) ?: error("Missing comic page entry: $entryName")
return zip.getInputStream(entry).use { it.readBytes() }
}
}
private class FileComicPageSource(
private val file: File
) : ComicPageSource {
override fun readBytes(): ByteArray = file.readBytes()
}
private class OpdsStreamComicPageSource(
private val pageIndex: Int,
private val urlTemplate: String,
private val catalog: OpdsCatalog?,
private val cacheDir: File
) : ComicPageSource {
override fun readBytes(): ByteArray {
val cachedFile = File(cacheDir, "page_$pageIndex.jpg")
if (cachedFile.isFile && cachedFile.length() > 0L) {
return cachedFile.readBytes()
}
return runCatching {
val bytes = DesktopOpdsHttp.fetchBytes(streamPageUrl(), catalog)
if (bytes.isNotEmpty()) {
cachedFile.writeBytes(bytes)
bytes
} else {
error("Empty OPDS stream page response.")
}
}.getOrElse {
errorPageBytes()
}
}
private fun streamPageUrl(): String {
return effectiveTemplate()
.replace("{pageNumber}", pageIndex.toString())
.replace("{page}", pageIndex.toString())
.replace("{maxWidth}", "1600")
.replace("{maxHeight}", "2400")
}
private fun effectiveTemplate(): String {
val catalogUrl = catalog?.url ?: return urlTemplate
if (!urlTemplate.startsWith("http", ignoreCase = true)) return urlTemplate
return runCatching {
val oldUrl = URL(urlTemplate)
val newUrl = URL(catalogUrl)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)
}.getOrDefault(urlTemplate)
}
private fun errorPageBytes(): ByteArray {
val image = BufferedImage(800, 1200, BufferedImage.TYPE_INT_RGB)
val graphics = image.createGraphics()
try {
graphics.color = Color.DARK_GRAY
graphics.fillRect(0, 0, image.width, image.height)
graphics.color = Color.WHITE
graphics.font = Font(Font.SANS_SERIF, Font.BOLD, 36)
val text = "Page unavailable"
val metrics = graphics.fontMetrics
graphics.drawString(text, (image.width - metrics.stringWidth(text)) / 2, image.height / 2)
} finally {
graphics.dispose()
}
return ByteArrayOutputStream().use { output ->
ImageIO.write(image, "jpg", output)
output.toByteArray()
}
}
}
private object DesktopArchiveCommand {
private const val EXTRACT_TIMEOUT_SECONDS = 60L
fun extractImagePages(
file: File,
tempDir: File,
imageExtensions: Set<String>
): List<DesktopComicArchive.ExtractedComicPage> {
val command = resolveCommand()
?: error("No tar/bsdtar command was found on PATH.")
val names = listArchiveEntries(command, file)
.filter { name ->
val extension = name.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
extension in imageExtensions
}
.sortedBy { name -> with(DesktopComicArchive) { name.comicSortKey() } }
require(names.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
return names.mapIndexed { index, name ->
val extension = name.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "img")
.lowercase()
.ifBlank { "img" }
val target = File(tempDir, "page_$index.$extension")
extractEntry(command, file, name, target)
DesktopComicArchive.ExtractedComicPage(name = name, file = target)
}
}
private fun resolveCommand(): String? {
val override = System.getProperty("reader.archive.command")
?: System.getenv("READER_ARCHIVE_COMMAND")
return listOfNotNull(override?.takeIf { it.isNotBlank() }, "bsdtar", "tar")
.firstOrNull(::isCommandAvailable)
}
private fun isCommandAvailable(command: String): Boolean {
return runCatching {
val process = ProcessBuilder(command, "--version")
.redirectErrorStream(true)
.start()
process.inputStream.use { it.readBytes() }
process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0
}.getOrDefault(false)
}
private fun listArchiveEntries(command: String, file: File): List<String> {
val process = ProcessBuilder(command, "-tf", file.absolutePath)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
error("$command timed out while listing ${file.name}.")
}
if (process.exitValue() != 0) {
error(output.ifBlank { "$command could not list ${file.name}." })
}
return output
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.toList()
}
private fun extractEntry(command: String, file: File, entryName: String, target: File) {
val process = ProcessBuilder(command, "-xOf", file.absolutePath, entryName)
.start()
val errorText = StringBuilder()
val errorThread = Thread {
process.errorStream.bufferedReader(Charsets.UTF_8).use { reader ->
errorText.append(reader.readText())
}
}.apply {
isDaemon = true
start()
}
process.inputStream.use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
error("$command timed out while extracting $entryName.")
}
errorThread.join(1000)
if (process.exitValue() != 0) {
error(errorText.toString().ifBlank { "$command could not extract $entryName." })
}
}
}
private object DesktopLibarchive {
private const val ARCHIVE_OK = 0
private const val ARCHIVE_EOF = 1
private const val ARCHIVE_ENTRY_DIRECTORY = 0x4000
private const val BUFFER_SIZE = 128 * 1024
fun extractImagePages(
file: File,
tempDir: File,
imageExtensions: Set<String>
): List<DesktopComicArchive.ExtractedComicPage> {
val archive = api.archive_read_new()
?: error("libarchive could not allocate a reader.")
try {
checkArchive(api.archive_read_support_filter_all(archive), archive, "enable archive filters")
checkArchive(api.archive_read_support_format_all(archive), archive, "enable archive formats")
checkArchive(
api.archive_read_open_filename(archive, file.absolutePath, BUFFER_SIZE.toLong()),
archive,
"open ${file.name}"
)
val pages = mutableListOf<DesktopComicArchive.ExtractedComicPage>()
val entryRef = PointerByReference()
while (true) {
when (val status = api.archive_read_next_header(archive, entryRef)) {
ARCHIVE_OK -> Unit
ARCHIVE_EOF -> break
else -> checkArchive(status, archive, "read archive header")
}
val entry = entryRef.value ?: continue
val name = api.archive_entry_pathname_utf8(entry)
?: api.archive_entry_pathname(entry)
?: continue
val extension = name.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
if (api.archive_entry_filetype(entry) == ARCHIVE_ENTRY_DIRECTORY || extension !in imageExtensions) {
api.archive_read_data_skip(archive)
continue
}
val target = File(tempDir, "page_${pages.size}.${extension.ifBlank { "img" }}")
target.outputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE)
while (true) {
val read = api.archive_read_data(archive, buffer, buffer.size.toLong())
when {
read > 0 -> output.write(buffer, 0, read.toInt())
read == 0L -> break
else -> checkArchive(read.toInt(), archive, "extract $name")
}
}
}
pages += DesktopComicArchive.ExtractedComicPage(name = name, file = target)
}
return pages.sortedBy { page -> with(DesktopComicArchive) { page.name.comicSortKey() } }
} finally {
api.archive_read_free(archive)
}
}
private val api: LibarchiveLibrary by lazy {
val overridePath = System.getProperty("reader.libarchive.path")
?: System.getenv("READER_LIBARCHIVE_PATH")
val candidates = if (overridePath.isNullOrBlank()) {
listOf("archive", "libarchive", "libarchive-13", "libarchive-14")
} else {
listOf(File(overridePath).absolutePath, overridePath)
}
candidates.firstNotNullOfOrNull { candidate ->
runCatching { Native.load(candidate, LibarchiveLibrary::class.java) }
.getOrNull()
} ?: error(
"Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " +
"or bundle archive.dll/libarchive for this platform."
)
}
private fun checkArchive(status: Int, archive: Pointer, action: String) {
if (status >= ARCHIVE_OK) return
val message = api.archive_error_string(archive).orEmpty()
error("libarchive could not $action: ${message.ifBlank { "error code $status" }}")
}
@Suppress("FunctionName")
private interface LibarchiveLibrary : Library {
fun archive_read_new(): Pointer?
fun archive_read_support_filter_all(archive: Pointer): Int
fun archive_read_support_format_all(archive: Pointer): Int
fun archive_read_open_filename(archive: Pointer, fileName: String, blockSize: Long): Int
fun archive_read_next_header(archive: Pointer, entry: PointerByReference): Int
fun archive_read_data(archive: Pointer, buffer: ByteArray, size: Long): Long
fun archive_read_data_skip(archive: Pointer): Int
fun archive_read_free(archive: Pointer): Int
fun archive_error_string(archive: Pointer): String?
fun archive_entry_pathname_utf8(entry: Pointer): String?
fun archive_entry_pathname(entry: Pointer): String?
fun archive_entry_filetype(entry: Pointer): Int
}
}

View file

@ -0,0 +1,143 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.CustomFontItem
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.net.HttpURLConnection
import java.net.URLEncoder
import java.net.URL
import java.util.UUID
class DesktopCustomFontStore(
private val fontsDir: File = defaultFontsDir()
) {
private var googleFontsCache: List<String>? = null
fun importFont(source: File, displayNameOverride: String? = null): Result<CustomFontItem> {
if (!source.isFile) {
return Result.failure(IllegalArgumentException("Choose a font file."))
}
val extension = source.extension.lowercase()
if (extension !in SupportedFontExtensions) {
return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2."))
}
return runCatching {
fontsDir.mkdirs()
val fontId = UUID.randomUUID().toString()
val fileName = "font_$fontId.$extension"
val destination = File(fontsDir, fileName)
source.inputStream().use { input ->
destination.outputStream().use { output -> input.copyTo(output) }
}
CustomFontItem(
id = fontId,
displayName = displayNameOverride?.takeIf { it.isNotBlank() }
?: source.nameWithoutExtension.ifBlank { "Imported font" },
fileName = fileName,
fileExtension = extension,
path = destination.absolutePath,
timestamp = System.currentTimeMillis()
)
}
}
fun deleteFont(font: CustomFontItem): Boolean {
val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false
val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false
val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root }
if (!insideFontStore) return false
return !target.exists() || target.delete()
}
fun loadGoogleFontsList(): List<String> {
googleFontsCache?.let { return it }
val loaded = runCatching {
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource)
?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource)
?: return@runCatching emptyList()
stream.bufferedReader(Charsets.UTF_8).use { reader ->
googleFontsFromJson(reader.readText())
}
}.getOrDefault(emptyList())
googleFontsCache = loaded
return loaded
}
fun downloadGoogleFont(fontName: String): Result<CustomFontItem> {
val normalizedFontName = fontName.trim()
if (normalizedFontName.isBlank()) {
return Result.failure(IllegalArgumentException("Choose a Google Font."))
}
return runCatching {
val encodedName = URLEncoder.encode(normalizedFontName, Charsets.UTF_8.name())
val cssConnection = URL("https://fonts.googleapis.com/css?family=$encodedName")
.openConnection() as HttpURLConnection
cssConnection.setRequestProperty("User-Agent", GoogleFontsSafariUserAgent)
cssConnection.connectTimeout = 15_000
cssConnection.readTimeout = 15_000
if (cssConnection.responseCode != HttpURLConnection.HTTP_OK) {
throw IllegalStateException("Font '$normalizedFontName' was not found on Google Fonts.")
}
val css = cssConnection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
val fontUrl = googleFontDownloadUrlFromCss(css)
?: throw IllegalStateException("Could not parse a download link for $normalizedFontName.")
val extension = googleFontFileExtension(fontUrl)
if (extension !in SupportedFontExtensions) {
throw IllegalStateException("Unsupported format ($extension) returned for $normalizedFontName.")
}
val tempFile = File.createTempFile("episteme_google_font_", ".$extension")
try {
val fontConnection = URL(fontUrl).openConnection() as HttpURLConnection
fontConnection.connectTimeout = 15_000
fontConnection.readTimeout = 30_000
fontConnection.inputStream.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
}
importFont(tempFile, displayNameOverride = normalizedFontName).getOrThrow()
} finally {
tempFile.delete()
}
}
}
companion object {
private val SupportedFontExtensions = setOf("ttf", "otf", "woff2")
private const val GoogleFontsResource = "google_fonts.json"
private const val GoogleFontsSafariUserAgent =
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1"
fun defaultFontsDir(): File {
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
return File(baseDir, "Episteme/custom_fonts")
}
}
}
internal fun googleFontDownloadUrlFromCss(css: String): String? {
return Regex("""url\((https://[^)]+)\)""")
.find(css)
?.groupValues
?.getOrNull(1)
}
internal fun googleFontFileExtension(fontUrl: String): String {
return fontUrl.substringBefore('?')
.substringAfterLast('.', "ttf")
.lowercase()
}
internal fun googleFontsFromJson(rawJson: String): List<String> {
return Json.parseToJsonElement(rawJson)
.jsonArray
.mapNotNull { element ->
runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull()
}
}

View file

@ -1,282 +1,12 @@
package com.aryan.reader.desktop
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.OptimizedCssRules
import com.aryan.reader.paginatedreader.UserAgentStylesheet
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter
import com.aryan.reader.shared.reader.SharedJvmBookLoader
import java.io.File
import java.util.Base64
import java.util.UUID
import java.util.zip.ZipFile
object DesktopEpubLoader {
fun load(file: File): SharedEpubBook {
ZipFile(file).use { zip ->
val container = zip.readText("META-INF/container.xml")
val opfPath = container
.substringAfter("full-path=\"", missingDelimiterValue = "")
.substringBefore("\"")
.ifBlank { error("EPUB container does not point to an OPF package.") }
val opf = zip.readText(opfPath)
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
.let { if (it.isBlank()) "" else "$it/" }
val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
val author = opf.tagText("creator").ifBlank { null }
val manifest = parseManifest(opf)
val cssByPath = loadCss(zip, manifest, basePath)
val cssRules = parseCssRules(cssByPath)
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
.findAll(opf)
.mapNotNull { match -> manifest[match.groupValues[1]] }
.toList()
val chapterPaths = spine.ifEmpty {
manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
}
val chapters = chapterPaths.mapIndexedNotNull { index, href ->
val path = normalizeZipPath(basePath + href)
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
val text = htmlToText(html)
val semanticBlocks = runCatching {
htmlToSemanticBlocks(
html = resourceReadyHtml,
cssRules = cssRules,
textStyle = TextStyle(fontSize = 18.sp),
chapterAbsPath = path,
extractionBasePath = "",
density = Density(1f),
fontFamilyMap = emptyMap(),
constraints = Constraints(maxWidth = 980, maxHeight = 720)
)
}.getOrElse { emptyList() }
if (text.isBlank()) {
null
} else {
SharedEpubChapter(
id = "chapter_$index",
title = html.tagText("h1")
.ifBlank { html.tagText("h2") }
.ifBlank { html.tagText("title") }
.ifBlank { "Chapter ${index + 1}" },
plainText = text,
semanticBlocks = semanticBlocks,
htmlContent = resourceReadyHtml.extractBodyOrSelf(),
baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
)
}
}
return SharedEpubBook(
id = file.absolutePath,
fileName = file.name,
title = title,
author = author,
css = cssByPath,
chapters = chapters.ifEmpty {
listOf(
SharedEpubChapter(
id = UUID.randomUUID().toString(),
title = title,
plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
)
)
}
)
}
}
private fun parseManifest(opf: String): Map<String, String> {
return Regex("<item\\s+[^>]*>").findAll(opf).mapNotNull { match ->
val item = match.value
val id = item.attr("id")
val href = item.attr("href")
if (id.isBlank() || href.isBlank()) null else id to href
}.toMap()
}
private fun loadCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
return manifest.values
.filter { it.endsWith(".css", ignoreCase = true) }
.mapNotNull { href ->
val path = normalizeZipPath(basePath + href)
val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
if (css.isBlank()) null else path to css
}
.toMap()
}
private fun parseCssRules(cssByPath: Map<String, String>): OptimizedCssRules {
val constraints = Constraints(maxWidth = 980, maxHeight = 720)
val baseRules = CssParser.parse(
cssContent = UserAgentStylesheet.default,
cssPath = null,
baseFontSizeSp = 18f,
density = 1f,
constraints = constraints,
isDarkTheme = false
).rules
return cssByPath.entries
.fold(baseRules) { rules, (path, css) ->
if (css.isBlank()) {
rules
} else {
rules.merge(
CssParser.parse(
cssContent = css,
cssPath = path,
baseFontSizeSp = 18f,
density = 1f,
constraints = constraints,
isDarkTheme = false
).rules
)
}
}
}
private fun ZipFile.readText(path: String): String {
val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
return getInputStream(entry).bufferedReader().use { it.readText() }
}
private fun ZipFile.readTextOrNull(path: String): String? {
val entry = getEntry(path) ?: return null
return getInputStream(entry).bufferedReader().use { it.readText() }
}
private fun String.attr(name: String): String {
return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
}
private fun String.tagText(tag: String): String {
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.get(1)
?.let(::htmlToText)
.orEmpty()
}
private fun normalizeZipPath(path: String): String {
val parts = ArrayDeque<String>()
path.split('/').forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
val attr = match.groupValues[1]
val raw = match.groupValues[2]
if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
return@replace match.value
}
val dataUri = zip.toDataUri(raw, chapterPath)
if (dataUri != null) "$attr=\"$dataUri\"" else match.value
}
}
private fun String.looksLikeEmbeddableResource(): Boolean {
return substringBefore('#')
.substringBefore('?')
.substringAfterLast('.', "")
.lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
}
private fun String.sanitizeReaderHtml(): String {
return replace(Regex("(?is)<script\\b.*?</script>"), "")
.replace(Regex("(?is)<object\\b.*?</object>"), "")
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
}
private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
val raw = match.groupValues[2].trim()
val dataUri = zip.toDataUri(raw, cssPath)
if (dataUri != null) "url('$dataUri')" else match.value
}
}
private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
val ref = rawRef.substringBefore('#').trim()
if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
val entry = getEntry(path) ?: return null
val bytes = getInputStream(entry).use { it.readBytes() }
return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
}
private fun mimeType(path: String): String {
return when (path.substringAfterLast('.', "").lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
"ttf" -> "font/ttf"
"otf" -> "font/otf"
"woff" -> "font/woff"
"woff2" -> "font/woff2"
"css" -> "text/css"
"js" -> "text/javascript"
else -> "application/octet-stream"
}
}
private fun String.extractBodyOrSelf(): String {
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
.find(this)
?.groupValues
?.get(1)
?.trim()
?: this
}
private fun htmlToText(html: String): String {
return html
.replace(Regex("(?is)<script.*?</script>"), "")
.replace(Regex("(?is)<style.*?</style>"), "")
.replace(Regex("(?i)<br\\s*/?>"), "\n")
.replace(Regex("(?i)</p\\s*>"), "\n\n")
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
.replace(Regex("<[^>]+>"), " ")
.decodeEntities()
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
.replace(Regex(" *\\n *"), "\n")
.replace(Regex("\\n{3,}"), "\n\n")
.trim()
}
private fun String.decodeEntities(): String {
return replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
}
.replace(Regex("&#(\\d+);")) { match ->
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
}
return SharedJvmBookLoader.load(file, FileType.EPUB)
}
}

View file

@ -0,0 +1,530 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.reader.SharedJvmBookLoader
import java.awt.Color
import java.awt.Font
import java.awt.GradientPaint
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.zip.ZipFile
import javax.imageio.ImageIO
import kotlin.math.max
data class DesktopFolderMetadataExtractionResult(
val books: List<BookItem>,
val stats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats()
)
data class DesktopFolderMetadataExtractionStats(
val processedBooks: Int = 0,
val updatedBooks: Int = 0,
val coversUpdated: Int = 0,
val failedBooks: Int = 0
) {
operator fun plus(other: DesktopFolderMetadataExtractionStats): DesktopFolderMetadataExtractionStats {
return DesktopFolderMetadataExtractionStats(
processedBooks = processedBooks + other.processedBooks,
updatedBooks = updatedBooks + other.updatedBooks,
coversUpdated = coversUpdated + other.coversUpdated,
failedBooks = failedBooks + other.failedBooks
)
}
}
object DesktopFolderMetadataExtractor {
private val textMetadataTypes = setOf(
FileType.PDF,
FileType.EPUB,
FileType.HTML,
FileType.MOBI,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
private val generatedCoverTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
fun enrichFolderBooks(
books: List<BookItem>,
sourceFolder: String
): DesktopFolderMetadataExtractionResult {
return enrichBooks(books) { book -> book.sourceFolder == sourceFolder }
}
fun enrichImportedBooks(
books: List<BookItem>,
importedBookIds: Set<String>
): DesktopFolderMetadataExtractionResult {
if (importedBookIds.isEmpty()) {
return DesktopFolderMetadataExtractionResult(books)
}
return enrichBooks(books) { book -> book.id in importedBookIds }
}
private fun enrichBooks(
books: List<BookItem>,
shouldConsider: (BookItem) -> Boolean
): DesktopFolderMetadataExtractionResult {
var stats = DesktopFolderMetadataExtractionStats()
val updatedBooks = books.map { book ->
if (!shouldConsider(book) || !book.needsFolderMetadataExtraction()) {
return@map book
}
stats = stats.copy(processedBooks = stats.processedBooks + 1)
val updated = runCatching { enrichBook(book) }
.onFailure { stats = stats.copy(failedBooks = stats.failedBooks + 1) }
.getOrDefault(book)
if (updated != book) {
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
if (updated.coverImagePath != book.coverImagePath) {
stats = stats.copy(coversUpdated = stats.coversUpdated + 1)
}
}
updated
}
return DesktopFolderMetadataExtractionResult(updatedBooks, stats)
}
private fun BookItem.needsFolderMetadataExtraction(): Boolean {
val path = path?.takeIf { it.isNotBlank() } ?: return false
val file = File(path)
if (!file.isFile) return false
val needsTextMetadata = type in textMetadataTypes && !folderTextMetadataParsed
val needsCover = type in generatedCoverTypes && coverImagePath?.let { File(it).isFile } != true
return needsTextMetadata || needsCover
}
private fun enrichBook(book: BookItem): BookItem {
val file = File(book.path.orEmpty())
val size = file.length().takeIf { it > 0L } ?: book.fileSize
var title = book.title
var author = book.author
var textMetadataParsed = book.folderTextMetadataParsed
var embeddedCover: EmbeddedCover? = null
when (book.type) {
FileType.EPUB -> {
val metadata = parseEpubMetadata(file)
title = sanitizeTitle(metadata.title) ?: title
author = sanitizeAuthor(metadata.author) ?: author
embeddedCover = metadata.cover
textMetadataParsed = true
}
FileType.PDF -> {
val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull()
title = sanitizeTitle(metadata?.title) ?: title
author = sanitizeAuthor(metadata?.author) ?: author
textMetadataParsed = true
}
FileType.HTML -> {
title = sanitizeTitle(parseHtmlTitle(file)) ?: title
textMetadataParsed = true
}
FileType.MOBI,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT -> {
runCatching { SharedJvmBookLoader.load(file, book.type) }
.onSuccess { loaded ->
title = sanitizeTitle(loaded.title) ?: title
author = sanitizeAuthor(loaded.author) ?: author
textMetadataParsed = true
}
}
else -> Unit
}
val coverPath = book.coverImagePath?.takeIf { File(it).isFile }
?: saveEmbeddedCover(book, embeddedCover)
?: renderReaderSurfaceCover(book, file)
?: saveGeneratedCover(book)
return book.copy(
title = title ?: file.nameWithoutExtension,
author = author,
fileSize = size,
coverImagePath = coverPath,
folderTextMetadataParsed = textMetadataParsed
)
}
private fun parseEpubMetadata(file: File): ExtractedBookMetadata {
ZipFile(file).use { zip ->
val containerXml = zip.readTextOrNull("META-INF/container.xml")
val opfPath = containerXml
?.let(::parseEpubRootfilePath)
?: zip.entries().asSequence()
.map { it.name }
.firstOrNull { it.endsWith(".opf", ignoreCase = true) }
?: return ExtractedBookMetadata()
val opf = zip.readTextOrNull(opfPath) ?: return ExtractedBookMetadata()
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
.let { if (it.isBlank()) "" else "$it/" }
val manifest = parseEpubManifest(opf)
val cover = findEpubCover(opf, manifest)
?.takeIf { it.isRasterCover }
?.let { item ->
val coverPath = normalizeZipPath(basePath + item.href)
zip.readBytesOrNull(coverPath)?.let { bytes ->
EmbeddedCover(bytes = bytes, extension = item.rasterExtension ?: "png")
}
}
return ExtractedBookMetadata(
title = opf.tagText("title"),
author = opf.tagText("creator"),
cover = cover
)
}
}
private fun parseEpubRootfilePath(containerXml: String): String? {
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
.find(containerXml)
?.groupValues
?.get(1)
?.takeIf { it.isNotBlank() }
}
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
.findAll(opf)
.mapNotNull { match ->
val item = match.value
val id = item.attr("id")
val href = item.attr("href")
if (id.isBlank() || href.isBlank()) {
null
} else {
EpubManifestItem(
id = id,
href = href,
mediaType = item.attr("media-type"),
properties = item.attr("properties")
)
}
}
.toList()
}
private fun findEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
.findAll(opf)
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
?.value
?.attr("content")
?.takeIf { it.isNotBlank() }
return manifest.firstOrNull { it.id == coverId }
?: manifest.firstOrNull { it.properties.split(Regex("\\s+")).any { property -> property == "cover-image" } }
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("cover", ignoreCase = true) }
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("front", ignoreCase = true) }
}
private fun parseHtmlTitle(file: File): String? {
return runCatching {
val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
buildString {
var remaining = 64 * 1024
val buffer = CharArray(2048)
while (remaining > 0) {
val read = reader.read(buffer, 0, minOf(buffer.size, remaining))
if (read <= 0) break
append(buffer, 0, read)
remaining -= read
if (contains("</title>", ignoreCase = true)) break
}
}
}
head.tagText("title")
}.getOrNull()
}
private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? {
if (cover == null || cover.bytes.isEmpty()) return null
val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null
return runCatching {
deleteExistingCoverFiles(book)
val target = coverCacheFile(book, extension)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.tmp")
temp.writeBytes(cover.bytes)
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
target.absolutePath
}.getOrNull()
}
private fun renderReaderSurfaceCover(book: BookItem, file: File): String? {
if (book.type == FileType.PDF && !DesktopPdfium.isAvailable()) return null
if (book.type != FileType.PDF && !DesktopComicArchive.canLoad(book.type)) return null
return runCatching {
val document = if (book.type == FileType.PDF) {
DesktopPdfium.load(file)
} else {
DesktopPdfium.loadComic(file, book.type)
}
try {
if (document.pageCount <= 0) {
null
} else {
val firstPage = document.pageSizes.first()
val scale = 800f / firstPage.height.coerceAtLeast(1f)
val image = DesktopPdfium.renderPageBufferedImage(
document = document,
pageIndex = 0,
scale = scale,
renderAnnotations = false
)
saveCoverImage(book, image)
}
} finally {
document.close()
}
}.getOrNull()
}
private fun saveGeneratedCover(book: BookItem): String? {
if (book.type !in generatedCoverTypes) return null
return saveCoverImage(book, generatedCoverImage(book))
}
private fun saveCoverImage(book: BookItem, image: BufferedImage): String? {
return runCatching {
deleteExistingCoverFiles(book)
val target = coverCacheFile(book, "png")
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.tmp")
ImageIO.write(image, "png", temp)
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
target.absolutePath
}.getOrNull()
}
private fun generatedCoverImage(book: BookItem): BufferedImage {
val width = 480
val height = 720
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val base = coverColor(book.type)
val title = book.title?.takeIf { it.isNotBlank() }
?: book.displayName.substringBeforeLast('.', missingDelimiterValue = book.displayName)
val author = book.author?.takeIf { it.isNotBlank() }
val g = image.createGraphics()
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.paint = GradientPaint(0f, 0f, base.brighter(), 0f, height.toFloat(), base.darker())
g.fillRect(0, 0, width, height)
g.color = Color(255, 255, 255, 36)
g.fillRoundRect(42, 42, width - 84, height - 84, 36, 36)
g.color = Color(255, 255, 255, 210)
g.font = Font("SansSerif", Font.BOLD, 34)
g.drawString(book.type.name, 64, 104)
g.font = Font("Serif", Font.BOLD, 48)
val titleLines = wrapText(title, g.fontMetrics, width - 128, maxLines = 6)
var y = 250
titleLines.forEach { line ->
g.drawString(line, 64, y)
y += 58
}
g.font = Font("SansSerif", Font.PLAIN, 28)
val footer = author ?: book.displayName
val footerLines = wrapText(footer, g.fontMetrics, width - 128, maxLines = 2)
val footerStart = max(y + 40, height - 150)
footerLines.forEachIndexed { index, line ->
g.drawString(line, 64, footerStart + index * 34)
}
} finally {
g.dispose()
}
return image
}
private fun wrapText(text: String, metrics: java.awt.FontMetrics, maxWidth: Int, maxLines: Int): List<String> {
val words = text.replace(Regex("\\s+"), " ").trim().split(' ').filter { it.isNotBlank() }
if (words.isEmpty()) return listOf("Untitled")
val lines = mutableListOf<String>()
var current = ""
for (word in words) {
val candidate = if (current.isBlank()) word else "$current $word"
if (metrics.stringWidth(candidate) <= maxWidth) {
current = candidate
} else {
if (current.isNotBlank()) lines += current
current = trimToWidth(word, metrics, maxWidth)
}
if (lines.size == maxLines) break
}
if (lines.size < maxLines && current.isNotBlank()) lines += current
return lines.take(maxLines)
}
private fun trimToWidth(text: String, metrics: java.awt.FontMetrics, maxWidth: Int): String {
if (metrics.stringWidth(text) <= maxWidth) return text
var candidate = text
while (candidate.length > 1 && metrics.stringWidth("$candidate...") > maxWidth) {
candidate = candidate.dropLast(1)
}
return "$candidate..."
}
private fun coverColor(type: FileType): Color {
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.MD -> Color(83, 101, 120)
FileType.HTML -> Color(122, 87, 42)
FileType.TXT -> Color(74, 92, 112)
else -> Color(93, 107, 130)
}
}
private fun coverCacheFile(book: BookItem, extension: String): File {
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
val hash = Integer.toUnsignedString(key.hashCode())
return File(coverCacheDir(), "cover_$hash.$extension")
}
private fun deleteExistingCoverFiles(book: BookItem) {
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
val hash = Integer.toUnsignedString(key.hashCode())
coverCacheDir().listFiles()
?.filter { it.isFile && it.name.startsWith("cover_$hash.") }
?.forEach { runCatching { it.delete() } }
}
private fun coverCacheDir(): File {
val overridePath = System.getProperty("reader.cover.cache.dir")
?: System.getenv("READER_COVER_CACHE_DIR")
if (!overridePath.isNullOrBlank()) {
return File(overridePath).apply { mkdirs() }
}
val root = DesktopLibraryDatabase.defaultDatabaseFile().parentFile
?: File(System.getProperty("user.home"), "AppData/Roaming/Episteme")
return File(root, "cover_cache").apply { mkdirs() }
}
private fun ZipFile.readTextOrNull(path: String): String? {
val entry = getEntry(path) ?: return null
return getInputStream(entry).bufferedReader(Charsets.UTF_8).use { it.readText() }
}
private fun ZipFile.readBytesOrNull(path: String): ByteArray? {
val entry = getEntry(path) ?: return null
return getInputStream(entry).use { it.readBytes() }
}
private fun String.attr(name: String): String {
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.get(1)
.orEmpty()
}
private fun String.tagText(tag: String): String {
return Regex(
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
.find(this)
?.groupValues
?.get(1)
?.replace(Regex("<[^>]+>"), " ")
?.decodeEntities()
?.replace(Regex("\\s+"), " ")
?.trim()
.orEmpty()
}
private fun String.decodeEntities(): String {
return replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
}
.replace(Regex("&#(\\d+);")) { match ->
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
}
}
private fun normalizeZipPath(path: String): String {
val parts = ArrayDeque<String>()
path.split('/').forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (parts.isNotEmpty()) parts.removeLast()
else -> parts.addLast(part)
}
}
return parts.joinToString("/")
}
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 val EpubManifestItem.isRasterCover: Boolean
get() = rasterExtension != null
private val EpubManifestItem.rasterExtension: String?
get() {
val extension = href.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
if (extension in rasterCoverExtensions) return extension
return when {
mediaType.equals("image/jpeg", ignoreCase = true) -> "jpg"
mediaType.equals("image/png", ignoreCase = true) -> "png"
mediaType.equals("image/gif", ignoreCase = true) -> "gif"
mediaType.equals("image/webp", ignoreCase = true) -> "webp"
mediaType.equals("image/bmp", ignoreCase = true) -> "bmp"
else -> null
}
}
private data class ExtractedBookMetadata(
val title: String? = null,
val author: String? = null,
val cover: EmbeddedCover? = null
)
private data class EmbeddedCover(
val bytes: ByteArray,
val extension: String
)
private data class EpubManifestItem(
val id: String,
val href: String,
val mediaType: String,
val properties: String
)
}

View file

@ -0,0 +1,732 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL
import com.aryan.reader.shared.ReaderAiByokSettings
import com.aryan.reader.shared.ReaderTtsCacheSummary
import com.aryan.reader.shared.ReaderTtsChunk
import com.aryan.reader.shared.ReaderTtsFileCacheManager
import com.aryan.reader.shared.ReaderTtsReadScope
import com.aryan.reader.shared.TtsAdapter
import com.aryan.reader.shared.createReaderTtsWavHeaderUnknownLength
import com.aryan.reader.shared.patchReaderTtsWavHeader
import com.aryan.reader.shared.splitReaderTextIntoTtsChunks
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.io.FileOutputStream
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.WebSocket
import java.nio.ByteBuffer
import java.util.Base64
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionStage
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.SourceDataLine
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
private data class DesktopTtsSequenceChunk(
val text: String,
val chapterTitle: String?
)
class DesktopGeminiCloudTtsAdapter(
private val settingsProvider: () -> ReaderAiByokSettings,
private val httpClient: HttpClient = HttpClient.newHttpClient(),
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
) : TtsAdapter {
@Volatile
private var activeLine: SourceDataLine? = null
@Volatile
private var activeWebSocket: WebSocket? = null
@Volatile
private var activePlayer: DesktopStreamingPcmPlayer? = null
override val isAvailable: Boolean
get() = settingsProvider().sanitized().isCloudTtsAvailable
override suspend fun speak(text: String) {
val trimmed = text.trim()
logDesktopTts("speak_start textChars=${trimmed.length}")
if (trimmed.isBlank()) return
speakSequence(splitReaderTextIntoTtsChunks(trimmed).ifEmpty { listOf(trimmed.take(5_000)) })
logDesktopTts("speak_finished")
}
suspend fun speakSequence(
texts: List<String>,
onChunkStart: suspend (Int) -> Unit = {}
) {
val normalizedChunks = texts
.flatMap { text -> splitReaderTextIntoTtsChunks(text).ifEmpty { listOf(text.trim()) } }
.map { text -> DesktopTtsSequenceChunk(text = text.trim().take(5_000), chapterTitle = null) }
.filter { it.text.isNotBlank() }
logDesktopTts(
"sequence_speak_start chunks=${normalizedChunks.size} totalTextChars=${normalizedChunks.sumOf { it.text.length }}"
)
if (normalizedChunks.isEmpty()) return
val callbackContext = coroutineContext
stop()
streamSequence("Desktop selection", normalizedChunks, callbackContext, onChunkStart)
logDesktopTts("sequence_speak_finished chunks=${normalizedChunks.size}")
}
suspend fun speakChunks(
bookTitle: String,
readScope: ReaderTtsReadScope,
chunks: List<ReaderTtsChunk>,
onChunkStart: suspend (Int) -> Unit = {}
) {
val sequenceChunks = chunks
.map { chunk ->
DesktopTtsSequenceChunk(
text = chunk.spokenText.trim().ifBlank { chunk.text.trim() }.take(5_000),
chapterTitle = chunk.chapterTitle.ifBlank { readScope.label }
)
}
.filter { it.text.isNotBlank() }
logDesktopTts(
"chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
"chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}"
)
if (sequenceChunks.isEmpty()) return
val callbackContext = coroutineContext
stop()
streamSequence(bookTitle.ifBlank { "Untitled" }, sequenceChunks, callbackContext, onChunkStart)
logDesktopTts("chunk_sequence_speak_finished chunks=${sequenceChunks.size}")
}
override suspend fun pause() {
withContext(Dispatchers.IO) {
activePlayer?.pause()
}
}
override suspend fun resume() {
withContext(Dispatchers.IO) {
activePlayer?.resume()
}
}
fun cacheSummary(bookTitle: String, speakerId: String? = settingsProvider().sanitized().ttsSpeakerId): ReaderTtsCacheSummary {
return cacheManager.getCacheSummary(bookTitle.ifBlank { "Untitled" }, speakerId)
}
fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String = settingsProvider().sanitized().ttsSpeakerId) {
cacheManager.clearBookCacheForSpeaker(bookTitle.ifBlank { "Untitled" }, speakerId)
}
fun clearBookCache(bookTitle: String) {
cacheManager.clearBookCache(bookTitle.ifBlank { "Untitled" })
}
override suspend fun stop() {
withContext(Dispatchers.IO) {
logDesktopTts("stop_requested hasWebSocket=${activeWebSocket != null} hasLine=${activeLine != null}")
runCatching { activeWebSocket?.abort() }
activeWebSocket = null
runCatching { activePlayer?.closeNow() }
activePlayer = null
runCatching { activeLine?.stop() }
runCatching { activeLine?.flush() }
runCatching { activeLine?.close() }
activeLine = null
logDesktopTts("stop_complete")
}
}
private suspend fun streamSequence(
bookTitle: String,
chunks: List<DesktopTtsSequenceChunk>,
callbackContext: CoroutineContext,
onChunkStart: suspend (Int) -> Unit
) = withContext(Dispatchers.IO) {
val settings = settingsProvider().sanitized()
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}"
)
if (!settings.isCloudTtsAvailable) {
logDesktopTts("stream_blocked reason=not_available")
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
}
val audioBytesReceived = AtomicLong(0)
val currentTurnAudioBytesReceived = AtomicLong(0)
val player = DesktopStreamingPcmPlayer { activeLine = it }
activePlayer = player
val setupComplete = CompletableDeferred<Unit>()
val currentTurnComplete = AtomicReference<CompletableDeferred<Unit>?>(null)
val activeCacheOutput = AtomicReference<FileOutputStream?>(null)
val failure = CompletableDeferred<Throwable>()
val messageBuffer = StringBuilder()
var webSocket: WebSocket? = null
var activeTempCacheFile: File? = null
fun handleMessage(message: String) {
handleGeminiTtsMessage(
message = message,
setupComplete = setupComplete,
turnComplete = currentTurnComplete.get(),
failure = failure,
onAudioPart = { bytes ->
audioBytesReceived.addAndGet(bytes.size.toLong())
currentTurnAudioBytesReceived.addAndGet(bytes.size.toLong())
activeCacheOutput.get()?.let { output ->
runCatching { output.write(bytes) }
.onFailure { error ->
logDesktopTts("cache_write_failed error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
}
}
runCatching { player.write(bytes) }
.onFailure { error ->
logDesktopTts("stream_audio_write_failed error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
}
}
)
}
val listener = object : WebSocket.Listener {
override fun onOpen(webSocket: WebSocket) {
activeWebSocket = webSocket
webSocket.request(1)
logDesktopTts("ws_open send_setup model=\"$GEMINI_CLOUD_TTS_MODEL\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\"")
webSocket.sendText(buildGeminiTtsSetup(settings.ttsSpeakerId), true)
.whenComplete { _, error ->
if (error != null) {
logDesktopTts("ws_setup_send_failed error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
} else {
logDesktopTts("ws_setup_send_complete")
}
}
}
override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*> {
messageBuffer.append(data)
logDesktopTts("ws_message_text chunkChars=${data.length} last=$last bufferChars=${messageBuffer.length}")
if (last) {
val message = messageBuffer.toString()
messageBuffer.clear()
handleMessage(message)
}
webSocket.request(1)
return CompletableFuture.completedFuture(null)
}
override fun onBinary(webSocket: WebSocket, data: ByteBuffer, last: Boolean): CompletionStage<*> {
val bytes = ByteArray(data.remaining())
data.get(bytes)
messageBuffer.append(bytes.decodeToString())
logDesktopTts("ws_message_binary chunkBytes=${bytes.size} last=$last bufferChars=${messageBuffer.length}")
if (last) {
val message = messageBuffer.toString()
messageBuffer.clear()
handleMessage(message)
}
webSocket.request(1)
return CompletableFuture.completedFuture(null)
}
override fun onError(webSocket: WebSocket, error: Throwable) {
logDesktopTts("ws_error error=\"${error.desktopTtsSummary()}\"")
failure.complete(error)
}
override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*> {
val activeTurn = currentTurnComplete.get()
logDesktopTts(
"ws_close status=$statusCode reason=\"${reason.desktopTtsPreview()}\" " +
"setupComplete=${setupComplete.isCompleted} turnComplete=${activeTurn?.isCompleted}"
)
if (!setupComplete.isCompleted && !failure.isCompleted) {
failure.complete(IllegalStateException("Cloud TTS connection closed before setup: $reason"))
} else if (activeTurn != null && !activeTurn.isCompleted && !failure.isCompleted) {
failure.complete(IllegalStateException("Cloud TTS connection closed: $reason"))
}
return CompletableFuture.completedFuture(null)
}
}
suspend fun ensureWebSocket(): WebSocket {
webSocket?.let { return it }
val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name())
val uri = URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey")
logDesktopTts("ws_connect_start endpoint=GeminiLive keyChars=${settings.geminiKey.length}")
val connectedWebSocket = runCatching {
httpClient.newWebSocketBuilder()
.buildAsync(uri, listener)
.get(15, TimeUnit.SECONDS)
}.getOrElse { error ->
logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"")
throw error
}
activeWebSocket = connectedWebSocket
webSocket = connectedWebSocket
logDesktopTts("ws_connect_complete")
logDesktopTts("setup_wait_start timeoutMs=15000")
withTimeout(15_000) {
select<Unit> {
setupComplete.onAwait { }
failure.onAwait { throw it }
}
}
logDesktopTts("setup_wait_complete")
return connectedWebSocket
}
try {
val totalChunksByChapter = chunks.groupingBy { it.chapterTitle }.eachCount()
chunks.forEach { chunk ->
cacheManager.saveTotalChunks(
bookTitle = bookTitle,
chapterTitle = chunk.chapterTitle,
totalChunks = totalChunksByChapter[chunk.chapterTitle] ?: chunks.size
)
}
chunks.forEachIndexed { index, chunk ->
val text = chunk.text
val turnComplete = CompletableDeferred<Unit>()
currentTurnAudioBytesReceived.set(0)
currentTurnComplete.set(turnComplete)
logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}")
withContext(callbackContext) {
onChunkStart(index)
}
val cacheFile = cacheManager.getCacheFile(bookTitle, chunk.chapterTitle, text, settings.ttsSpeakerId)
if (cacheFile.exists() && cacheFile.length() > 44) {
logDesktopTts(
"cache_hit index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
)
val cachedBytes = playCachedWav(cacheFile, player)
currentTurnAudioBytesReceived.set(cachedBytes)
audioBytesReceived.addAndGet(cachedBytes)
logDesktopTts("cache_play_complete index=${index + 1}/${chunks.size} audioBytes=$cachedBytes")
currentTurnComplete.compareAndSet(turnComplete, null)
return@forEachIndexed
}
val socket = ensureWebSocket()
val tempCacheFile = File(cacheFile.absolutePath + ".tmp")
activeTempCacheFile = tempCacheFile
runCatching {
tempCacheFile.parentFile?.mkdirs()
FileOutputStream(tempCacheFile).also { output ->
output.write(createReaderTtsWavHeaderUnknownLength(24_000))
activeCacheOutput.set(output)
}
}.onFailure { error ->
activeCacheOutput.set(null)
tempCacheFile.delete()
logDesktopTts("cache_prepare_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
}
try {
logDesktopTts("text_send_start index=${index + 1}/${chunks.size} textChars=${text.length}")
runCatching { socket.sendText(buildGeminiTtsTextInput(text), true).join() }
.onFailure { error ->
logDesktopTts("text_send_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
throw error
}
logDesktopTts("text_send_complete index=${index + 1}/${chunks.size}")
val turnTimeoutMs = (30_000L + text.length * 80L).coerceIn(60_000L, 600_000L)
logDesktopTts("turn_wait_start index=${index + 1}/${chunks.size} timeoutMs=$turnTimeoutMs")
withTimeout(turnTimeoutMs) {
select<Unit> {
turnComplete.onAwait { }
failure.onAwait { throw it }
}
}
val turnAudioBytes = currentTurnAudioBytesReceived.get()
logDesktopTts(
"turn_wait_complete index=${index + 1}/${chunks.size} " +
"turnAudioBytes=$turnAudioBytes totalAudioBytes=${audioBytesReceived.get()}"
)
if (turnAudioBytes == 0L) {
logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}")
throw IllegalStateException("Cloud TTS returned no audio for a text chunk.")
}
activeCacheOutput.getAndSet(null)?.close()
runCatching {
patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt())
if (cacheFile.exists()) cacheFile.delete()
if (!tempCacheFile.renameTo(cacheFile)) {
throw IllegalStateException("Could not move temp cache file into place.")
}
}.onSuccess {
logDesktopTts(
"cache_store_complete index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
)
}.onFailure { error ->
tempCacheFile.delete()
logDesktopTts("cache_store_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
}
activeTempCacheFile = null
} finally {
activeCacheOutput.getAndSet(null)?.let { output ->
runCatching { output.close() }
}
}
currentTurnComplete.compareAndSet(turnComplete, null)
}
if (audioBytesReceived.get() == 0L) {
logDesktopTts("stream_failed reason=empty_audio")
throw IllegalStateException("Cloud TTS returned no audio.")
}
player.drainAndClose()
webSocket?.let { socket -> runCatching { socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join() } }
activeWebSocket = null
activePlayer = null
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
} catch (error: Throwable) {
currentTurnComplete.set(null)
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }
activeTempCacheFile?.delete()
activeTempCacheFile = null
runCatching { webSocket?.abort() }
activeWebSocket = null
activePlayer = null
player.closeNow()
throw error
}
}
}
private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer): Long {
var totalBytes = 0L
file.inputStream().use { input ->
var skipped = 0L
while (skipped < 44L) {
val next = input.skip(44L - skipped)
if (next <= 0L) break
skipped += next
}
val buffer = ByteArray(8192)
while (true) {
coroutineContext.ensureActive()
val read = input.read(buffer)
if (read <= 0) break
player.write(buffer.copyOf(read))
totalBytes += read
}
}
return totalBytes
}
private fun defaultDesktopTtsCacheRoot(): File {
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
return File(baseDir, "Episteme/TTS_Cache")
}
private fun buildGeminiTtsSetup(speakerId: String): String {
val systemPrompt = """
You are a professional audiobook narrator.
Read the exact text provided, word for word, with neutral emotion and good pacing.
Do not add conversational filler, acknowledgments, extra words, summaries, or commentary.
Skip non-verbal symbols or formatting noise that cannot be read naturally.
""".trimIndent()
return buildJsonObject {
put(
"setup",
buildJsonObject {
put("model", JsonPrimitive("models/$GEMINI_CLOUD_TTS_MODEL"))
put(
"systemInstruction",
buildJsonObject {
put("parts", buildJsonArray {
add(buildJsonObject { put("text", JsonPrimitive(systemPrompt)) })
})
}
)
put(
"generationConfig",
buildJsonObject {
put("responseModalities", buildJsonArray { add(JsonPrimitive("AUDIO")) })
put(
"speechConfig",
buildJsonObject {
put(
"voiceConfig",
buildJsonObject {
put(
"prebuiltVoiceConfig",
buildJsonObject { put("voiceName", JsonPrimitive(speakerId)) }
)
}
)
}
)
}
)
}
)
}.toString()
}
private fun buildGeminiTtsTextInput(text: String): String {
return buildJsonObject {
put(
"realtimeInput",
buildJsonObject {
put("text", JsonPrimitive(text))
}
)
}.toString()
}
private fun handleGeminiTtsMessage(
message: String,
setupComplete: CompletableDeferred<Unit>,
turnComplete: CompletableDeferred<Unit>?,
failure: CompletableDeferred<Throwable>,
onAudioPart: (ByteArray) -> Unit
) {
logDesktopTts("message_handle chars=${message.length} preview=\"${message.desktopTtsPreview()}\"")
val json = runCatching { DesktopGeminiTtsJson.parseToJsonElement(message).jsonObject }.getOrElse { error ->
logDesktopTts("message_parse_failed error=\"${error.desktopTtsSummary()}\"")
return
}
json["error"]?.let { error ->
logDesktopTts("message_provider_error body=\"${error.toString().desktopTtsPreview(300)}\"")
failure.complete(IllegalStateException(error.toString()))
return
}
if (json.containsKey("setupComplete") || json.containsKey("setup_complete")) {
logDesktopTts("message_setup_complete")
setupComplete.complete(Unit)
}
val serverContent = json.jsonObjectValue("serverContent", "server_content") ?: return
val modelTurn = serverContent.jsonObjectValue("modelTurn", "model_turn")
val parts = modelTurn?.get("parts")?.jsonArray
parts?.forEach { part ->
val inlineData = part.jsonObjectOrNull()?.jsonObjectValue("inlineData", "inline_data")
val encoded = inlineData?.get("data")?.jsonPrimitive?.contentOrNull
if (!encoded.isNullOrBlank()) {
val decoded = Base64.getMimeDecoder().decode(encoded)
onAudioPart(decoded)
logDesktopTts("message_audio_part bytes=${decoded.size}")
}
}
if (serverContent.booleanValue("turnComplete", "turn_complete")) {
logDesktopTts("message_turn_complete")
turnComplete?.complete(Unit)
}
}
private val DesktopGeminiTtsJson = Json { ignoreUnknownKeys = true }
private fun JsonObject.jsonObjectValue(vararg keys: String): JsonObject? {
return keys.firstNotNullOfOrNull { key -> get(key) as? JsonObject }
}
private fun JsonObject.booleanValue(vararg keys: String): Boolean {
return keys.any { key -> get(key)?.jsonPrimitive?.booleanOrNull == true }
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
return this as? JsonObject
}
private fun ByteArray.upsample16BitMonoLe2x(): ByteArray {
if (size < 2) return this
val sampleCount = size / 2
val output = ByteArray(sampleCount * 4)
var outputIndex = 0
fun sampleAt(index: Int): Int {
val byteIndex = index * 2
val lo = this[byteIndex].toInt() and 0xFF
val hi = this[byteIndex + 1].toInt()
return (hi shl 8) or lo
}
fun writeSample(sample: Int) {
output[outputIndex] = (sample and 0xFF).toByte()
output[outputIndex + 1] = ((sample shr 8) and 0xFF).toByte()
outputIndex += 2
}
for (index in 0 until sampleCount) {
val current = sampleAt(index)
val next = sampleAt((index + 1).coerceAtMost(sampleCount - 1))
writeSample(current)
writeSample(((current + next) / 2).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
}
return output
}
private class DesktopStreamingPcmPlayer(
private val onLineChanged: (SourceDataLine?) -> Unit
) {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val stateLock = java.lang.Object()
private var line: SourceDataLine? = null
private var fallbackTo48Khz = true
@Volatile
private var closed = false
@Volatile
private var paused = false
private var bytesWritten = 0L
init {
logDesktopTts("play_stream_start mixers=\"${availableAudioMixers().desktopTtsPreview(260)}\"")
}
fun pause() {
synchronized(stateLock) {
if (closed || paused) return
paused = true
runCatching { line?.stop() }
logDesktopTts("play_stream_paused totalWritten=$bytesWritten")
}
}
fun resume() {
synchronized(stateLock) {
if (closed || !paused) return
paused = false
runCatching { line?.start() }
stateLock.notifyAll()
logDesktopTts("play_stream_resumed totalWritten=$bytesWritten")
}
}
fun write(pcm24Khz: ByteArray) {
if (closed || pcm24Khz.isEmpty()) return
waitIfPaused()
val activeLine = synchronized(stateLock) {
if (closed) return
line ?: openBestLine()
}
val bytes = if (fallbackTo48Khz) pcm24Khz.upsample16BitMonoLe2x() else pcm24Khz
var offset = 0
var lineStarted = activeLine.isRunning
val primeTargetBytes = (activeLine.bufferSize / 2).coerceAtLeast(8192)
while (offset < bytes.size && !closed) {
waitIfPaused()
val maxWrite = if (lineStarted) 8192 else primeTargetBytes
val written = activeLine.write(bytes, offset, (bytes.size - offset).coerceAtMost(maxWrite))
if (written <= 0) break
offset += written
bytesWritten += written
if (!lineStarted && (offset >= bytes.size || offset >= primeTargetBytes)) {
activeLine.start()
lineStarted = true
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
}
}
if (!lineStarted && !closed) {
activeLine.start()
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
}
logDesktopTts("play_stream_write inputBytes=${pcm24Khz.size} writtenBytes=$offset totalWritten=$bytesWritten")
}
fun drainAndClose() {
val activeLine = line
if (activeLine != null && !closed) {
logDesktopTts("play_stream_drain totalWritten=$bytesWritten")
runCatching { activeLine.drain() }
.onFailure { error -> logDesktopTts("play_stream_drain_failed error=\"${error.desktopTtsSummary()}\"") }
}
closeNow()
}
fun closeNow() {
val activeLine = synchronized(stateLock) {
if (closed) return
closed = true
paused = false
stateLock.notifyAll()
line.also { line = null }
}
activeLine?.let {
runCatching { it.stop() }
runCatching { it.flush() }
runCatching { it.close() }
}
onLineChanged(null)
logDesktopTts("play_stream_closed totalWritten=$bytesWritten")
}
private fun waitIfPaused() {
synchronized(stateLock) {
while (paused && !closed) {
stateLock.wait(100)
}
}
}
private fun openBestLine(): SourceDataLine {
fallbackTo48Khz = true
return runCatching {
openLine(48_000f)
}.getOrElse { firstError ->
logDesktopTts("play_primary_failed sampleRate=48000 error=\"${firstError.desktopTtsSummary()}\"")
fallbackTo48Khz = false
runCatching {
openLine(24_000f)
}.onFailure { secondError ->
logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"")
secondError.printStackTrace()
}.getOrElse {
throw firstError
}
}
}
private fun openLine(sampleRate: Float): SourceDataLine {
val format = AudioFormat(sampleRate, 16, 1, true, false)
val bufferBytes = sampleRate.toInt().coerceAtLeast(16_384)
logDesktopTts("play_line_request sampleRate=${sampleRate.toInt()} bufferBytes=$bufferBytes")
val openedLine = AudioSystem.getSourceDataLine(format)
openedLine.open(format, bufferBytes)
line = openedLine
onLineChanged(openedLine)
logDesktopTts(
"play_line_opened sampleRate=${sampleRate.toInt()} output48Khz=$fallbackTo48Khz " +
"line=\"${openedLine.lineInfo.toString().desktopTtsPreview(160)}\""
)
return openedLine
}
}
private fun availableAudioMixers(): String {
return runCatching {
AudioSystem.getMixerInfo()
.joinToString(limit = 8, truncated = "...") { "${it.name}/${it.description}" }
.ifBlank { "none" }
}.getOrDefault("unavailable")
}

View file

@ -1,66 +1,20 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.Tag
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.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import com.aryan.reader.shared.SharedLibrarySnapshot
import com.aryan.reader.shared.SharedLibrarySnapshotJson
import java.io.File
data class DesktopLibrarySnapshot(
val books: List<BookItem> = emptyList(),
val shelfRecords: List<ShelfRecord> = emptyList(),
val shelfRefs: List<BookShelfRef> = emptyList(),
val tags: List<Tag> = emptyList()
)
class DesktopLibraryDatabase(
private val databaseFile: File = defaultDatabaseFile()
) {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
fun load(): SharedLibrarySnapshot {
if (!databaseFile.exists()) return SharedLibrarySnapshot()
return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText())
}
fun load(): DesktopLibrarySnapshot {
if (!databaseFile.exists()) return DesktopLibrarySnapshot()
val root = runCatching {
json.parseToJsonElement(databaseFile.readText()).jsonObject
}.getOrNull() ?: return DesktopLibrarySnapshot()
return DesktopLibrarySnapshot(
books = root.array("books").mapNotNull { it.asBookItemOrNull() },
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
tags = root.array("tags").mapNotNull { it.asTagOrNull() }
)
}
fun save(snapshot: DesktopLibrarySnapshot) {
fun save(snapshot: SharedLibrarySnapshot) {
databaseFile.parentFile?.mkdirs()
val root = JsonObject(
mapOf(
"schemaVersion" to JsonPrimitive(1),
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
)
)
databaseFile.writeText(root.toString())
databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot))
}
companion object {
@ -71,135 +25,3 @@ class DesktopLibraryDatabase(
}
}
}
private fun JsonObject.array(name: String): List<JsonElement> {
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
}
private fun JsonObject.string(name: String): String? {
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
}
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
return this[name]?.jsonPrimitive?.longOrNull ?: fallback
}
private fun JsonObject.float(name: String): Float? {
return this[name]?.jsonPrimitive?.floatOrNull
}
private fun JsonObject.double(name: String): Double? {
return this[name]?.jsonPrimitive?.doubleOrNull
}
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
}
private fun JsonElement.asBookItemOrNull(): BookItem? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val id = obj.string("id") ?: return null
val displayName = obj.string("displayName") ?: return null
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
return BookItem(
id = id,
path = obj.string("path"),
type = type,
displayName = displayName,
timestamp = obj.long("timestamp"),
title = obj.string("title"),
author = obj.string("author"),
progressPercentage = obj.float("progressPercentage"),
isRecent = obj.boolean("isRecent", true),
fileSize = obj.long("fileSize"),
sourceFolder = obj.string("sourceFolder"),
seriesName = obj.string("seriesName"),
seriesIndex = obj.double("seriesIndex"),
tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
)
}
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return ShelfRecord(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
isSmart = obj.boolean("isSmart", false),
smartRulesJson = obj.string("smartRulesJson")
)
}
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return BookShelfRef(
bookId = obj.string("bookId") ?: return null,
shelfId = obj.string("shelfId") ?: return null,
addedAt = obj.long("addedAt")
)
}
private fun JsonElement.asTagOrNull(): Tag? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
return Tag(
id = obj.string("id") ?: return null,
name = obj.string("name") ?: return null,
color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
)
}
private fun BookItem.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"path" to path.asJson(),
"type" to JsonPrimitive(type.name),
"displayName" to JsonPrimitive(displayName),
"timestamp" to JsonPrimitive(timestamp),
"title" to title.asJson(),
"author" to author.asJson(),
"progressPercentage" to progressPercentage.asJson(),
"isRecent" to JsonPrimitive(isRecent),
"fileSize" to JsonPrimitive(fileSize),
"sourceFolder" to sourceFolder.asJson(),
"seriesName" to seriesName.asJson(),
"seriesIndex" to seriesIndex.asJson(),
"tags" to JsonArray(tags.map { it.toJsonObject() })
)
)
}
private fun ShelfRecord.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"isSmart" to JsonPrimitive(isSmart),
"smartRulesJson" to smartRulesJson.asJson()
)
)
}
private fun BookShelfRef.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"bookId" to JsonPrimitive(bookId),
"shelfId" to JsonPrimitive(shelfId),
"addedAt" to JsonPrimitive(addedAt)
)
)
}
private fun Tag.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
"id" to JsonPrimitive(id),
"name" to JsonPrimitive(name),
"color" to color.asJson()
)
)
}
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull

View file

@ -0,0 +1,573 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.BookItem
import com.aryan.reader.shared.BookShelfRef
import com.aryan.reader.shared.FileType
import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.LocalFolderSyncEngine
import com.aryan.reader.shared.LocalFolderSyncStats
import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedFolderBookMetadata
import com.aryan.reader.shared.SharedFolderScannedFile
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.SyncedFolder
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
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 kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
data class DesktopLocalFolderSyncResult(
val state: SharedReaderScreenState,
val shelfRefs: List<BookShelfRef>,
val stats: LocalFolderSyncStats,
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
val idMigrations: Map<String, String> = emptyMap(),
val removedBookIds: Set<String> = emptySet(),
val failedFolders: List<String> = emptyList()
)
object DesktopLocalFolderSync {
private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
fun hasSupportedFiles(folder: File): Boolean {
if (!folder.isDirectory) return false
return folder.walkTopDown()
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
.any { file ->
file.isFile &&
file.shouldSyncBookFile() &&
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
}
}
fun sync(
state: SharedReaderScreenState,
shelfRefs: List<BookShelfRef>,
targetFolder: File? = null,
nowMillis: Long = System.currentTimeMillis()
): DesktopLocalFolderSyncResult {
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
var nextState = state
var nextShelfRefs = shelfRefs
var totalStats = LocalFolderSyncStats()
var totalMetadataStats = DesktopFolderMetadataExtractionStats()
val allMigrations = linkedMapOf<String, String>()
val allRemovedBookIds = linkedSetOf<String>()
val failedFolders = mutableListOf<String>()
requestedFolders.forEach { folder ->
val root = File(folder.uriString)
if (!root.isDirectory) {
failedFolders += folder.name
return@forEach
}
val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString)
val remoteMetadata = readAllMetadata(root)
val syncResult = LocalFolderSyncEngine.syncFolder(
state = nextState,
folder = folder,
files = scannedFiles,
remoteMetadata = remoteMetadata,
nowMillis = nowMillis
)
nextState = syncResult.state
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
nextShelfRefs,
syncResult.idMigrations
).filterNot { it.bookId in syncResult.removedBookIds }
allMigrations += syncResult.idMigrations
allRemovedBookIds += syncResult.removedBookIds
totalStats += syncResult.stats
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
importAnnotationSidecars(root, syncedBooks)
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
books = nextState.rawLibraryBooks,
sourceFolder = folder.uriString
)
if (metadataResult.stats.updatedBooks > 0) {
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
}
totalMetadataStats += metadataResult.stats
syncedBooks.forEach { book ->
saveBookMetadata(book)
savePdfAnnotationSidecar(book)
}
}
return DesktopLocalFolderSyncResult(
state = nextState,
shelfRefs = nextShelfRefs,
stats = totalStats,
metadataStats = totalMetadataStats,
idMigrations = allMigrations,
removedBookIds = allRemovedBookIds,
failedFolders = failedFolders
)
}
fun saveBookSidecars(book: BookItem) {
saveBookMetadata(book)
savePdfAnnotationSidecar(book)
}
fun saveBookMetadata(book: BookItem) {
val metadata = book.toSharedFolderBookMetadata() ?: return
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
saveMetadataToFolder(root, metadata)
}
fun savePdfAnnotationSidecar(book: BookItem) {
val path = book.path?.takeIf { it.isNotBlank() } ?: return
if (book.type != FileType.PDF) return
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
val data = buildMap {
if (annotationFile.isFile) {
val annotationJson = annotationFile.readText().trim()
val annotations = SharedPdfAnnotationSerializer.decode(annotationJson)
put(
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
)
}
if (bookmarkFile.isFile) {
val bookmarksJson = bookmarkFile.readText().trim()
desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) }
}
if (richTextFile.isFile) {
val richTextJson = richTextFile.readText().trim()
val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson)
if (richTextElement == null) {
SharedPdfRichTextLog.d(
"desktop.sync.exportRichTextParseFailed book=${book.id} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}"
)
} else {
val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement)
SharedPdfRichTextLog.d(
"desktop.sync.exportRichText book=${book.id} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
)
put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument))
}
}
}
if (data.isEmpty()) {
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
return
}
val timestamp = maxOf(
annotationFile.lastModifiedIfFile(),
bookmarkFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfFile(),
System.currentTimeMillis()
)
val dataJson = desktopFolderSyncJson.encodeToString(
JsonElement.serializer(),
JsonObject(data)
)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
"keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\""
)
}
saveAnnotationSidecar(
root = root,
bookId = book.id,
jsonPayload = dataJson,
timestamp = timestamp
)
}
private fun foldersToSync(
state: SharedReaderScreenState,
targetFolder: File?,
nowMillis: Long
): List<SyncedFolder> {
if (targetFolder == null) return state.syncedFolders
val root = targetFolder.canonicalOrAbsolute()
val rootPath = root.absolutePath
val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root }
return listOf(
existing ?: SyncedFolder(
uriString = rootPath,
name = root.name.takeIf { it.isNotBlank() } ?: rootPath,
lastScanTime = nowMillis,
allowedFileTypes = desktopSyncableTypes
)
)
}
private fun scanFolder(root: File, sourceFolder: String): List<SharedFolderScannedFile> {
val rootPath = root.toPath().toAbsolutePath().normalize()
return root.walkTopDown()
.onEnter { it == root || it.shouldEnterSyncedFolder() }
.filter { it.isFile && it.shouldSyncBookFile() }
.mapNotNull { file ->
val type = SharedFileCapabilities.fileTypeForName(file.name)
.takeIf { it in desktopSyncableTypes }
?: return@mapNotNull null
val relativePath = runCatching {
rootPath.relativize(file.toPath().toAbsolutePath().normalize())
.joinToString("/")
}.getOrNull() ?: file.name
SharedFolderScannedFile(
name = file.name,
path = file.absolutePath,
sourceFolder = sourceFolder,
relativePath = relativePath,
type = type,
size = file.length(),
lastModified = file.lastModified()
)
}
.toList()
}
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) return emptyMap()
return syncDir.listFiles().orEmpty()
.asSequence()
.filter { it.isFile }
.mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } }
.groupBy({ it.first }, { it.second })
.mapNotNull { (bookId, files) ->
val best = files
.mapNotNull { file ->
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
}
.filter { it.bookId == bookId }
.maxByOrNull { it.lastModifiedTimestamp }
best?.let { bookId to it }
}
.toMap()
}
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) return
val target = File(syncDir, ".${metadata.bookId}.json")
val temp = File(syncDir, ".${metadata.bookId}.tmp")
runCatching {
temp.writeText(metadata.toJsonString())
moveReplacing(temp, target)
}.onFailure {
runCatching { temp.delete() }
}
}
private fun resolveMetadataConflicts(
syncDir: File,
bookId: String,
cleanup: Boolean
): SharedFolderBookMetadata? {
val candidates = syncDir.listFiles().orEmpty().filter { file ->
val normalized = file.name.removePrefix(".")
file.isFile && (
normalized == "$bookId.json" ||
normalized.startsWith("$bookId.sync-conflict") ||
normalized.startsWith("$bookId.json.sync-conflict")
)
}
if (candidates.isEmpty()) return null
val parsed = candidates.mapNotNull { file ->
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
}
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
if (cleanup) {
candidates
.filterNot { it == winner.first }
.forEach { runCatching { it.delete() } }
val correctName = ".${bookId}.json"
if (winner.first.name != correctName) {
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
}
}
return winner.second
}
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) return emptyMap()
return syncDir.listFiles().orEmpty()
.asSequence()
.filter { it.isFile }
.mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } }
.groupBy({ it.first }, { it.second })
.mapNotNull { (bookId, files) ->
val best = files
.mapNotNull { it.readAnnotationSidecarOrNull() }
.maxByOrNull { it.timestamp }
best?.let { bookId to it }
}
.toMap()
}
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
if (books.isEmpty()) return
val sidecars = preloadAnnotationSidecars(root)
if (sidecars.isEmpty()) return
books.forEach { book ->
val path = book.path?.takeIf { it.isNotBlank() } ?: return@forEach
if (book.type != FileType.PDF) return@forEach
val sidecar = sidecars[book.id] ?: return@forEach
val annotationFile = desktopPdfAnnotationFile(path)
val bookmarkFile = desktopPdfBookmarkFile(path)
val richTextFile = desktopPdfRichTextFile(path)
val localTimestamp = maxOf(
annotationFile.lastModifiedIfFile(),
bookmarkFile.lastModifiedIfFile(),
richTextFile.lastModifiedIfFile()
)
if (sidecar.timestamp <= localTimestamp + 1000L) {
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
SharedPdfRichTextLog.d(
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
"localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " +
"richFile=\"${richTextFile.absolutePath.richSyncPreview()}\""
)
}
return@forEach
}
if (sidecar.data.hasPdfAnnotationPayload()) {
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
annotationFile.parentFile?.mkdirs()
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
annotationFile.setLastModified(sidecar.timestamp)
}
sidecar.data["bookmarks"]?.let { bookmarks ->
bookmarkFile.parentFile?.mkdirs()
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
bookmarkFile.setLastModified(sidecar.timestamp)
}
sidecar.data["text"]?.let { richText ->
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
SharedPdfRichTextLog.d(
"desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " +
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
)
richTextFile.parentFile?.mkdirs()
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
richTextFile.setLastModified(sidecar.timestamp)
}
}
}
private fun saveAnnotationSidecar(
root: File,
bookId: String,
jsonPayload: String,
timestamp: Long
) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
if (existing != null && existing.timestamp >= timestamp) {
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
"candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\""
)
}
return
}
val wrapper = JsonObject(
mapOf(
"version" to JsonPrimitive(1),
"timestamp" to JsonPrimitive(timestamp),
"data" to data
)
)
val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json")
val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp")
runCatching {
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
moveReplacing(temp, target)
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
"target=\"${target.absolutePath.richSyncPreview()}\""
)
}
}.onFailure {
if (data.containsKey("text")) {
SharedPdfRichTextLog.d(
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
"target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}"
)
}
runCatching { temp.delete() }
}
}
private fun resolveAnnotationConflicts(
syncDir: File,
bookId: String,
cleanup: Boolean
): AnnotationSidecar? {
val candidates = syncDir.listFiles().orEmpty().filter { file ->
file.isFile && file.annotationBookIdOrNull() == bookId
}
if (candidates.isEmpty()) return null
val parsed = candidates.mapNotNull { file ->
file.readAnnotationSidecarOrNull()?.let { file to it }
}
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
if (cleanup) {
candidates
.filterNot { it == winner.first }
.forEach { runCatching { it.delete() } }
val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json"
if (winner.first.name != correctName) {
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
}
}
return winner.second
}
}
private data class AnnotationSidecar(
val timestamp: Long,
val data: JsonObject
)
private val desktopFolderSyncJson = Json {
ignoreUnknownKeys = true
prettyPrint = true
encodeDefaults = true
}
private fun File.shouldEnterSyncedFolder(): Boolean {
if (!isDirectory) return false
if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false
if (name.startsWith(".")) return false
return runCatching { !isHidden }.getOrDefault(true)
}
private fun File.shouldSyncBookFile(): Boolean {
if (name.startsWith(".")) return false
if (extension.equals("json", ignoreCase = true)) return false
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
}
private fun File.metadataBookIdOrNull(): String? {
val fileName = name
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null
if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null
val normalized = fileName.removePrefix(".")
val base = if (normalized.contains(".sync-conflict")) {
normalized.substringBefore(".sync-conflict")
} else {
normalized.substringBeforeLast(".json")
}
return base.removeSuffix(".json").takeIf { it.isNotBlank() }
}
private fun File.annotationBookIdOrNull(): String? {
var candidate = name
if (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null
if (candidate.contains(".syncthing.")) return null
if (candidate.contains(".sync-conflict")) {
candidate = candidate.substringBefore(".sync-conflict")
}
candidate = candidate.substringBeforeLast(".json")
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
}
return candidate.removePrefix(".").takeIf { it.isNotBlank() }
}
private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? {
return runCatching {
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
AnnotationSidecar(timestamp = timestamp, data = data)
}.getOrNull()
}
private fun Json.parseElementOrNull(raw: String): JsonElement? {
return runCatching { parseToJsonElement(raw) }.getOrNull()
}
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
if (this is JsonNull) return null
return runCatching { jsonObject }.getOrNull()
}
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
}
private fun File.canonicalOrAbsolute(): File {
return runCatching { canonicalFile }.getOrElse { absoluteFile }
}
private fun File.lastModifiedIfFile(): Long {
return if (isFile) lastModified() else 0L
}
private fun String.richSyncPreview(maxLength: Int = 160): String {
return replace(Regex("\\s+"), " ")
.trim()
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
.replace("\"", "\\\"")
}
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
)
}
}

Some files were not shown because too many files have changed in this diff Show more