Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
12a8c1fc30
214 changed files with 53372 additions and 4702 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,24 +1890,24 @@ 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)
|
||||
|
||||
if (includeAnnotations) {
|
||||
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
|
||||
|
||||
PdfExporter.exportAnnotatedPdf(
|
||||
context = appContext,
|
||||
sourceUri = sourceUri,
|
||||
destStream = outputStream,
|
||||
virtualPages = virtualPages,
|
||||
inkAnnotations = annotations,
|
||||
richTextPageLayouts = richTextPageLayouts,
|
||||
textBoxes = textBoxes,
|
||||
highlights = highlights
|
||||
)
|
||||
} else {
|
||||
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
input.copyTo(outputStream)
|
||||
PdfiumAnnotationExporter.exportAnnotatedPdf(
|
||||
context = appContext,
|
||||
sourceUri = sourceUri,
|
||||
destStream = outputStream,
|
||||
virtualPages = virtualPages,
|
||||
inkAnnotations = annotations,
|
||||
richTextPageLayouts = richTextPageLayouts,
|
||||
textBoxes = textBoxes,
|
||||
highlights = highlights
|
||||
)
|
||||
} else {
|
||||
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
input.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,21 +4655,13 @@ 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()
|
||||
)
|
||||
recentFilesRepository.addShelf(shelf)
|
||||
dismissCreateShelfDialog()
|
||||
syncShelfChangeToFirestore(shelfId)
|
||||
}
|
||||
val shelfId = UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
val shelf = SharedLibraryEditor.createShelfRecord(name, shelfId)?.toShelfEntity(now) ?: return
|
||||
viewModelScope.launch {
|
||||
recentFilesRepository.addShelf(shelf)
|
||||
dismissCreateShelfDialog()
|
||||
syncShelfChangeToFirestore(shelfId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
43
app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
Normal file
43
app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
Normal 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) }
|
||||
667
app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
Normal file
667
app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
Normal 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()}"
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/") }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
chapterIndex = chapterIndex,
|
||||
note = null
|
||||
)
|
||||
return EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = newCfi,
|
||||
newText = newText,
|
||||
newColor = newColor,
|
||||
chapterIndex = chapterIndex,
|
||||
currentList = currentList
|
||||
)
|
||||
return newCfi
|
||||
}
|
||||
|
||||
// --- UI Components ---
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
|
||||
val catalogs = decodedCatalogs.ifEmpty {
|
||||
SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -272,4 +201,4 @@ class OpdsRepository(context: Context) {
|
|||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,14 +1072,16 @@ class BookPaginator(
|
|||
|
||||
private fun prefetchChapters(currentChapterIndex: Int) {
|
||||
Timber.v("Prefetching chapters around index $currentChapterIndex.")
|
||||
val nextChapterIndex = currentChapterIndex + 1
|
||||
if (nextChapterIndex < chapters.size) {
|
||||
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
|
||||
}
|
||||
for (offset in 1..2) {
|
||||
val nextChapterIndex = currentChapterIndex + offset
|
||||
if (nextChapterIndex < chapters.size) {
|
||||
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
|
||||
}
|
||||
|
||||
val prevChapterIndex = currentChapterIndex - 1
|
||||
if (prevChapterIndex >= 0) {
|
||||
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
|
||||
val prevChapterIndex = currentChapterIndex - offset
|
||||
if (prevChapterIndex >= 0) {
|
||||
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
|
||||
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)
|
||||
|
||||
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
|
||||
if (DEBUG_CONTENT_STYLING) {
|
||||
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
|
||||
}
|
||||
|
||||
withStyle(finalParagraphStyle) {
|
||||
withStyle(initialSpanStyle) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
anchorLocatorForReconfig = locator
|
||||
val currentPage = pagerState.currentPage
|
||||
val locator = resolvePaginatedReconfigurationAnchor(
|
||||
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
|
||||
fallbackLocator = fallbackLocatorForReconfiguration
|
||||
)
|
||||
anchorLocatorForReconfig = locator
|
||||
|
||||
Timber.tag("ThemeReconfig").d("""
|
||||
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,12 +877,13 @@ fun PaginatedReaderScreen(
|
|||
delay(400L)
|
||||
|
||||
val activePaginator = currentPaginatorRef.value
|
||||
if (activePaginator is BookPaginator) {
|
||||
val currentPage = pagerState.currentPage
|
||||
val locator = activePaginator.getLocatorForPage(currentPage)
|
||||
if (locator != null) {
|
||||
anchorLocatorForReconfig = locator
|
||||
}
|
||||
val currentPage = pagerState.currentPage
|
||||
val locator = resolvePaginatedReconfigurationAnchor(
|
||||
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
|
||||
fallbackLocator = fallbackLocatorForReconfiguration
|
||||
)
|
||||
if (locator != null) {
|
||||
anchorLocatorForReconfig = locator
|
||||
}
|
||||
|
||||
debouncedFontSizeMult = fontSizeMultiplier
|
||||
|
|
@ -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,25 +1045,32 @@ fun PaginatedReaderScreen(
|
|||
if (anchorLocatorForReconfig != null) {
|
||||
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
|
||||
|
||||
snapshotFlow { paginator.isLoading }.filter { !it }.first()
|
||||
try {
|
||||
onReconfigurationRestoreActiveChanged(true)
|
||||
snapshotFlow { paginator.isLoading }.filter { !it }.first()
|
||||
|
||||
val targetLocator = anchorLocatorForReconfig
|
||||
if (targetLocator != null) {
|
||||
val page = paginator.findPageForLocator(targetLocator)
|
||||
val targetLocator = anchorLocatorForReconfig
|
||||
if (targetLocator != null) {
|
||||
val page = paginator.findPageForLocator(targetLocator)
|
||||
|
||||
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
|
||||
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
|
||||
|
||||
if (page != null) {
|
||||
pagerState.scrollToPage(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)
|
||||
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
|
||||
}
|
||||
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 newPage = pagerState.currentPage + shiftAmount
|
||||
pagerState.scrollToPage(newPage)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
internal fun resolvePaginatedReconfigurationAnchor(
|
||||
currentPageLocator: Locator?,
|
||||
fallbackLocator: Locator?
|
||||
): Locator? = currentPageLocator ?: fallbackLocator
|
||||
|
|
@ -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()
|
||||
|
||||
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
|
||||
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) {
|
||||
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
|
||||
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()
|
||||
}
|
||||
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
|
||||
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
|
||||
|
||||
Timber.tag("PAGINATION_DEBUG")
|
||||
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
|
||||
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 -> {
|
||||
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
|
||||
if (DEBUG_PAGINATION_LOGS) {
|
||||
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
|
||||
if (DEBUG_PAGINATION_LOGS) {
|
||||
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasSplit) {
|
||||
if (currentPageContent.isEmpty()) {
|
||||
Timber.tag("PAGINATION_DEBUG")
|
||||
.w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)")
|
||||
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 {
|
||||
Timber.tag("PAGINATION_DEBUG")
|
||||
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
|
||||
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()))
|
||||
}
|
||||
|
||||
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
|
||||
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()
|
||||
}
|
||||
|
||||
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
|
||||
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
|
||||
|
||||
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
|
||||
if (DEBUG_PAGINATION_LOGS) {
|
||||
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
|
||||
}
|
||||
|
||||
if (availableTextHeight <= 0) {
|
||||
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: 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) {
|
||||
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
|
||||
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) {
|
||||
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
|
||||
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
|
||||
)
|
||||
|
||||
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
|
||||
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,7 +1138,9 @@ private suspend fun calculateContentHeightWithMargins(
|
|||
}
|
||||
}.roundToInt()
|
||||
totalHeight += (childHeight + margin)
|
||||
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
|
||||
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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -33,4 +33,4 @@ internal enum class DisplayMode {
|
|||
internal fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 +6785,18 @@ fun PdfViewerScreen(
|
|||
title = { Text(stringResource(R.string.title_save_to_device)) },
|
||||
text = { Text(stringResource(R.string.desc_choose_format_save)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showSaveDialog = false
|
||||
pendingSaveMode = SaveMode.ANNOTATED
|
||||
val suggestedName = getSuggestedFilename(
|
||||
originalFileName, isAnnotated = true
|
||||
)
|
||||
saveLauncher.launch(suggestedName)
|
||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showSaveDialog = false
|
||||
pendingSaveMode = SaveMode.ANNOTATED
|
||||
val suggestedName = getSuggestedFilename(
|
||||
originalFileName, isAnnotated = true
|
||||
)
|
||||
saveLauncher.launch(suggestedName)
|
||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
|
|
@ -6723,31 +6827,34 @@ fun PdfViewerScreen(
|
|||
title = { Text(stringResource(R.string.share_chooser_title)) },
|
||||
text = { Text(stringResource(R.string.desc_choose_format_share)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showShareDialog = false
|
||||
isShareLoading = true
|
||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
val filename = getSuggestedFilename(
|
||||
originalFileName, isAnnotated = true
|
||||
)
|
||||
coroutineScope.launch {
|
||||
val currentRichTextLayouts = richTextController?.pageLayouts
|
||||
|
||||
viewModel.sharePdf(
|
||||
activityContext = context,
|
||||
sourceUri = effectivePdfUri,
|
||||
annotations = allAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
includeAnnotations = true,
|
||||
filename = filename,
|
||||
bookId = currentBookId
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showShareDialog = false
|
||||
isShareLoading = true
|
||||
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
|
||||
val filename = getSuggestedFilename(
|
||||
originalFileName, isAnnotated = true
|
||||
)
|
||||
isShareLoading = false
|
||||
}
|
||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||
coroutineScope.launch {
|
||||
val currentRichTextLayouts = richTextController?.pageLayouts
|
||||
|
||||
viewModel.sharePdf(
|
||||
activityContext = context,
|
||||
sourceUri = effectivePdfUri,
|
||||
annotations = allAnnotations,
|
||||
richTextPageLayouts = currentRichTextLayouts,
|
||||
textBoxes = textBoxes.toList(),
|
||||
highlights = userHighlights.toList(),
|
||||
includeAnnotations = true,
|
||||
filename = filename,
|
||||
bookId = currentBookId
|
||||
)
|
||||
isShareLoading = false
|
||||
}
|
||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
||||
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 (pageWidthPx <= 0 || pageHeightPx <= 0) return emptyList()
|
||||
|
||||
val validPages = if (dirtyGlobalIndex > 0 && previousLayouts.isNotEmpty()) {
|
||||
previousLayouts.takeWhile { it.globalEndIndex < dirtyGlobalIndex }
|
||||
} else {
|
||||
emptyList()
|
||||
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) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
|
||||
return 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
|
||||
|
||||
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),
|
||||
density = density
|
||||
)
|
||||
|
||||
val newPages = mutableListOf<PageTextLayout>()
|
||||
var currentPageIndex = startPageIndex
|
||||
var currentPageStartRel = 0
|
||||
var currentPageAccumulatedHeight = 0f
|
||||
var currentPageIndex = 0
|
||||
var segmentStart = 0
|
||||
val rawText = globalText.text
|
||||
|
||||
var currentLineIndex = 0
|
||||
val totalLines = measureResult.lineCount
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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++
|
||||
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
|
||||
)
|
||||
segmentStart = segmentEnd
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -1400,4 +1516,4 @@ class RichTextController(
|
|||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
app/src/test/java/com/aryan/reader/FileHasherTest.kt
Normal file
54
app/src/test/java/com/aryan/reader/FileHasherTest.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
514
app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt
Normal file
514
app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
147
app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt
Normal file
147
app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}]"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
452
app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt
Normal file
452
app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt
Normal 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 = ""
|
||||
)
|
||||
}
|
||||
119
app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt
Normal file
119
app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <line>"))
|
||||
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 & B"))
|
||||
assertTrue(html.contains("<tag>"))
|
||||
}
|
||||
|
||||
private fun contextWithCache(cacheDir: File): Context {
|
||||
val context = mockk<Context>()
|
||||
every { context.cacheDir } returns cacheDir
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
208
app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt
Normal file
208
app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
134
app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt
Normal file
134
app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
229
app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt
Normal file
229
app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
240
app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt
Normal file
240
app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt
Normal 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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
139
app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt
Normal file
139
app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt
Normal 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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue