Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
e01fa07dda
214 changed files with 53372 additions and 4702 deletions
|
|
@ -9,6 +9,7 @@ plugins {
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
|
||||||
alias(libs.plugins.kotlin.ksp)
|
alias(libs.plugins.kotlin.ksp)
|
||||||
id("com.diffplug.spotless") version "8.2.1"
|
id("com.diffplug.spotless") version "8.2.1"
|
||||||
|
alias(libs.plugins.kover)
|
||||||
}
|
}
|
||||||
|
|
||||||
val localProperties = Properties()
|
val localProperties = Properties()
|
||||||
|
|
@ -150,6 +151,22 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kover {
|
||||||
|
reports {
|
||||||
|
filters {
|
||||||
|
excludes {
|
||||||
|
classes(
|
||||||
|
"*.BuildConfig",
|
||||||
|
"*.ComposableSingletons*",
|
||||||
|
"*_Impl",
|
||||||
|
"*Database_Impl",
|
||||||
|
"*Dao_Impl"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
//noinspection UseTomlInstead
|
//noinspection UseTomlInstead
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
|
|
@ -220,7 +237,6 @@ dependencies {
|
||||||
|
|
||||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||||
|
|
||||||
implementation("com.tom-roush:pdfbox-android:2.0.27.0")
|
|
||||||
implementation("me.zhanghai.android.libarchive:library:1.1.6")
|
implementation("me.zhanghai.android.libarchive:library:1.1.6")
|
||||||
|
|
||||||
implementation("androidx.paging:paging-runtime-ktx:3.3.6")
|
implementation("androidx.paging:paging-runtime-ktx:3.3.6")
|
||||||
|
|
@ -252,6 +268,8 @@ dependencies {
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("io.mockk:mockk-android:1.14.9")
|
testImplementation("io.mockk:mockk-android:1.14.9")
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation("org.json:json:20251224")
|
||||||
|
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||||
testImplementation("org.slf4j:slf4j-nop:2.0.17")
|
testImplementation("org.slf4j:slf4j-nop:2.0.17")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,26 @@ private val codeOrDataExtensions = setOf(
|
||||||
"go"
|
"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? {
|
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
||||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
val effectiveName = lowerName.withTransparentTextSuffix()
|
val effectiveName = lowerName.withTransparentTextSuffix()
|
||||||
|
|
@ -44,6 +64,22 @@ internal fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||||
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
|
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? {
|
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
|
||||||
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
val effectiveName = lowerName.withTransparentTextSuffix()
|
val effectiveName = lowerName.withTransparentTextSuffix()
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ import com.aryan.reader.data.LocalSyncUtils
|
||||||
import com.aryan.reader.data.FolderBookMetadata
|
import com.aryan.reader.data.FolderBookMetadata
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
class FolderSyncWorker(
|
class FolderSyncWorker(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
|
|
@ -316,7 +315,13 @@ class FolderSyncWorker(
|
||||||
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
|
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
|
||||||
|
|
||||||
val type = getFileType(name, mimeType)
|
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++
|
supportedBooksSeen++
|
||||||
val stableId = buildStableBookId(name, rootDocId, docId)
|
val stableId = buildStableBookId(name, rootDocId, docId)
|
||||||
foundBookIds.add(stableId)
|
foundBookIds.add(stableId)
|
||||||
|
|
@ -554,11 +559,14 @@ class FolderSyncWorker(
|
||||||
val sidecarData = preloadedSidecars[book.bookId] ?: continue
|
val sidecarData = preloadedSidecars[book.bookId] ?: continue
|
||||||
val (remoteTs, jsonPayload) = sidecarData
|
val (remoteTs, jsonPayload) = sidecarData
|
||||||
|
|
||||||
|
val safeSlashBookId = book.bookId.replace("/", "_")
|
||||||
|
val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
|
||||||
val localFiles = listOf(
|
val localFiles = listOf(
|
||||||
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
|
File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
|
||||||
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
|
File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
|
||||||
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
|
File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
|
||||||
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.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
|
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 {
|
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
|
||||||
val relativePath = buildRelativePath(rootDocId, docId, name)
|
val relativePath = buildRelativePath(rootDocId, docId, name)
|
||||||
if (relativePath.equals(name, ignoreCase = true)) {
|
return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath)
|
||||||
return "local_$name"
|
|
||||||
}
|
|
||||||
return "local_${name}_${shortHash(relativePath.lowercase())}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
|
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
|
||||||
|
|
@ -625,11 +630,6 @@ class FolderSyncWorker(
|
||||||
return relative.ifBlank { fallbackName }
|
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? {
|
private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
|
||||||
val uriString = item.uriString ?: return null
|
val uriString = item.uriString ?: return null
|
||||||
return try {
|
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)
|
Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
val cleanSummary = remember(entry.summary) {
|
val cleanSummary = remember(summary) {
|
||||||
val preProcessed = entry.summary
|
val preProcessed = summary
|
||||||
.replace("<br>", "\n")
|
.replace("<br>", "\n")
|
||||||
.replace("</p>", "\n\n")
|
.replace("</p>", "\n\n")
|
||||||
Jsoup.parse(preProcessed).text().trim()
|
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.BookTagCrossRef
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.ShelfEntity
|
import com.aryan.reader.data.ShelfEntity
|
||||||
import com.aryan.reader.data.SmartCollectionEngine
|
|
||||||
import com.aryan.reader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.aryan.reader.shared.SmartCollectionEngine
|
||||||
|
|
||||||
fun interface FolderPathResolver {
|
fun interface FolderPathResolver {
|
||||||
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
||||||
|
|
@ -183,7 +183,7 @@ class LibraryStateProjector(
|
||||||
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
|
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
|
||||||
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
|
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
|
||||||
if (rules != null) {
|
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)))
|
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
|
||||||
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
|
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import com.aryan.reader.data.BookMetadata
|
||||||
import com.aryan.reader.data.CloudflareRepository
|
import com.aryan.reader.data.CloudflareRepository
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import com.aryan.reader.data.FeedbackRepository
|
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.BookCacheDatabase
|
||||||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
import com.aryan.reader.pdf.PdfCoverGenerator
|
||||||
import com.aryan.reader.pdf.PdfExporter
|
|
||||||
import com.aryan.reader.pdf.PdfUserHighlight
|
import com.aryan.reader.pdf.PdfUserHighlight
|
||||||
|
import com.aryan.reader.pdf.PdfiumAnnotationExporter
|
||||||
import com.aryan.reader.pdf.ReflowWorker
|
import com.aryan.reader.pdf.ReflowWorker
|
||||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
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.PdfTextBoxRepository
|
||||||
import com.aryan.reader.pdf.data.PdfTextRepository
|
import com.aryan.reader.pdf.data.PdfTextRepository
|
||||||
import com.aryan.reader.pdf.data.VirtualPage
|
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 io.legere.pdfiumandroid.PdfiumCore
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -709,27 +712,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createAndAssignTag(name: String, bookIds: Set<String>) {
|
fun createAndAssignTag(name: String, bookIds: Set<String>) {
|
||||||
val trimmedName = name.trim()
|
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
|
||||||
if (trimmedName.isBlank() || bookIds.isEmpty()) return
|
if (sanitizedBookIds.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val tagId = UUID.randomUUID().toString()
|
val tagId = UUID.randomUUID().toString()
|
||||||
val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE)
|
val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE)
|
||||||
val color = colors.random().toInt()
|
val color = colors.random().toInt()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
val tag = TagEntity(tagId, trimmedName, color, System.currentTimeMillis())
|
val tag = SharedLibraryEditor.createTag(name, tagId, color)?.toTagEntity(now) ?: return@launch
|
||||||
recentFilesRepository.createTag(tag)
|
recentFilesRepository.createTag(tag)
|
||||||
|
|
||||||
bookIds.forEach { bookId ->
|
sanitizedBookIds.forEach { bookId ->
|
||||||
recentFilesRepository.assignTagToBook(bookId, tagId)
|
recentFilesRepository.assignTagToBook(bookId, tagId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleTagForBooks(tagId: String, bookIds: Set<String>, assign: Boolean) {
|
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 {
|
viewModelScope.launch {
|
||||||
bookIds.forEach { bookId ->
|
sanitizedBookIds.forEach { bookId ->
|
||||||
if (assign) {
|
if (assign) {
|
||||||
recentFilesRepository.assignTagToBook(bookId, tagId)
|
recentFilesRepository.assignTagToBook(bookId, tagId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1094,9 +1098,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) }
|
prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
PDFBoxResourceLoader.init(getApplication())
|
|
||||||
}
|
|
||||||
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
|
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
|
||||||
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) }
|
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 virtualPages = pageLayoutRepository.getLayoutOrNull(bookId)
|
||||||
val outputStream = appContext.contentResolver.openOutputStream(destUri)
|
val outputStream = appContext.contentResolver.openOutputStream(destUri)
|
||||||
if (outputStream != null) {
|
if (outputStream != null) {
|
||||||
PdfExporter.exportAnnotatedPdf(
|
PdfiumAnnotationExporter.exportAnnotatedPdf(
|
||||||
context = appContext,
|
context = appContext,
|
||||||
sourceUri = sourceUri,
|
sourceUri = sourceUri,
|
||||||
destStream = outputStream,
|
destStream = outputStream,
|
||||||
|
|
@ -1889,12 +1890,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
val destFile = File(shareDir, filename)
|
val destFile = File(shareDir, filename)
|
||||||
val outputStream = FileOutputStream(destFile)
|
FileOutputStream(destFile).use { outputStream ->
|
||||||
|
|
||||||
if (includeAnnotations) {
|
if (includeAnnotations) {
|
||||||
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
|
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
|
||||||
|
|
||||||
PdfExporter.exportAnnotatedPdf(
|
PdfiumAnnotationExporter.exportAnnotatedPdf(
|
||||||
context = appContext,
|
context = appContext,
|
||||||
sourceUri = sourceUri,
|
sourceUri = sourceUri,
|
||||||
destStream = outputStream,
|
destStream = outputStream,
|
||||||
|
|
@ -1909,6 +1909,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
input.copyTo(outputStream)
|
input.copyTo(outputStream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val authority = "${appContext.packageName}.provider"
|
val authority = "${appContext.packageName}.provider"
|
||||||
val contentUri = androidx.core.content.FileProvider.getUriForFile(
|
val contentUri = androidx.core.content.FileProvider.getUriForFile(
|
||||||
|
|
@ -1953,6 +1954,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
|
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (book.isManualOnlyReaderFile()) {
|
||||||
|
Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}")
|
||||||
|
return
|
||||||
|
}
|
||||||
val currentUser = uiState.value.currentUser ?: return
|
val currentUser = uiState.value.currentUser ?: return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -1972,6 +1978,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val hasTextBoxes = textBoxFile.exists()
|
val hasTextBoxes = textBoxFile.exists()
|
||||||
val hasHighlights = highlightFile.exists()
|
val hasHighlights = highlightFile.exists()
|
||||||
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
|
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 (hasAnyData) {
|
||||||
if (googleDriveRepository.hasDrivePermissions(appContext)) {
|
if (googleDriveRepository.hasDrivePermissions(appContext)) {
|
||||||
|
|
@ -1985,12 +1995,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (file == null || !file.exists()) return
|
if (file == null || !file.exists()) return
|
||||||
try {
|
try {
|
||||||
val content = file.readText().trim()
|
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("[")) {
|
if (content.startsWith("[")) {
|
||||||
bundleJson.put(key, JSONArray(content))
|
bundleJson.put(key, JSONArray(content))
|
||||||
} else if (content.startsWith("{")) {
|
} else if (content.startsWith("{")) {
|
||||||
bundleJson.put(key, JSONObject(content))
|
bundleJson.put(key, JSONObject(content))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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")
|
Timber.e(e, "Failed to parse local $key file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2003,7 +2023,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
val bundleFile =
|
val bundleFile =
|
||||||
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
|
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(
|
val uploaded = googleDriveRepository.uploadAnnotationFile(
|
||||||
accessToken, book.bookId, bundleFile
|
accessToken, book.bookId, bundleFile
|
||||||
|
|
@ -2011,9 +2038,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
bundleFile.delete()
|
bundleFile.delete()
|
||||||
|
|
||||||
if (uploaded != null) {
|
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")
|
Timber.tag("AnnotationSync")
|
||||||
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
|
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
|
||||||
} else {
|
} else {
|
||||||
|
if (hasRichText) {
|
||||||
|
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
|
||||||
|
.e("android.cloud.export.uploadFailed book=${book.bookId}")
|
||||||
|
}
|
||||||
Timber.tag("AnnotationSync")
|
Timber.tag("AnnotationSync")
|
||||||
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
|
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
|
||||||
return@launch
|
return@launch
|
||||||
|
|
@ -2204,7 +2239,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
while (nextIdx < totalChapters) {
|
while (nextIdx < totalChapters) {
|
||||||
Timber.tag("TTS_BG_ADVANCE").d("Trying chapter $nextIdx natively.")
|
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()) {
|
if (!nativeChunks.isNullOrEmpty()) {
|
||||||
val token = getAuthToken()
|
val token = getAuthToken()
|
||||||
|
|
@ -2231,7 +2266,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
// Save reading position locally
|
// Save reading position locally
|
||||||
val cfi = nativeChunks.firstOrNull()?.sourceCfi
|
val cfi = nativeChunks.firstOrNull()?.sourceCfi
|
||||||
if (cfi != null) {
|
if (cfi != null) {
|
||||||
val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi)
|
val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi, bookId)
|
||||||
if (locator != null) {
|
if (locator != null) {
|
||||||
recentFilesRepository.getFileByBookId(bookId)?.uriString?.let { uriString ->
|
recentFilesRepository.getFileByBookId(bookId)?.uriString?.let { uriString ->
|
||||||
recentFilesRepository.updateEpubReadingPosition(uriString, locator, cfi, 0f)
|
recentFilesRepository.updateEpubReadingPosition(uriString, locator, cfi, 0f)
|
||||||
|
|
@ -2872,25 +2907,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
} else {
|
} else {
|
||||||
allFiles.filter { it.sourceFolderUri == null }
|
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 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 =
|
val allKnownShelfNames =
|
||||||
(localShelfNames + remoteShelvesDeferred.await().map { it.name }).toSet()
|
(localShelfNames + remoteShelves.map { it.name }).toSet()
|
||||||
val localShelves = allKnownShelfNames.mapNotNull { name ->
|
val localShelves = allKnownShelfNames.mapNotNull { name ->
|
||||||
val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", 0L)
|
val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", 0L)
|
||||||
if (timestamp == 0L && name !in localShelfNames) return@mapNotNull null
|
if (timestamp == 0L && name !in localShelfNames) return@mapNotNull null
|
||||||
val bookIds = prefs.getStringSet(
|
val bookIds = prefs.getStringSet(
|
||||||
"$KEY_SHELF_CONTENT_PREFIX$name", emptySet()
|
"$KEY_SHELF_CONTENT_PREFIX$name", emptySet()
|
||||||
).orEmpty().toList()
|
).orEmpty().filter { it in syncableBookIds }
|
||||||
val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$name", false)
|
val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$name", false)
|
||||||
ShelfMetadata(name, bookIds, timestamp, isDeleted)
|
ShelfMetadata(name, bookIds, timestamp, isDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
val remoteBooks = remoteBooksDeferred.await()
|
|
||||||
val remoteShelves = remoteShelvesDeferred.await()
|
|
||||||
|
|
||||||
// 3. Merge Books
|
// 3. Merge Books
|
||||||
val localBooksMap = localBooks.associateBy { it.bookId }
|
val localBooksMap = localBooks.associateBy { it.bookId }
|
||||||
val remoteBooksMap = remoteBooks.associateBy { it.bookId }
|
val remoteBooksMap = remoteBooks.associateBy { it.bookId }
|
||||||
|
|
@ -2985,7 +3023,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
currentShelves.add(remote.name)
|
currentShelves.add(remote.name)
|
||||||
putStringSet(
|
putStringSet(
|
||||||
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
|
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
|
||||||
remote.bookIds.toSet()
|
remote.bookIds.filter { it in syncableBookIds }.toSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
putStringSet(KEY_SHELVES, currentShelves)
|
putStringSet(KEY_SHELVES, currentShelves)
|
||||||
|
|
@ -3014,7 +3052,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
currentShelves.add(remote.name)
|
currentShelves.add(remote.name)
|
||||||
putStringSet(
|
putStringSet(
|
||||||
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
|
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
|
||||||
remote.bookIds.toSet()
|
remote.bookIds.filter { it in syncableBookIds }.toSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
putStringSet(KEY_SHELVES, currentShelves)
|
putStringSet(KEY_SHELVES, currentShelves)
|
||||||
|
|
@ -3033,7 +3071,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
val finalMergedBooks = withContext(Dispatchers.IO) {
|
val finalMergedBooks = withContext(Dispatchers.IO) {
|
||||||
recentFilesRepository.getAllFilesForSync()
|
recentFilesRepository.getAllFilesForSync()
|
||||||
}
|
}.filterNot { it.isManualOnlyReaderFile() }
|
||||||
val remoteFiles = withContext(Dispatchers.IO) {
|
val remoteFiles = withContext(Dispatchers.IO) {
|
||||||
googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
|
googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
|
||||||
}
|
}
|
||||||
|
|
@ -3100,11 +3138,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val jsonString = tempDownloadFile.readText()
|
val jsonString = tempDownloadFile.readText()
|
||||||
|
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||||
|
"android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}"
|
||||||
|
)
|
||||||
|
|
||||||
// Determine format
|
// Determine format
|
||||||
val isBundle = try {
|
val isBundle = try {
|
||||||
val obj = JSONObject(jsonString)
|
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) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
@ -3124,13 +3171,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
highlightFile.parentFile?.mkdirs()
|
highlightFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
if (isBundle) {
|
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) {
|
fun writeSafe(key: String, file: File) {
|
||||||
if (bundle.has(key)) {
|
if (bundle.has(key)) {
|
||||||
file.parentFile?.mkdirs()
|
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 {
|
} 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()
|
if (file.exists()) file.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4592,23 +4655,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createShelf(name: String) {
|
fun createShelf(name: String) {
|
||||||
if (name.isNotBlank()) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val shelfId = UUID.randomUUID().toString()
|
val shelfId = UUID.randomUUID().toString()
|
||||||
val shelf = com.aryan.reader.data.ShelfEntity(
|
val now = System.currentTimeMillis()
|
||||||
id = shelfId,
|
val shelf = SharedLibraryEditor.createShelfRecord(name, shelfId)?.toShelfEntity(now) ?: return
|
||||||
name = name,
|
viewModelScope.launch {
|
||||||
isSmart = false,
|
|
||||||
smartRulesJson = null,
|
|
||||||
createdAt = System.currentTimeMillis(),
|
|
||||||
updatedAt = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
recentFilesRepository.addShelf(shelf)
|
recentFilesRepository.addShelf(shelf)
|
||||||
dismissCreateShelfDialog()
|
dismissCreateShelfDialog()
|
||||||
syncShelfChangeToFirestore(shelfId)
|
syncShelfChangeToFirestore(shelfId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun setMainScreenPage(page: Int) {
|
fun setMainScreenPage(page: Int) {
|
||||||
val sanitizedPage = page.coerceIn(0, 1)
|
val sanitizedPage = page.coerceIn(0, 1)
|
||||||
|
|
@ -4651,12 +4706,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renameShelf(shelfId: String, newName: String) {
|
fun renameShelf(shelfId: String, newName: String) {
|
||||||
if (shelfId.isBlank() || newName.isBlank()) {
|
val cleanName = SharedLibraryEditor.cleanShelfName(newName)
|
||||||
|
if (!SharedLibraryEditor.canMutateShelf(shelfId) || cleanName == null) {
|
||||||
dismissRenameShelfDialog()
|
dismissRenameShelfDialog()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
recentFilesRepository.renameShelf(shelfId, newName)
|
recentFilesRepository.renameShelf(shelfId, cleanName)
|
||||||
syncShelfChangeToFirestore(shelfId)
|
syncShelfChangeToFirestore(shelfId)
|
||||||
_internalState.update { it.copy(viewingShelfId = shelfId) }
|
_internalState.update { it.copy(viewingShelfId = shelfId) }
|
||||||
persistLibraryLandingState()
|
persistLibraryLandingState()
|
||||||
|
|
@ -4665,7 +4721,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteShelf(shelfId: String) {
|
fun deleteShelf(shelfId: String) {
|
||||||
if (shelfId.isBlank() || shelfId == "unshelved") {
|
if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
|
||||||
dismissDeleteShelfDialog()
|
dismissDeleteShelfDialog()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -4697,21 +4753,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
fun removeContextualItemsFromShelf() {
|
fun removeContextualItemsFromShelf() {
|
||||||
val shelfId = _internalState.value.viewingShelfId
|
val shelfId = _internalState.value.viewingShelfId
|
||||||
if (shelfId.isNullOrBlank() || shelfId == "unshelved") {
|
if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
|
||||||
clearContextualAction()
|
clearContextualAction()
|
||||||
return
|
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()) {
|
if (bookIdsToRemove.isEmpty()) {
|
||||||
clearContextualAction()
|
clearContextualAction()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
recentFilesRepository.removeBooksFromShelf(shelfId, bookIdsToRemove)
|
recentFilesRepository.removeBooksFromShelf(targetShelfId, bookIdsToRemove.toList())
|
||||||
clearContextualAction()
|
clearContextualAction()
|
||||||
syncShelfChangeToFirestore(shelfId)
|
syncShelfChangeToFirestore(targetShelfId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4755,6 +4812,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
fun deleteSelectedShelves() {
|
fun deleteSelectedShelves() {
|
||||||
val shelvesToDelete = _internalState.value.contextualActionShelfIds
|
val shelvesToDelete = _internalState.value.contextualActionShelfIds
|
||||||
|
.filterTo(mutableSetOf()) { SharedLibraryEditor.canMutateShelf(it) }
|
||||||
if (shelvesToDelete.isEmpty()) {
|
if (shelvesToDelete.isEmpty()) {
|
||||||
clearShelfContextualAction()
|
clearShelfContextualAction()
|
||||||
return
|
return
|
||||||
|
|
@ -4788,7 +4846,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext)
|
val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext)
|
||||||
val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch
|
val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch
|
||||||
val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId)
|
val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId)
|
||||||
|
val manualOnlyBookIds = recentFilesRepository.getAllFilesForSync()
|
||||||
|
.filter { it.isManualOnlyReaderFile() }
|
||||||
|
.mapTo(mutableSetOf()) { it.bookId }
|
||||||
val bookIds = crossRefs.map { it.bookId }
|
val bookIds = crossRefs.map { it.bookId }
|
||||||
|
.filterNot { it in manualOnlyBookIds }
|
||||||
|
|
||||||
val shelfMetadata = ShelfMetadata(
|
val shelfMetadata = ShelfMetadata(
|
||||||
name = shelf.name,
|
name = shelf.name,
|
||||||
|
|
@ -4814,8 +4876,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addBooksToShelf(shelfId: String) {
|
fun addBooksToShelf(shelfId: String) {
|
||||||
val bookIdsToAdd = _internalState.value.booksSelectedForAdding
|
val bookIdsToAdd = SharedLibraryEditor.cleanBookIds(_internalState.value.booksSelectedForAdding)
|
||||||
if (bookIdsToAdd.isEmpty()) {
|
if (!SharedLibraryEditor.canMutateShelf(shelfId) || bookIdsToAdd.isEmpty()) {
|
||||||
dismissAddBooksToShelf()
|
dismissAddBooksToShelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -4943,6 +5005,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
.associateBy { it.name }
|
.associateBy { it.name }
|
||||||
|
|
||||||
for (item in managedBooks) {
|
for (item in managedBooks) {
|
||||||
|
if (item.isManualOnlyReaderFile()) {
|
||||||
|
cleanupBookDataLocally(item.bookId)
|
||||||
|
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
recentFilesRepository.markAsDeleted(listOf(item.bookId))
|
recentFilesRepository.markAsDeleted(listOf(item.bookId))
|
||||||
cleanupBookDataLocally(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.BookItem as SharedBookItem
|
||||||
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
|
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
|
||||||
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
|
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.FileType as SharedFileType
|
||||||
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
||||||
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
|
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
|
||||||
|
|
@ -30,15 +31,18 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
||||||
type = type.toSharedFileType(),
|
type = type.toSharedFileType(),
|
||||||
displayName = customName ?: displayName,
|
displayName = customName ?: displayName,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
|
coverImagePath = coverImagePath,
|
||||||
title = title,
|
title = title,
|
||||||
author = author,
|
author = author,
|
||||||
progressPercentage = progressPercentage,
|
progressPercentage = progressPercentage,
|
||||||
isRecent = isRecent,
|
isRecent = isRecent,
|
||||||
fileSize = fileSize,
|
fileSize = fileSize,
|
||||||
sourceFolder = sourceFolderUri,
|
sourceFolder = sourceFolderUri,
|
||||||
|
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
seriesIndex = seriesIndex,
|
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 {
|
fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
|
||||||
return ShelfRecord(
|
return ShelfRecord(
|
||||||
id = id,
|
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 {
|
fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
|
||||||
return SharedBookShelfRef(
|
return SharedBookShelfRef(
|
||||||
bookId = bookId,
|
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.PdfAnnotationRepository
|
||||||
import com.aryan.reader.pdf.data.PageLayoutRepository
|
import com.aryan.reader.pdf.data.PageLayoutRepository
|
||||||
import com.aryan.reader.pdf.data.PdfTextBoxRepository
|
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.JSONObject
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -273,6 +275,10 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val hasHighlights = highlightFile.exists()
|
val hasHighlights = highlightFile.exists()
|
||||||
|
|
||||||
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
|
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) {
|
if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes && !hasHighlights) {
|
||||||
Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.")
|
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) {
|
fun putJsonSafe(key: String, file: File) {
|
||||||
try {
|
try {
|
||||||
val content = file.readText().trim()
|
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("[")) {
|
if (content.startsWith("[")) {
|
||||||
bundleJson.put(key, JSONArray(content))
|
bundleJson.put(key, JSONArray(content))
|
||||||
} else if (content.startsWith("{")) {
|
} else if (content.startsWith("{")) {
|
||||||
bundleJson.put(key, JSONObject(content))
|
bundleJson.put(key, JSONObject(content))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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")
|
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")
|
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(
|
LocalSyncUtils.saveAnnotationSidecar(
|
||||||
context = context,
|
context = context,
|
||||||
sourceFolderUri = folderUriString.toUri(),
|
sourceFolderUri = folderUriString.toUri(),
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
jsonPayload = bundleJson.toString(),
|
jsonPayload = canonicalBundleJson,
|
||||||
timestamp = finalTs
|
timestamp = finalTs
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -323,13 +345,24 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
|
suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
|
||||||
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
|
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
|
||||||
try {
|
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?) {
|
fun writeSafe(key: String, file: File?) {
|
||||||
if (file != null && bundle.has(key)) {
|
if (file != null && bundle.has(key)) {
|
||||||
file.parentFile?.mkdirs()
|
file.parentFile?.mkdirs()
|
||||||
val contentStr = bundle.get(key).toString()
|
val contentStr = bundle.get(key).toString()
|
||||||
file.writeText(contentStr)
|
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)")
|
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,20 @@
|
||||||
package com.aryan.reader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import com.aryan.reader.toSharedBookItem
|
||||||
import kotlinx.serialization.encodeToString
|
import com.aryan.reader.shared.SmartCollectionEngine as SharedSmartCollectionEngine
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
@Serializable
|
typealias SmartField = com.aryan.reader.shared.SmartField
|
||||||
enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG }
|
typealias SmartOperator = com.aryan.reader.shared.SmartOperator
|
||||||
@Serializable
|
typealias SmartRule = com.aryan.reader.shared.SmartRule
|
||||||
enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN }
|
typealias SmartCollectionDefinition = com.aryan.reader.shared.SmartCollectionDefinition
|
||||||
|
|
||||||
@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()
|
|
||||||
)
|
|
||||||
|
|
||||||
object SmartCollectionEngine {
|
object SmartCollectionEngine {
|
||||||
private val json = Json {
|
fun toJson(definition: SmartCollectionDefinition): String =
|
||||||
encodeDefaults = true
|
SharedSmartCollectionEngine.toJson(definition)
|
||||||
ignoreUnknownKeys = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
|
fun fromJson(json: String?): SmartCollectionDefinition? =
|
||||||
|
SharedSmartCollectionEngine.fromJson(json)
|
||||||
|
|
||||||
fun fromJson(json: String?): SmartCollectionDefinition? {
|
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean =
|
||||||
if (json.isNullOrBlank()) return null
|
SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ import timber.log.Timber
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.w3c.dom.Element
|
import org.w3c.dom.Element
|
||||||
import org.w3c.dom.Node
|
import org.w3c.dom.Node
|
||||||
|
|
@ -42,6 +45,8 @@ import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
|
||||||
class EpubParser(private val context: Context) {
|
class EpubParser(private val context: Context) {
|
||||||
|
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
data class EpubDocument(
|
data class EpubDocument(
|
||||||
val metadata: Node, val manifest: Node, val spine: Node, val opfFilePath: String
|
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
|
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
|
// EpubFile can still represent in-memory file data during initial parsing before extraction
|
||||||
data class EpubFile(val absPath: String, val data: ByteArray) {
|
data class EpubFile(val absPath: String, val data: ByteArray) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
|
|
@ -92,6 +106,9 @@ class EpubParser(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "EpubParser"
|
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
|
internal val String.decodedURL: String
|
||||||
|
|
@ -173,8 +190,24 @@ class EpubParser(private val context: Context) {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
Timber.d("Parsing EPUB input stream for bookId: $bookId")
|
Timber.d("Parsing EPUB input stream for bookId: $bookId")
|
||||||
|
|
||||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
val shouldDeleteExtractionDir = !parseContent && extractionDirOverride == null
|
||||||
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
|
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 tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
|
||||||
val filesMap: Map<String, EpubFile>
|
val filesMap: Map<String, EpubFile>
|
||||||
|
|
@ -190,10 +223,80 @@ class EpubParser(private val context: Context) {
|
||||||
val document = createEpubDocument(filesMap)
|
val document = createEpubDocument(filesMap)
|
||||||
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
|
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
|
||||||
originalBookNameHint, parseContent)
|
originalBookNameHint, parseContent)
|
||||||
|
if (parseContent && extractionDirOverride == null) {
|
||||||
|
writeCachedEpubBook(
|
||||||
|
extractionDir = extractionDir,
|
||||||
|
bookId = bookId,
|
||||||
|
originalBookNameHint = originalBookNameHint,
|
||||||
|
shouldUseToc = shouldUseToc,
|
||||||
|
book = book
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (shouldDeleteExtractionDir) {
|
||||||
|
extractionDir.deleteRecursively()
|
||||||
|
}
|
||||||
return@withContext book
|
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> {
|
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> {
|
||||||
val filesMap = mutableMapOf<String, EpubFile>()
|
val filesMap = mutableMapOf<String, EpubFile>()
|
||||||
zipFile.use { zf ->
|
zipFile.use { zf ->
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,11 @@ class Fb2Parser(private val context: Context) {
|
||||||
extractionDirOverride: File? = null
|
extractionDirOverride: File? = null
|
||||||
): EpubBook = withContext(Dispatchers.IO) {
|
): EpubBook = withContext(Dispatchers.IO) {
|
||||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
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
|
var streamToParse = inputStream
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,18 @@ object ImportedFileCache {
|
||||||
return File(context.cacheDir, activeBookDirName(bookId))
|
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))
|
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 {
|
fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File {
|
||||||
val dirName = buildString {
|
val dirName = buildString {
|
||||||
append(TEMP_PREFIX)
|
append(TEMP_PREFIX)
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,11 @@ class MobiParser(private val context: Context) {
|
||||||
val bookAuthor = parsedData.author ?: "Unknown Author"
|
val bookAuthor = parsedData.author ?: "Unknown Author"
|
||||||
|
|
||||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
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
|
val sequentialImageMap = parsedData.resources
|
||||||
.filter { it.mediaType.startsWith("image/") }
|
.filter { it.mediaType.startsWith("image/") }
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,11 @@ class OdtParser(private val context: Context) {
|
||||||
extractionDirOverride: File? = null
|
extractionDirOverride: File? = null
|
||||||
): EpubBook = withContext(Dispatchers.IO) {
|
): EpubBook = withContext(Dispatchers.IO) {
|
||||||
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
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 mathJaxFileName = "tex-mml-chtml.js"
|
||||||
val mathJaxFile = File(extractionDir, mathJaxFileName)
|
val mathJaxFile = File(extractionDir, mathJaxFileName)
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
FileOutputStream(tempFile).bufferedWriter().use { writer ->
|
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) {
|
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")
|
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")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
try {
|
try {
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
||||||
|
.copy(extractionBasePath = extractionDir.absolutePath)
|
||||||
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
|
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
|
||||||
return@withContext cachedBook
|
return@withContext cachedBook
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to load cached MD, parsing again")
|
Timber.e(e, "Failed to load cached MD, parsing again")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val parseStart = System.currentTimeMillis()
|
val parseStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown START | file=$originalBookNameHint")
|
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")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
try {
|
try {
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
||||||
|
.copy(extractionBasePath = extractionDir.absolutePath)
|
||||||
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
|
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
|
||||||
return@withContext cachedBook
|
return@withContext cachedBook
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to load cached TXT, parsing again")
|
Timber.e(e, "Failed to load cached TXT, parsing again")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val parseStart = System.currentTimeMillis()
|
val parseStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[TXT] parsePlainText START | file=$originalBookNameHint")
|
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")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
try {
|
try {
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
||||||
|
.copy(extractionBasePath = extractionDir.absolutePath)
|
||||||
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
|
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
|
||||||
return@withContext cachedBook
|
return@withContext cachedBook
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to load cached HTML, parsing again")
|
Timber.e(e, "Failed to load cached HTML, parsing again")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val parseStart = System.currentTimeMillis()
|
val parseStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint")
|
Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint")
|
||||||
|
|
@ -511,6 +517,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
var inStyle = false
|
var inStyle = false
|
||||||
var inBody = false
|
var inBody = false
|
||||||
var pageNum = 1
|
var pageNum = 1
|
||||||
|
val headBuilder = java.lang.StringBuilder()
|
||||||
val currentChapterBuilder = java.lang.StringBuilder()
|
val currentChapterBuilder = java.lang.StringBuilder()
|
||||||
|
|
||||||
var line: String?
|
var line: String?
|
||||||
|
|
@ -531,19 +538,9 @@ class SingleFileImporter(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inBody) {
|
if (!inBody) {
|
||||||
if (trimmed.startsWith("<title", ignoreCase = true)) {
|
headBuilder.append(line).append('\n')
|
||||||
val t = trimmed.substringAfter(">").substringBefore("</title>")
|
extractHtmlTitle(headBuilder.toString())?.let { title = it }
|
||||||
if (t.isNotBlank()) title = t
|
extractHtmlAuthor(headBuilder.toString())?.let { author = it }
|
||||||
}
|
|
||||||
val authorMatch = Regex("<meta[^>]+name=\"author\"[^>]+content=\"([^\"]+)\"").find(
|
|
||||||
line
|
|
||||||
)
|
|
||||||
?: Regex("<meta[^>]+property=\"article:author\"[^>]+content=\"([^\"]+)\"").find(
|
|
||||||
line
|
|
||||||
)
|
|
||||||
if (authorMatch != null) {
|
|
||||||
author = authorMatch.groupValues[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed.startsWith("<style", ignoreCase = true)) {
|
if (trimmed.startsWith("<style", ignoreCase = true)) {
|
||||||
inStyle = true
|
inStyle = true
|
||||||
|
|
@ -576,7 +573,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.startsWith("<p") || trimmed.startsWith("<div") ||
|
if (trimmed.startsWith("<p") || trimmed.startsWith("<div") ||
|
||||||
trimmed.startsWith("<h") || trimmed.startsWith("<section") ||
|
trimmed.startsWithHtmlHeadingTag() || trimmed.startsWith("<section") ||
|
||||||
trimmed.contains("<page-break>") ||
|
trimmed.contains("<page-break>") ||
|
||||||
(trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("<!"))) {
|
(trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("<!"))) {
|
||||||
inBody = true
|
inBody = true
|
||||||
|
|
@ -646,6 +643,54 @@ class SingleFileImporter(private val context: Context) {
|
||||||
return@withContext book
|
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 {
|
private fun sanitizeHtmlFragment(html: String): String {
|
||||||
return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
|
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")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
try {
|
try {
|
||||||
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
|
||||||
|
.copy(extractionBasePath = extractionDir.absolutePath)
|
||||||
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
|
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
|
||||||
return@withContext cachedBook
|
return@withContext cachedBook
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to load cached DOCX, parsing again")
|
Timber.e(e, "Failed to load cached DOCX, parsing again")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ImportedFileCache.resetActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val parseStart = System.currentTimeMillis()
|
val parseStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
|
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
|
||||||
|
|
|
||||||
|
|
@ -70,58 +70,16 @@ import androidx.core.content.edit
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import org.json.JSONArray
|
import com.aryan.reader.shared.EpubAnnotationSerializer
|
||||||
import org.json.JSONObject
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
|
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
|
||||||
|
|
||||||
data class Bookmark(
|
typealias Bookmark = com.aryan.reader.shared.EpubBookmark
|
||||||
val cfi: String,
|
typealias HighlightColor = com.aryan.reader.shared.HighlightColor
|
||||||
val chapterTitle: String,
|
typealias UserHighlight = com.aryan.reader.shared.UserHighlight
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
fun escapeJsString(value: String): String {
|
fun escapeJsString(value: String): String {
|
||||||
return value
|
return com.aryan.reader.shared.escapeJsString(value)
|
||||||
.replace("\\", "\\\\")
|
|
||||||
.replace("'", "\\'")
|
|
||||||
.replace("\"", "\\\"")
|
|
||||||
.replace("\n", "\\n")
|
|
||||||
.replace("\r", "\\r")
|
|
||||||
.replace("\t", "\\t")
|
|
||||||
.replace("\u2028", "\\u2028")
|
|
||||||
.replace("\u2029", "\\u2029")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveHighlightPalette(context: Context, palette: List<HighlightColor>) {
|
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> {
|
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
|
||||||
val stringSetToParse: Collection<String> = if (bookmarksJson != null) {
|
val stringSetToParse: Collection<String> = if (bookmarksJson != null) {
|
||||||
try {
|
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson, chapters.map { it.title })
|
||||||
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()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}"
|
val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}"
|
||||||
prefs.getStringSet(key, emptySet()) ?: emptySet()
|
prefs.getStringSet(key, emptySet()) ?: emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
return stringSetToParse.mapNotNull { jsonString ->
|
return EpubAnnotationSerializer.parseBookmarkEntries(stringSetToParse, chapters.map { it.title })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<UserHighlight>) {
|
fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<UserHighlight>) {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
|
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
|
||||||
val key = "highlights_data_$sanitizedTitle"
|
val key = "highlights_data_$sanitizedTitle"
|
||||||
val jsonArray = JSONArray()
|
prefs.edit { putString(key, EpubAnnotationSerializer.highlightsToJson(highlights)) }
|
||||||
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()) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighlight> {
|
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 sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
|
||||||
val key = "highlights_data_$sanitizedTitle"
|
val key = "highlights_data_$sanitizedTitle"
|
||||||
val jsonString = prefs.getString(key, "[]") ?: "[]"
|
val jsonString = prefs.getString(key, "[]") ?: "[]"
|
||||||
val list = mutableListOf<UserHighlight>()
|
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseHighlightsJson(jsonString: String?): List<UserHighlight> {
|
fun parseHighlightsJson(jsonString: String?): List<UserHighlight> {
|
||||||
if (jsonString.isNullOrBlank()) return emptyList()
|
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun highlightsToJson(highlights: List<UserHighlight>): String {
|
fun highlightsToJson(highlights: List<UserHighlight>): String {
|
||||||
val jsonArray = JSONArray()
|
return EpubAnnotationSerializer.highlightsToJson(highlights)
|
||||||
highlights.forEach { h ->
|
}
|
||||||
val obj = JSONObject().apply {
|
|
||||||
put("id", h.id)
|
fun bookmarksToJson(bookmarks: Collection<Bookmark>): String {
|
||||||
put("cfi", h.cfi)
|
return EpubAnnotationSerializer.bookmarksToJson(bookmarks)
|
||||||
put("text", h.text)
|
|
||||||
put("colorId", h.color.id)
|
|
||||||
put("chapterIndex", h.chapterIndex)
|
|
||||||
put("note", h.note ?: "")
|
|
||||||
}
|
|
||||||
jsonArray.put(obj)
|
|
||||||
}
|
|
||||||
return jsonArray.toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearHighlightsFromPrefs(context: Context, bookTitle: String) {
|
fun clearHighlightsFromPrefs(context: Context, bookTitle: String) {
|
||||||
|
|
@ -291,28 +157,13 @@ fun processAndAddHighlight(
|
||||||
chapterIndex: Int,
|
chapterIndex: Int,
|
||||||
currentList: MutableList<UserHighlight>
|
currentList: MutableList<UserHighlight>
|
||||||
): String {
|
): String {
|
||||||
// Scenario: Exact match -> Update color and text instead of stacking identical spans
|
return EpubAnnotationSerializer.processAndAddHighlight(
|
||||||
val exactMatchIndex = currentList.indexOfFirst {
|
newCfi = newCfi,
|
||||||
it.chapterIndex == chapterIndex && it.cfi == newCfi
|
newText = newText,
|
||||||
}
|
newColor = newColor,
|
||||||
|
|
||||||
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,
|
chapterIndex = chapterIndex,
|
||||||
note = null
|
currentList = currentList
|
||||||
)
|
)
|
||||||
)
|
|
||||||
return newCfi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UI Components ---
|
// --- UI Components ---
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,8 @@ enum class ReaderTool(val title: String, val category: String) {
|
||||||
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||||
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
|
||||||
AUTO_SCROLL("Auto Scroll", "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 }
|
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
||||||
|
|
@ -282,6 +283,7 @@ fun EpubReaderTopBar(
|
||||||
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
onTogglePageTurnAnimation: (Boolean) -> Unit,
|
||||||
onStartAutoScroll: () -> Unit,
|
onStartAutoScroll: () -> Unit,
|
||||||
onOpenTtsSettings: () -> Unit,
|
onOpenTtsSettings: () -> Unit,
|
||||||
|
onOpenTtsReplacements: () -> Unit,
|
||||||
onOpenDictionarySettings: () -> Unit,
|
onOpenDictionarySettings: () -> Unit,
|
||||||
onOpenThemeSettings: () -> Unit,
|
onOpenThemeSettings: () -> Unit,
|
||||||
onOpenVisualOptions: () -> 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
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!highlight.note.isNullOrBlank()) {
|
val note = highlight.note
|
||||||
|
if (!note.isNullOrBlank()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
|
@ -799,7 +800,7 @@ private fun HighlightsList(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = highlight.note,
|
text = note,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic),
|
style = MaterialTheme.typography.bodySmall.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic),
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ import com.aryan.reader.SearchResult
|
||||||
import com.aryan.reader.SummarizationResult
|
import com.aryan.reader.SummarizationResult
|
||||||
import com.aryan.reader.SummaryCacheManager
|
import com.aryan.reader.SummaryCacheManager
|
||||||
import com.aryan.reader.TtsSettingsSheet
|
import com.aryan.reader.TtsSettingsSheet
|
||||||
|
import com.aryan.reader.TtsWordReplacementsSheet
|
||||||
import com.aryan.reader.areReaderAiFeaturesEnabled
|
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||||
import com.aryan.reader.countWords
|
import com.aryan.reader.countWords
|
||||||
import com.aryan.reader.isByokCloudTtsAvailable
|
import com.aryan.reader.isByokCloudTtsAvailable
|
||||||
|
|
@ -177,6 +178,7 @@ import com.aryan.reader.loadCustomThemes
|
||||||
import com.aryan.reader.loadGlobalTextureTransparency
|
import com.aryan.reader.loadGlobalTextureTransparency
|
||||||
import com.aryan.reader.loadReaderThemeId
|
import com.aryan.reader.loadReaderThemeId
|
||||||
import com.aryan.reader.loadReaderTextureBitmap
|
import com.aryan.reader.loadReaderTextureBitmap
|
||||||
|
import com.aryan.reader.loadTtsReplacementPreferences
|
||||||
import com.aryan.reader.paginatedreader.BookPaginator
|
import com.aryan.reader.paginatedreader.BookPaginator
|
||||||
import com.aryan.reader.paginatedreader.CfiUtils
|
import com.aryan.reader.paginatedreader.CfiUtils
|
||||||
import com.aryan.reader.paginatedreader.HeaderBlock
|
import com.aryan.reader.paginatedreader.HeaderBlock
|
||||||
|
|
@ -195,12 +197,16 @@ import com.aryan.reader.rememberSearchState
|
||||||
import com.aryan.reader.saveCustomThemes
|
import com.aryan.reader.saveCustomThemes
|
||||||
import com.aryan.reader.saveGlobalTextureTransparency
|
import com.aryan.reader.saveGlobalTextureTransparency
|
||||||
import com.aryan.reader.saveReaderThemeId
|
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.SpeakerSamplePlayer
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
import com.aryan.reader.tts.loadTtsMode
|
import com.aryan.reader.tts.loadTtsMode
|
||||||
import com.aryan.reader.tts.splitTextIntoChunks
|
import com.aryan.reader.tts.splitTextIntoChunks
|
||||||
|
import com.aryan.reader.withTtsReplacements
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
@ -570,6 +576,7 @@ fun EpubReaderScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
|
stableBookId = uiState.selectedBookId,
|
||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -600,6 +607,7 @@ fun EpubReaderHost(
|
||||||
onImportFont: (Uri) -> Unit,
|
onImportFont: (Uri) -> Unit,
|
||||||
onToggleReflow: ((Int) -> Unit)? = null,
|
onToggleReflow: ((Int) -> Unit)? = null,
|
||||||
onDeleteReflow: (() -> Unit)? = null,
|
onDeleteReflow: (() -> Unit)? = null,
|
||||||
|
stableBookId: String? = null,
|
||||||
viewModel: MainViewModel
|
viewModel: MainViewModel
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
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(
|
LocatorConverter(
|
||||||
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
|
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
|
||||||
proto = ProtoBuf { serializersModule = semanticBlockModule },
|
proto = ProtoBuf { serializersModule = semanticBlockModule },
|
||||||
context = context
|
context = context,
|
||||||
|
stableBookId = readerCacheBookId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -698,9 +711,7 @@ fun EpubReaderHost(
|
||||||
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
|
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
|
||||||
var isTtsCollapsed by remember { mutableStateOf(false) }
|
var isTtsCollapsed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val bookId = remember(epubBook.title, epubBook.fileName) {
|
val bookId = readerCacheBookId
|
||||||
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
|
|
||||||
}
|
|
||||||
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
|
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
|
||||||
|
|
||||||
val initialSettings = remember(isAutoScrollLocal) {
|
val initialSettings = remember(isAutoScrollLocal) {
|
||||||
|
|
@ -895,6 +906,8 @@ fun EpubReaderHost(
|
||||||
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
|
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
|
||||||
var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
|
var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
|
||||||
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
|
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 bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
|
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
|
||||||
|
|
@ -910,18 +923,7 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
LaunchedEffect(bookmarks) {
|
LaunchedEffect(bookmarks) {
|
||||||
Timber.d("Bookmarks changed, saving...")
|
Timber.d("Bookmarks changed, saving...")
|
||||||
val stringSet = bookmarks.map { bookmark ->
|
onBookmarksChanged(bookmarksToJson(bookmarks))
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeBookmarkInVerticalView by remember { mutableStateOf<Bookmark?>(null) }
|
var activeBookmarkInVerticalView by remember { mutableStateOf<Bookmark?>(null) }
|
||||||
|
|
@ -1209,9 +1211,15 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
||||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||||
|
var showTtsReplacementsSheet by remember { mutableStateOf(false) }
|
||||||
var showTtsControlsSheet by remember { mutableStateOf(false) }
|
var showTtsControlsSheet by remember { mutableStateOf(false) }
|
||||||
var showThemePanel by remember { mutableStateOf(false) }
|
var showThemePanel by remember { mutableStateOf(false) }
|
||||||
var showPaletteManager 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 currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
|
||||||
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
|
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
|
||||||
|
|
@ -1350,7 +1358,7 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("TTS_LOCATE")
|
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)
|
onSavePosition(locator, cfiForWebView, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1535,7 +1543,7 @@ fun EpubReaderHost(
|
||||||
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
|
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
|
||||||
ttsChapterIndex = chapterIndex
|
ttsChapterIndex = chapterIndex
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = ttsChunks,
|
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||||
bookTitle = epubBook.title,
|
bookTitle = epubBook.title,
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
|
|
@ -1588,7 +1596,11 @@ fun EpubReaderHost(
|
||||||
val relativeOffset = startOffset - target.startOffsetInSource
|
val relativeOffset = startOffset - target.startOffsetInSource
|
||||||
val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length)
|
val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length)
|
||||||
val slicedText = target.text.substring(safeRelativeOffset)
|
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)
|
val remainingChunks = mutableListOf(newChunk)
|
||||||
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
|
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
|
||||||
|
|
@ -1599,7 +1611,7 @@ fun EpubReaderHost(
|
||||||
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
|
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
|
||||||
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
|
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = remainingChunks,
|
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||||
bookTitle = epubBook.title,
|
bookTitle = epubBook.title,
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
|
|
@ -1662,7 +1674,9 @@ fun EpubReaderHost(
|
||||||
currentTtsMode = currentTtsMode,
|
currentTtsMode = currentTtsMode,
|
||||||
getAuthToken = { viewModel.getAuthToken() },
|
getAuthToken = { viewModel.getAuthToken() },
|
||||||
locatorConverter = locatorConverter,
|
locatorConverter = locatorConverter,
|
||||||
epubBook = epubBook
|
epubBook = epubBook,
|
||||||
|
ttsReplacementPreferences = ttsReplacementPreferences,
|
||||||
|
ttsReplacementBookId = bookId
|
||||||
)
|
)
|
||||||
|
|
||||||
TtsHighlightHandler(
|
TtsHighlightHandler(
|
||||||
|
|
@ -2045,8 +2059,26 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(paginatedPagerState.currentPage, paginator) {
|
LaunchedEffect(paginatedPagerState, paginator, currentRenderMode, isPagerInitialized, isPaginatedReconfigurationRestoring) {
|
||||||
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
|
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)
|
delay(1500L)
|
||||||
val pageToSave = paginatedPagerState.currentPage
|
val pageToSave = paginatedPagerState.currentPage
|
||||||
|
|
||||||
|
|
@ -2164,12 +2196,21 @@ fun EpubReaderHost(
|
||||||
RenderMode.PAGINATED -> {
|
RenderMode.PAGINATED -> {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val pageToSave = paginatedPagerState.currentPage
|
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)
|
val chapterIndex = paginator?.findChapterIndexForPage(pageToSave)
|
||||||
|
|
||||||
if (locator != null && chapterIndex != null) {
|
if (locator != null) {
|
||||||
val bookPaginator = paginator as? BookPaginator
|
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 completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() }
|
||||||
val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it }
|
val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it }
|
||||||
val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter)
|
val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter)
|
||||||
|
|
@ -2186,7 +2227,7 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
onSavePosition(locator, null, progress)
|
onSavePosition(locator, null, progress)
|
||||||
} else {
|
} 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()
|
onNavigateBack()
|
||||||
|
|
@ -3570,7 +3611,7 @@ fun EpubReaderHost(
|
||||||
ttsChapterIndex = targetChapterIndex
|
ttsChapterIndex = targetChapterIndex
|
||||||
|
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = ttsChunks,
|
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||||
bookTitle = epubBook.title,
|
bookTitle = epubBook.title,
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
|
|
@ -3886,6 +3927,7 @@ fun EpubReaderHost(
|
||||||
) {
|
) {
|
||||||
PaginatedReaderScreen(
|
PaginatedReaderScreen(
|
||||||
book = epubBook,
|
book = epubBook,
|
||||||
|
bookId = readerCacheBookId,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
effectiveBg = effectiveBg,
|
effectiveBg = effectiveBg,
|
||||||
effectiveText = effectiveText,
|
effectiveText = effectiveText,
|
||||||
|
|
@ -3910,7 +3952,18 @@ fun EpubReaderHost(
|
||||||
activeTextureId = activeTextureId,
|
activeTextureId = activeTextureId,
|
||||||
activeTextureAlpha = activeTextureAlpha,
|
activeTextureAlpha = activeTextureAlpha,
|
||||||
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
|
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 ->
|
onPaginatorReady = { newPaginator ->
|
||||||
paginator = newPaginator
|
paginator = newPaginator
|
||||||
},
|
},
|
||||||
|
|
@ -4624,6 +4677,7 @@ fun EpubReaderHost(
|
||||||
searchFocusRequester = searchFocusRequester,
|
searchFocusRequester = searchFocusRequester,
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||||
|
onOpenTtsReplacements = { showTtsReplacementsSheet = true },
|
||||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||||
onOpenThemeSettings = { showThemePanel = true },
|
onOpenThemeSettings = { showThemePanel = true },
|
||||||
onOpenVisualOptions = { showVisualOptionsSheet = 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) {
|
if (showCustomizeToolsSheet) {
|
||||||
CustomizeToolsSheet(
|
CustomizeToolsSheet(
|
||||||
hiddenTools = hiddenTools,
|
hiddenTools = hiddenTools,
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@ import com.aryan.reader.RenderMode
|
||||||
import com.aryan.reader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
import com.aryan.reader.paginatedreader.BookPaginator
|
import com.aryan.reader.paginatedreader.BookPaginator
|
||||||
import com.aryan.reader.paginatedreader.IPaginator
|
import com.aryan.reader.paginatedreader.IPaginator
|
||||||
|
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||||
import com.aryan.reader.tts.TtsController
|
import com.aryan.reader.tts.TtsController
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||||
|
import com.aryan.reader.withTtsReplacements
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -115,7 +117,9 @@ fun TtsSessionObserver(
|
||||||
currentTtsMode: TtsMode,
|
currentTtsMode: TtsMode,
|
||||||
getAuthToken: suspend () -> String?,
|
getAuthToken: suspend () -> String?,
|
||||||
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW
|
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 currentRenderModeState = rememberUpdatedState(currentRenderMode)
|
||||||
val loadedChunkCountState = rememberUpdatedState(loadedChunkCount)
|
val loadedChunkCountState = rememberUpdatedState(loadedChunkCount)
|
||||||
|
|
@ -131,6 +135,8 @@ fun TtsSessionObserver(
|
||||||
val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange)
|
val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange)
|
||||||
val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW
|
val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW
|
||||||
val epubBookState = rememberUpdatedState(epubBook) // NEW
|
val epubBookState = rememberUpdatedState(epubBook) // NEW
|
||||||
|
val ttsReplacementPreferencesState = rememberUpdatedState(ttsReplacementPreferences)
|
||||||
|
val ttsReplacementBookIdState = rememberUpdatedState(ttsReplacementBookId)
|
||||||
|
|
||||||
DisposableEffect(ttsController) {
|
DisposableEffect(ttsController) {
|
||||||
val job = scope.launch {
|
val job = scope.launch {
|
||||||
|
|
@ -168,7 +174,9 @@ fun TtsSessionObserver(
|
||||||
ttsController = ttsController,
|
ttsController = ttsController,
|
||||||
scope = this,
|
scope = this,
|
||||||
locatorConverter = locatorConverterState.value,
|
locatorConverter = locatorConverterState.value,
|
||||||
epubBook = epubBookState.value
|
epubBook = epubBookState.value,
|
||||||
|
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
|
||||||
|
ttsReplacementBookId = ttsReplacementBookIdState.value
|
||||||
)
|
)
|
||||||
} else if (currentRenderModeState.value == RenderMode.PAGINATED) {
|
} else if (currentRenderModeState.value == RenderMode.PAGINATED) {
|
||||||
handlePaginatedAutoAdvance(
|
handlePaginatedAutoAdvance(
|
||||||
|
|
@ -182,7 +190,9 @@ fun TtsSessionObserver(
|
||||||
onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
|
onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
|
||||||
scope = this,
|
scope = this,
|
||||||
ttsMode = currentTtsMode,
|
ttsMode = currentTtsMode,
|
||||||
getAuthToken = getAuthToken
|
getAuthToken = getAuthToken,
|
||||||
|
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
|
||||||
|
ttsReplacementBookId = ttsReplacementBookIdState.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (wasPlaying && !isPlaying && !sessionFinished) {
|
} else if (wasPlaying && !isPlaying && !sessionFinished) {
|
||||||
|
|
@ -303,7 +313,9 @@ private fun handleVerticalAutoAdvance(
|
||||||
ttsController: TtsController,
|
ttsController: TtsController,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter,
|
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
|
if (currentTtsChapterIndex == null) return
|
||||||
|
|
||||||
|
|
@ -323,7 +335,7 @@ private fun handleVerticalAutoAdvance(
|
||||||
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
|
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
|
||||||
val token = getAuthToken()
|
val token = getAuthToken()
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = remainingChunks,
|
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
||||||
bookTitle = epubBookTitle,
|
bookTitle = epubBookTitle,
|
||||||
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
|
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
|
||||||
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
|
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
|
||||||
|
|
@ -355,7 +367,7 @@ private fun handleVerticalAutoAdvance(
|
||||||
onUpdateTtsChapter(nextIdx)
|
onUpdateTtsChapter(nextIdx)
|
||||||
|
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = nativeChunks,
|
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
||||||
bookTitle = epubBookTitle,
|
bookTitle = epubBookTitle,
|
||||||
chapterTitle = chapters.getOrNull(nextIdx)?.title,
|
chapterTitle = chapters.getOrNull(nextIdx)?.title,
|
||||||
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
|
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
|
||||||
|
|
@ -399,7 +411,9 @@ private fun handlePaginatedAutoAdvance(
|
||||||
onUpdateTtsChapter: (Int?) -> Unit,
|
onUpdateTtsChapter: (Int?) -> Unit,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
ttsMode: TtsMode,
|
ttsMode: TtsMode,
|
||||||
getAuthToken: suspend () -> String?
|
getAuthToken: suspend () -> String?,
|
||||||
|
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||||
|
ttsReplacementBookId: String?
|
||||||
) {
|
) {
|
||||||
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
|
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
|
||||||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...")
|
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...")
|
||||||
|
|
@ -432,7 +446,7 @@ private fun handlePaginatedAutoAdvance(
|
||||||
val token = getAuthToken()
|
val token = getAuthToken()
|
||||||
|
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = nextChapterChunks,
|
chunks = nextChapterChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
|
||||||
bookTitle = epubBookTitle,
|
bookTitle = epubBookTitle,
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,9 @@
|
||||||
// OpdsModels.kt
|
|
||||||
package com.aryan.reader.opds
|
package com.aryan.reader.opds
|
||||||
|
|
||||||
data class OpdsCatalog(
|
typealias OpdsCatalog = com.aryan.reader.shared.opds.OpdsCatalog
|
||||||
val id: String,
|
typealias OpdsFacet = com.aryan.reader.shared.opds.OpdsFacet
|
||||||
val title: String,
|
typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed
|
||||||
val url: String,
|
typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor
|
||||||
val isDefault: Boolean = false,
|
typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition
|
||||||
val username: String? = null,
|
typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry
|
||||||
val password: String? = null
|
typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,486 +1,3 @@
|
||||||
// OpdsParser.kt
|
|
||||||
package com.aryan.reader.opds
|
package com.aryan.reader.opds
|
||||||
|
|
||||||
import android.util.Xml
|
typealias OpdsParser = com.aryan.reader.shared.opds.SharedOpdsParser
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,11 @@ package com.aryan.reader.opds
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import com.aryan.reader.shared.opds.SharedOpdsCatalogs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -37,76 +36,26 @@ class OpdsRepository(context: Context) {
|
||||||
|
|
||||||
fun getCatalogs(): List<OpdsCatalog> {
|
fun getCatalogs(): List<OpdsCatalog> {
|
||||||
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
|
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
|
||||||
val catalogs = mutableListOf<OpdsCatalog>()
|
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
|
||||||
|
val catalogs = decodedCatalogs.ifEmpty {
|
||||||
if (jsonString != null) {
|
SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
|
||||||
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) {
|
if (decodedCatalogs.isEmpty()) {
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (catalogs.isEmpty()) {
|
|
||||||
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true))
|
|
||||||
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Standard Ebooks", "https://standardebooks.org/feeds/opds", isDefault = true))
|
|
||||||
|
|
||||||
saveCatalogs(catalogs)
|
saveCatalogs(catalogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
return catalogs
|
return catalogs
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveUrl(baseUrl: String, href: String): String {
|
suspend fun getSearchTemplate(
|
||||||
return try {
|
openSearchUrl: String,
|
||||||
val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
|
username: String? = null,
|
||||||
|
password: String? = null
|
||||||
resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
|
): String? = withContext(Dispatchers.IO) {
|
||||||
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
href
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getSearchTemplate(openSearchUrl: String): String? = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val request = Request.Builder().url(openSearchUrl).build()
|
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 body = response.body?.string() ?: return@withContext null
|
||||||
|
parser.extractOpenSearchTemplate(body, openSearchUrl)
|
||||||
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
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to fetch OpenSearch template")
|
Timber.e(e, "Failed to fetch OpenSearch template")
|
||||||
null
|
null
|
||||||
|
|
@ -114,48 +63,28 @@ class OpdsRepository(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
|
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
|
||||||
val current = getCatalogs().toMutableList()
|
saveCatalogs(
|
||||||
current.add(OpdsCatalog(UUID.randomUUID().toString(), title, url, username = username, password = password))
|
SharedOpdsCatalogs.addCatalog(
|
||||||
saveCatalogs(current)
|
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?) {
|
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||||
val current = getCatalogs().toMutableList()
|
saveCatalogs(SharedOpdsCatalogs.updateCatalog(getCatalogs(), id, title, url, username, password))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeCatalog(id: String) {
|
fun removeCatalog(id: String) {
|
||||||
val current = getCatalogs().toMutableList()
|
saveCatalogs(SharedOpdsCatalogs.removeCatalog(getCatalogs(), id))
|
||||||
val toRemove = current.find { it.id == id }
|
|
||||||
if (toRemove?.isDefault == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current.removeAll { it.id == id }
|
|
||||||
saveCatalogs(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
private fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
||||||
val jsonArray = JSONArray()
|
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
|
||||||
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()) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
|
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -18,16 +19,6 @@ import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
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) {
|
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val repository = OpdsRepository(application)
|
private val repository = OpdsRepository(application)
|
||||||
|
|
||||||
|
|
@ -142,42 +133,11 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
|
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
|
||||||
val candidates = listOfNotNull(
|
return SharedOpdsDownloadNamer.resolveExtension(
|
||||||
response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
|
acquisition = acquisition,
|
||||||
Uri.parse(acquisition.url).lastPathSegment
|
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 {
|
init {
|
||||||
|
|
@ -233,17 +193,9 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
|
||||||
val template = if (!searchLink.contains("{searchTerms}")) {
|
val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
|
||||||
repository.getSearchTemplate(searchLink) ?: searchLink
|
val catalog = _uiState.value.currentCatalog
|
||||||
} else {
|
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
|
||||||
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)}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openFeedUrl(finalUrl)
|
openFeedUrl(finalUrl)
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ fun androidHtmlToSemanticBlocks(
|
||||||
fontFamilyMap: Map<String, FontFamily>,
|
fontFamilyMap: Map<String, FontFamily>,
|
||||||
constraints: androidx.compose.ui.unit.Constraints,
|
constraints: androidx.compose.ui.unit.Constraints,
|
||||||
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
|
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
|
||||||
mathSvgCache: Map<String, String> = emptyMap()
|
mathSvgCache: Map<String, String> = emptyMap(),
|
||||||
|
adaptThemeColors: Boolean = false
|
||||||
): List<SemanticBlock> {
|
): List<SemanticBlock> {
|
||||||
return htmlToSemanticBlocks(
|
return htmlToSemanticBlocks(
|
||||||
html = html,
|
html = html,
|
||||||
|
|
@ -76,6 +77,7 @@ fun androidHtmlToSemanticBlocks(
|
||||||
imageDimensionsCache = imageDimensionsCache,
|
imageDimensionsCache = imageDimensionsCache,
|
||||||
mathSvgCache = mathSvgCache,
|
mathSvgCache = mathSvgCache,
|
||||||
resourceResolver = AndroidHtmlResourceResolver,
|
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.BookProcessingInput
|
||||||
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
|
||||||
import com.aryan.reader.paginatedreader.data.ConfigurationCache
|
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.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.ProcessedBook
|
||||||
import com.aryan.reader.paginatedreader.data.ProcessedChapter
|
import com.aryan.reader.paginatedreader.data.ProcessedChapter
|
||||||
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
|
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
|
||||||
|
|
@ -80,7 +83,8 @@ data class TtsChunk(
|
||||||
val text: String,
|
val text: String,
|
||||||
val sourceCfi: String,
|
val sourceCfi: String,
|
||||||
val startOffsetInSource: Int,
|
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> {
|
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)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@Stable
|
@Stable
|
||||||
|
|
@ -138,7 +162,7 @@ class BookPaginator(
|
||||||
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
|
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
|
||||||
val chapterStartPageIndices = 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>?) {
|
override fun entryRemoved(evicted: Boolean, key: Int, oldValue: List<Page>, newValue: List<Page>?) {
|
||||||
Timber.d("Chapter $key pages removed from cache. Evicted: $evicted")
|
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 chapterCharacterIndex = ConcurrentHashMap<Int, List<PageCharacterRange>>()
|
||||||
private val chapterCumulativeChars = ConcurrentHashMap<Int, List<Long>>()
|
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 var pageCountsAreAccurate by mutableStateOf(false)
|
||||||
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
|
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
|
||||||
private var currentConfigHash: Int = 0
|
private var currentConfigHash: Int = 0
|
||||||
|
@Volatile
|
||||||
|
private var chapterStartSnapshot: IntArray = IntArray(0)
|
||||||
|
|
||||||
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
|
||||||
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
|
||||||
|
|
@ -198,6 +227,7 @@ class BookPaginator(
|
||||||
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
||||||
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
||||||
Timber.i("Book cache is new or stale. Creating initial record.")
|
Timber.i("Book cache is new or stale. Creating initial record.")
|
||||||
|
bookCacheDao.deleteEntireBookCache(bookId)
|
||||||
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
|
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
|
||||||
bookCacheDao.insertProcessedBook(initialBook)
|
bookCacheDao.insertProcessedBook(initialBook)
|
||||||
enqueueBookProcessingWork()
|
enqueueBookProcessingWork()
|
||||||
|
|
@ -229,8 +259,10 @@ class BookPaginator(
|
||||||
triggerPagination(startChapter, PRIORITY_HIGHEST)
|
triggerPagination(startChapter, PRIORITY_HIGHEST)
|
||||||
|
|
||||||
// Queue neighbors with lower priority
|
// Queue neighbors with lower priority
|
||||||
if (startChapter + 1 < chapters.size) triggerPagination(startChapter + 1, PRIORITY_LOW)
|
for (offset in 1..2) {
|
||||||
if (startChapter - 1 >= 0) triggerPagination(startChapter - 1, PRIORITY_LOW)
|
if (startChapter + offset < chapters.size) triggerPagination(startChapter + offset, PRIORITY_LOW)
|
||||||
|
if (startChapter - offset >= 0) triggerPagination(startChapter - offset, PRIORITY_LOW)
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
Timber.i("Paginator initialized. UI is ready.")
|
Timber.i("Paginator initialized. UI is ready.")
|
||||||
|
|
@ -238,10 +270,8 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ADD this new function]
|
|
||||||
private fun runEstimator() {
|
private fun runEstimator() {
|
||||||
var runningTotal = 0
|
var runningTotal = 0
|
||||||
val tempCounts = mutableMapOf<Int, Int>()
|
|
||||||
|
|
||||||
// This loop is extremely fast (math only)
|
// This loop is extremely fast (math only)
|
||||||
chapters.forEachIndexed { index, chapter ->
|
chapters.forEachIndexed { index, chapter ->
|
||||||
|
|
@ -255,12 +285,12 @@ class BookPaginator(
|
||||||
chapterPageCounts[index] = estimatedCount
|
chapterPageCounts[index] = estimatedCount
|
||||||
chapterStartPageIndices[index] = runningTotal
|
chapterStartPageIndices[index] = runningTotal
|
||||||
|
|
||||||
tempCounts[index] = estimatedCount
|
|
||||||
runningTotal += estimatedCount
|
runningTotal += estimatedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPageCount = runningTotal
|
totalPageCount = runningTotal
|
||||||
pageCountsAreAccurate = false
|
pageCountsAreAccurate = false
|
||||||
|
rebuildChapterStartSnapshot()
|
||||||
Timber.i("Estimator finished. Estimated total pages: $totalPageCount")
|
Timber.i("Estimator finished. Estimated total pages: $totalPageCount")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +317,11 @@ class BookPaginator(
|
||||||
append("-pg:$paragraphGapMultiplier")
|
append("-pg:$paragraphGapMultiplier")
|
||||||
append("-img:$imageSizeMultiplier")
|
append("-img:$imageSizeMultiplier")
|
||||||
append("-vm:$verticalMarginMultiplier")
|
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()
|
val hash = configString.hashCode()
|
||||||
return hash
|
return hash
|
||||||
|
|
@ -325,6 +360,7 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
totalPageCount = runningTotal
|
totalPageCount = runningTotal
|
||||||
pageCountsAreAccurate = countsMap.size == chapters.size
|
pageCountsAreAccurate = countsMap.size == chapters.size
|
||||||
|
rebuildChapterStartSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateAndSaveConfigurationCache() {
|
private suspend fun updateAndSaveConfigurationCache() {
|
||||||
|
|
@ -340,12 +376,204 @@ class BookPaginator(
|
||||||
bookCacheDao.insertConfigurationCache(newCache)
|
bookCacheDao.insertConfigurationCache(newCache)
|
||||||
|
|
||||||
bookCacheDao.cleanupOldConfigurations(bookId)
|
bookCacheDao.cleanupOldConfigurations(bookId)
|
||||||
|
bookCacheDao.cleanupOldPageCaches(bookId)
|
||||||
|
|
||||||
if (finalizedChapterCounts.size >= chapters.size) {
|
if (finalizedChapterCounts.size >= chapters.size) {
|
||||||
pageCountsAreAccurate = true
|
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>? {
|
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
|
||||||
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
|
||||||
if (pages.isNullOrEmpty()) {
|
if (pages.isNullOrEmpty()) {
|
||||||
|
|
@ -401,7 +629,12 @@ class BookPaginator(
|
||||||
|
|
||||||
private fun enqueueBookProcessingWork() {
|
private fun enqueueBookProcessingWork() {
|
||||||
val serializableChapters = chapters.map {
|
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(
|
val input = BookProcessingInput(
|
||||||
|
|
@ -437,13 +670,12 @@ class BookPaginator(
|
||||||
chapterAbsPath = chapter.absPath,
|
chapterAbsPath = chapter.absPath,
|
||||||
extractionBasePath = extractionBasePath,
|
extractionBasePath = extractionBasePath,
|
||||||
userTextAlign = userTextAlign,
|
userTextAlign = userTextAlign,
|
||||||
paragraphGapMultiplier = paragraphGapMultiplier
|
paragraphGapMultiplier = paragraphGapMultiplier,
|
||||||
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
|
|
||||||
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->
|
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->
|
||||||
if (cachedChapter.estimatedPageCount == 0) {
|
if (cachedChapter.contentBlocksProto.isNotEmpty()) {
|
||||||
Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.")
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
|
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
|
||||||
|
|
||||||
|
|
@ -466,10 +698,12 @@ class BookPaginator(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
|
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
|
var htmlToParse = chapter.htmlContent
|
||||||
if (htmlToParse.isEmpty()) {
|
if (htmlToParse.isEmpty()) {
|
||||||
|
|
@ -506,10 +740,10 @@ class BookPaginator(
|
||||||
val processedHtml = document.outerHtml()
|
val processedHtml = document.outerHtml()
|
||||||
|
|
||||||
var parsingCssRules = OptimizedCssRules()
|
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)
|
parsingCssRules = parsingCssRules.merge(uaResult.rules)
|
||||||
bookCss.forEach { (path, content) ->
|
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)
|
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,7 +756,8 @@ class BookPaginator(
|
||||||
density = density,
|
density = density,
|
||||||
fontFamilyMap = fontFamilyMap,
|
fontFamilyMap = fontFamilyMap,
|
||||||
constraints = constraints,
|
constraints = constraints,
|
||||||
mathSvgCache = svgResults
|
mathSvgCache = svgResults,
|
||||||
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
|
@ -617,6 +852,7 @@ class BookPaginator(
|
||||||
for (i in (chapterIndex + 1) until chapters.size) {
|
for (i in (chapterIndex + 1) until chapters.size) {
|
||||||
chapterStartPageIndices[i] = (chapterStartPageIndices[i] ?: 0) + difference
|
chapterStartPageIndices[i] = (chapterStartPageIndices[i] ?: 0) + difference
|
||||||
}
|
}
|
||||||
|
rebuildChapterStartSnapshot()
|
||||||
|
|
||||||
if (chapterIndex < currentUserChapterIndex.value) {
|
if (chapterIndex < currentUserChapterIndex.value) {
|
||||||
pageShiftRequest.tryEmit(difference)
|
pageShiftRequest.tryEmit(difference)
|
||||||
|
|
@ -640,6 +876,14 @@ class BookPaginator(
|
||||||
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
|
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
|
||||||
val currentPageInChapter = pageIndex - chapterStart
|
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
|
var lastFoundAnchor: String? = null
|
||||||
val anchorSet = tocAnchors.toSet()
|
val anchorSet = tocAnchors.toSet()
|
||||||
|
|
||||||
|
|
@ -685,6 +929,19 @@ class BookPaginator(
|
||||||
)
|
)
|
||||||
return null
|
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
|
val entry = chapterStartPageIndices.entries
|
||||||
.filter { it.value <= pageIndex }
|
.filter { it.value <= pageIndex }
|
||||||
.maxWithOrNull(compareBy({ it.value }, { it.key }))
|
.maxWithOrNull(compareBy({ it.value }, { it.key }))
|
||||||
|
|
@ -698,12 +955,24 @@ class BookPaginator(
|
||||||
|
|
||||||
override fun getCfiForPage(pageIndex: Int): String? {
|
override fun getCfiForPage(pageIndex: Int): String? {
|
||||||
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
|
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]
|
val chapterPages = pageCache[chapterIndex]
|
||||||
if (chapterPages == null) {
|
if (chapterPages == null) {
|
||||||
Timber.w("getCfiForPage: Chapter $chapterIndex not in cache for page $pageIndex.")
|
Timber.w("getCfiForPage: Chapter $chapterIndex not in cache for page $pageIndex.")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val pageInChapterIndex = pageIndex - (chapterStartPageIndices[chapterIndex] ?: 0)
|
|
||||||
val pageContent = chapterPages.getOrNull(pageInChapterIndex)?.content ?: return null
|
val pageContent = chapterPages.getOrNull(pageInChapterIndex)?.content ?: return null
|
||||||
|
|
||||||
val firstTextBlock = pageContent.firstOrNull { it is TextContentBlock } as? TextContentBlock
|
val firstTextBlock = pageContent.firstOrNull { it is TextContentBlock } as? TextContentBlock
|
||||||
|
|
@ -729,6 +998,11 @@ class BookPaginator(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadCachedPagesForChapter(chapter, chapterIndex)?.let {
|
||||||
|
Timber.d("paginateChapter: Persistent page cache HIT for chapter $chapterIndex.")
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
val blocks = blockCache[chapterIndex] ?: run {
|
val blocks = blockCache[chapterIndex] ?: run {
|
||||||
Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
|
Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
|
||||||
val blocksFromDb = getBlocksForChapter(chapter, chapterIndex)
|
val blocksFromDb = getBlocksForChapter(chapter, chapterIndex)
|
||||||
|
|
@ -757,47 +1031,10 @@ class BookPaginator(
|
||||||
pageCache.put(chapterIndex, pages)
|
pageCache.put(chapterIndex, pages)
|
||||||
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
||||||
|
|
||||||
pageCache.put(chapterIndex, pages)
|
applyPageRuntimeIndexes(chapterIndex, pages)
|
||||||
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
|
savePageCacheAsync(chapter, chapterIndex, pages)
|
||||||
|
|
||||||
val characterIndex = mutableListOf<PageCharacterRange>()
|
updatePageCountsOnMain(chapterIndex, pages.size)
|
||||||
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++
|
|
||||||
}
|
|
||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -835,16 +1072,18 @@ class BookPaginator(
|
||||||
|
|
||||||
private fun prefetchChapters(currentChapterIndex: Int) {
|
private fun prefetchChapters(currentChapterIndex: Int) {
|
||||||
Timber.v("Prefetching chapters around index $currentChapterIndex.")
|
Timber.v("Prefetching chapters around index $currentChapterIndex.")
|
||||||
val nextChapterIndex = currentChapterIndex + 1
|
for (offset in 1..2) {
|
||||||
|
val nextChapterIndex = currentChapterIndex + offset
|
||||||
if (nextChapterIndex < chapters.size) {
|
if (nextChapterIndex < chapters.size) {
|
||||||
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
|
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
|
||||||
}
|
}
|
||||||
|
|
||||||
val prevChapterIndex = currentChapterIndex - 1
|
val prevChapterIndex = currentChapterIndex - offset
|
||||||
if (prevChapterIndex >= 0) {
|
if (prevChapterIndex >= 0) {
|
||||||
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
|
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getChapterPathForPage(pageIndex: Int): String? {
|
override fun getChapterPathForPage(pageIndex: Int): String? {
|
||||||
val chapterIndex = findChapterIndexForPage(pageIndex)
|
val chapterIndex = findChapterIndexForPage(pageIndex)
|
||||||
|
|
@ -908,6 +1147,19 @@ class BookPaginator(
|
||||||
return@launch
|
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
|
// 3. FIND PAGE
|
||||||
var targetPageInChapter = 0
|
var targetPageInChapter = 0
|
||||||
var found = false
|
var found = false
|
||||||
|
|
@ -1093,6 +1345,26 @@ class BookPaginator(
|
||||||
return null
|
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
|
var fallbackPageInChapter = -1
|
||||||
|
|
||||||
for ((pageIndex, page) in chapterPages.withIndex()) {
|
for ((pageIndex, page) in chapterPages.withIndex()) {
|
||||||
|
|
@ -1150,6 +1422,23 @@ class BookPaginator(
|
||||||
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
|
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
|
||||||
|
|
||||||
Timber.tag("POS_DIAG").d("getLocatorForPage: Request pageIndex=$pageIndex. Resolved chapterIndex=$chapterIndex (starts at $chStart). PageInChapter=${pageIndex - chStart}")
|
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
|
val pageContent = getPageContent(pageIndex) ?: return null
|
||||||
|
|
||||||
Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}")
|
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.io.File
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
private const val DEBUG_CONTENT_STYLING = false
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
class ContentStyler(
|
class ContentStyler(
|
||||||
private val baseTextStyle: TextStyle,
|
private val baseTextStyle: TextStyle,
|
||||||
|
|
@ -57,7 +59,8 @@ class ContentStyler(
|
||||||
private val chapterAbsPath: String,
|
private val chapterAbsPath: String,
|
||||||
private val extractionBasePath: String,
|
private val extractionBasePath: String,
|
||||||
private val userTextAlign: TextAlign?,
|
private val userTextAlign: TextAlign?,
|
||||||
private val paragraphGapMultiplier: Float
|
private val paragraphGapMultiplier: Float,
|
||||||
|
private val adaptThemeColors: Boolean = true
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
|
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
|
||||||
|
|
@ -167,6 +170,7 @@ class ContentStyler(
|
||||||
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
|
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
|
||||||
val finalSvgContent = when {
|
val finalSvgContent = when {
|
||||||
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
|
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
|
||||||
|
!adaptThemeColors -> embedImagesInSvg(nonBlankSvgContent)
|
||||||
else -> {
|
else -> {
|
||||||
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
|
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
|
||||||
embedImagesInSvg(themedSvg)
|
embedImagesInSvg(themedSvg)
|
||||||
|
|
@ -223,6 +227,10 @@ class ContentStyler(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyThemeToStyle(style: CssStyle): CssStyle {
|
private fun applyThemeToStyle(style: CssStyle): CssStyle {
|
||||||
|
if (!adaptThemeColors) {
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
val newSpanStyle = style.spanStyle.let { original ->
|
val newSpanStyle = style.spanStyle.let { original ->
|
||||||
val newColor = if (original.color.isSpecified) {
|
val newColor = if (original.color.isSpecified) {
|
||||||
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||||
|
|
@ -346,7 +354,9 @@ class ContentStyler(
|
||||||
block: SemanticTextBlock,
|
block: SemanticTextBlock,
|
||||||
blockStyle: CssStyle
|
blockStyle: CssStyle
|
||||||
): AnnotatedString {
|
): AnnotatedString {
|
||||||
|
if (DEBUG_CONTENT_STYLING) {
|
||||||
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
|
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
|
||||||
|
}
|
||||||
|
|
||||||
val builtString = buildAnnotatedString {
|
val builtString = buildAnnotatedString {
|
||||||
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
|
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
|
||||||
|
|
@ -394,7 +404,9 @@ class ContentStyler(
|
||||||
.merge(blockStyle.spanStyle)
|
.merge(blockStyle.spanStyle)
|
||||||
.copy(fontFamily = effectiveBlockFontFamily)
|
.copy(fontFamily = effectiveBlockFontFamily)
|
||||||
|
|
||||||
|
if (DEBUG_CONTENT_STYLING) {
|
||||||
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
|
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
|
||||||
|
}
|
||||||
|
|
||||||
withStyle(finalParagraphStyle) {
|
withStyle(finalParagraphStyle) {
|
||||||
withStyle(initialSpanStyle) {
|
withStyle(initialSpanStyle) {
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,20 @@ data class Locator(
|
||||||
class LocatorConverter(
|
class LocatorConverter(
|
||||||
private val bookCacheDao: BookCacheDao,
|
private val bookCacheDao: BookCacheDao,
|
||||||
private val proto: ProtoBuf,
|
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) {
|
private fun cacheBookId(book: EpubBook, overrideBookId: String? = null): String {
|
||||||
Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='${book.title}' index=$chapterIndex")
|
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 {
|
try {
|
||||||
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
|
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
|
||||||
|
|
||||||
|
|
@ -98,7 +108,8 @@ class LocatorConverter(
|
||||||
baseFontSizeSp = 16f,
|
baseFontSizeSp = 16f,
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraints = constraints,
|
constraints = constraints,
|
||||||
isDarkTheme = false
|
isDarkTheme = false,
|
||||||
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
|
|
||||||
val rules = bookCssResult.rules
|
val rules = bookCssResult.rules
|
||||||
|
|
@ -123,16 +134,17 @@ class LocatorConverter(
|
||||||
extractionBasePath = book.extractionBasePath,
|
extractionBasePath = book.extractionBasePath,
|
||||||
density = density,
|
density = density,
|
||||||
fontFamilyMap = emptyMap(),
|
fontFamilyMap = emptyMap(),
|
||||||
constraints = constraints
|
constraints = constraints,
|
||||||
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
|
|
||||||
val protoBytes = proto.encodeToByteArray(semanticBlocks)
|
val protoBytes = proto.encodeToByteArray(semanticBlocks)
|
||||||
|
|
||||||
val newCacheEntry = ProcessedChapter(
|
val newCacheEntry = ProcessedChapter(
|
||||||
bookId = book.title,
|
bookId = cacheBookId,
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
contentBlocksProto = protoBytes,
|
contentBlocksProto = protoBytes,
|
||||||
estimatedPageCount = 0
|
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
|
||||||
)
|
)
|
||||||
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
|
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
|
||||||
semanticBlocks
|
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")
|
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
|
var allBlocks: List<SemanticBlock>? = null
|
||||||
|
|
||||||
|
|
@ -154,7 +166,7 @@ class LocatorConverter(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) {
|
if (allBlocks.isNullOrEmpty()) {
|
||||||
allBlocks = processAndCacheChapter(book, chapterIndex)
|
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) {
|
if (allBlocks.isNullOrEmpty()) {
|
||||||
|
|
@ -224,8 +236,8 @@ class LocatorConverter(
|
||||||
return bestMatch
|
return bestMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List<TtsChunk>? = withContext(Dispatchers.IO) {
|
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List<TtsChunk>? = withContext(Dispatchers.IO) {
|
||||||
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
|
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
|
||||||
|
|
||||||
var allBlocks: List<SemanticBlock>? = null
|
var allBlocks: List<SemanticBlock>? = null
|
||||||
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
||||||
|
|
@ -235,7 +247,7 @@ class LocatorConverter(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) {
|
if (allBlocks.isNullOrEmpty()) {
|
||||||
allBlocks = processAndCacheChapter(book, chapterIndex)
|
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) return@withContext null
|
if (allBlocks.isNullOrEmpty()) return@withContext null
|
||||||
|
|
@ -279,9 +291,9 @@ class LocatorConverter(
|
||||||
chunks
|
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")
|
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
|
var blocks: List<SemanticBlock>? = null
|
||||||
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
||||||
|
|
@ -291,7 +303,7 @@ class LocatorConverter(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blocks.isNullOrEmpty()) {
|
if (blocks.isNullOrEmpty()) {
|
||||||
blocks = processAndCacheChapter(book, locator.chapterIndex)
|
blocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blocks.isNullOrEmpty()) {
|
if (blocks.isNullOrEmpty()) {
|
||||||
|
|
@ -331,8 +343,26 @@ class LocatorConverter(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) {
|
private fun estimateSemanticPageCount(blocks: List<SemanticBlock>): Int {
|
||||||
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
|
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
|
var allBlocks: List<SemanticBlock>? = null
|
||||||
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
|
||||||
|
|
@ -342,7 +372,7 @@ class LocatorConverter(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) {
|
if (allBlocks.isNullOrEmpty()) {
|
||||||
allBlocks = processAndCacheChapter(book, locator.chapterIndex)
|
allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBlocks.isNullOrEmpty()) return@withContext null
|
if (allBlocks.isNullOrEmpty()) return@withContext null
|
||||||
|
|
|
||||||
|
|
@ -723,6 +723,7 @@ private fun WrappingContentLayout(
|
||||||
fun PaginatedReaderScreen(
|
fun PaginatedReaderScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
book: EpubBook,
|
book: EpubBook,
|
||||||
|
bookId: String? = null,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
effectiveBg: Color,
|
effectiveBg: Color,
|
||||||
effectiveText: Color,
|
effectiveText: Color,
|
||||||
|
|
@ -739,6 +740,9 @@ fun PaginatedReaderScreen(
|
||||||
textAlign: ReaderTextAlign,
|
textAlign: ReaderTextAlign,
|
||||||
ttsHighlightInfo: TtsHighlightInfo?,
|
ttsHighlightInfo: TtsHighlightInfo?,
|
||||||
initialChapterIndexInBook: Int?,
|
initialChapterIndexInBook: Int?,
|
||||||
|
fallbackLocatorForReconfiguration: Locator? = null,
|
||||||
|
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
|
||||||
|
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
|
||||||
onPaginatorReady: (IPaginator) -> Unit,
|
onPaginatorReady: (IPaginator) -> Unit,
|
||||||
onTap: (Offset?) -> Unit,
|
onTap: (Offset?) -> Unit,
|
||||||
isProUser: Boolean,
|
isProUser: Boolean,
|
||||||
|
|
@ -796,37 +800,32 @@ fun PaginatedReaderScreen(
|
||||||
|
|
||||||
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
|
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
|
||||||
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
|
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
|
||||||
|
val latestFallbackLocatorForReconfiguration by rememberUpdatedState(fallbackLocatorForReconfiguration)
|
||||||
|
|
||||||
val previousState = remember {
|
var previousConstraints by remember {
|
||||||
arrayOf<Any>(this.constraints, isDarkTheme, effectiveBg, effectiveText)
|
mutableStateOf(this.constraints)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousState[0] != this.constraints ||
|
if (previousConstraints != this.constraints) {
|
||||||
previousState[1] != isDarkTheme ||
|
|
||||||
previousState[2] != effectiveBg ||
|
|
||||||
previousState[3] != effectiveText
|
|
||||||
) {
|
|
||||||
val activePaginator = currentPaginatorRef.value
|
val activePaginator = currentPaginatorRef.value
|
||||||
if (activePaginator is BookPaginator) {
|
|
||||||
val currentPage = pagerState.currentPage
|
val currentPage = pagerState.currentPage
|
||||||
val locator = activePaginator.getLocatorForPage(currentPage)
|
val locator = resolvePaginatedReconfigurationAnchor(
|
||||||
|
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
|
||||||
|
fallbackLocator = fallbackLocatorForReconfiguration
|
||||||
|
)
|
||||||
anchorLocatorForReconfig = locator
|
anchorLocatorForReconfig = locator
|
||||||
|
|
||||||
Timber.tag("ThemeReconfig").d("""
|
Timber.tag("ThemeReconfig").d("""
|
||||||
RECONFIG DETECTED
|
RECONFIG DETECTED
|
||||||
- Reason: ${if (previousState[0] != this.constraints) "Constraints" else "Theme/Colors"}
|
- Reason: Constraints
|
||||||
- Current Page: $currentPage
|
- Current Page: $currentPage
|
||||||
- Saved Locator: $locator
|
- Saved Locator: $locator
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
}
|
previousConstraints = this.constraints
|
||||||
previousState[0] = this.constraints
|
|
||||||
previousState[1] = isDarkTheme
|
|
||||||
previousState[2] = effectiveBg
|
|
||||||
previousState[3] = effectiveText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val textStyle = remember(
|
val layoutTextStyle = remember(
|
||||||
baseTextStyle, effectiveText,
|
baseTextStyle,
|
||||||
debouncedFontSizeMult,
|
debouncedFontSizeMult,
|
||||||
debouncedLineHeightMult,
|
debouncedLineHeightMult,
|
||||||
debouncedFontFamily
|
debouncedFontFamily
|
||||||
|
|
@ -835,7 +834,7 @@ fun PaginatedReaderScreen(
|
||||||
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
|
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
|
||||||
|
|
||||||
baseTextStyle.copy(
|
baseTextStyle.copy(
|
||||||
color = effectiveText,
|
color = Color.Unspecified,
|
||||||
fontSize = adjustedFontSize,
|
fontSize = adjustedFontSize,
|
||||||
lineHeight = adjustedLineHeight,
|
lineHeight = adjustedLineHeight,
|
||||||
fontFamily = debouncedFontFamily,
|
fontFamily = debouncedFontFamily,
|
||||||
|
|
@ -848,6 +847,9 @@ fun PaginatedReaderScreen(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val textStyle = remember(layoutTextStyle, effectiveText) {
|
||||||
|
layoutTextStyle.copy(color = effectiveText)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||||
|
|
@ -875,13 +877,14 @@ fun PaginatedReaderScreen(
|
||||||
delay(400L)
|
delay(400L)
|
||||||
|
|
||||||
val activePaginator = currentPaginatorRef.value
|
val activePaginator = currentPaginatorRef.value
|
||||||
if (activePaginator is BookPaginator) {
|
|
||||||
val currentPage = pagerState.currentPage
|
val currentPage = pagerState.currentPage
|
||||||
val locator = activePaginator.getLocatorForPage(currentPage)
|
val locator = resolvePaginatedReconfigurationAnchor(
|
||||||
|
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
|
||||||
|
fallbackLocator = fallbackLocatorForReconfiguration
|
||||||
|
)
|
||||||
if (locator != null) {
|
if (locator != null) {
|
||||||
anchorLocatorForReconfig = locator
|
anchorLocatorForReconfig = locator
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
debouncedFontSizeMult = fontSizeMultiplier
|
debouncedFontSizeMult = fontSizeMultiplier
|
||||||
debouncedLineHeightMult = lineHeightMultiplier
|
debouncedLineHeightMult = lineHeightMultiplier
|
||||||
|
|
@ -955,7 +958,15 @@ fun PaginatedReaderScreen(
|
||||||
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
|
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
|
||||||
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
|
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
|
val userAgentStylesheet = UserAgentStylesheet.default
|
||||||
var allRules = OptimizedCssRules()
|
var allRules = OptimizedCssRules()
|
||||||
val allFontFaces = mutableListOf<FontFaceInfo>()
|
val allFontFaces = mutableListOf<FontFaceInfo>()
|
||||||
|
|
@ -963,12 +974,11 @@ fun PaginatedReaderScreen(
|
||||||
val uaResult = CssParser.parse(
|
val uaResult = CssParser.parse(
|
||||||
cssContent = userAgentStylesheet,
|
cssContent = userAgentStylesheet,
|
||||||
cssPath = null,
|
cssPath = null,
|
||||||
baseFontSizeSp = textStyle.fontSize.value,
|
baseFontSizeSp = layoutTextStyle.fontSize.value,
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraints = textConstraints,
|
constraints = textConstraints,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = false,
|
||||||
themeBackgroundColor = effectiveBg,
|
adaptThemeColors = false
|
||||||
themeTextColor = effectiveText
|
|
||||||
)
|
)
|
||||||
allRules = allRules.merge(uaResult.rules)
|
allRules = allRules.merge(uaResult.rules)
|
||||||
allFontFaces.addAll(uaResult.fontFaces)
|
allFontFaces.addAll(uaResult.fontFaces)
|
||||||
|
|
@ -977,12 +987,11 @@ fun PaginatedReaderScreen(
|
||||||
val bookCssResult = CssParser.parse(
|
val bookCssResult = CssParser.parse(
|
||||||
cssContent = content,
|
cssContent = content,
|
||||||
cssPath = path,
|
cssPath = path,
|
||||||
baseFontSizeSp = textStyle.fontSize.value,
|
baseFontSizeSp = layoutTextStyle.fontSize.value,
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraints = textConstraints,
|
constraints = textConstraints,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = false,
|
||||||
themeBackgroundColor = effectiveBg,
|
adaptThemeColors = false
|
||||||
themeTextColor = effectiveText
|
|
||||||
)
|
)
|
||||||
allRules = allRules.merge(bookCssResult.rules)
|
allRules = allRules.merge(bookCssResult.rules)
|
||||||
allFontFaces.addAll(bookCssResult.fontFaces)
|
allFontFaces.addAll(bookCssResult.fontFaces)
|
||||||
|
|
@ -990,12 +999,11 @@ fun PaginatedReaderScreen(
|
||||||
val fontFamilyMap = loadFontFamilies(
|
val fontFamilyMap = loadFontFamilies(
|
||||||
fontFaces = allFontFaces, extractionPath = book.extractionBasePath
|
fontFaces = allFontFaces, extractionPath = book.extractionBasePath
|
||||||
)
|
)
|
||||||
book.title
|
|
||||||
val bookCacheDao =
|
val bookCacheDao =
|
||||||
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
|
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
|
||||||
val proto = ProtoBuf { serializersModule = semanticBlockModule }
|
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.d("Recreating BookPaginator for ID: $uniqueBookId. TextAlign: $userTextAlign")
|
||||||
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter")
|
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter")
|
||||||
|
|
@ -1005,7 +1013,7 @@ fun PaginatedReaderScreen(
|
||||||
chapters = book.chaptersForPagination,
|
chapters = book.chaptersForPagination,
|
||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
constraints = textConstraints,
|
constraints = textConstraints,
|
||||||
textStyle = textStyle,
|
textStyle = layoutTextStyle,
|
||||||
extractionBasePath = book.extractionBasePath,
|
extractionBasePath = book.extractionBasePath,
|
||||||
density = density,
|
density = density,
|
||||||
fontFamilyMap = fontFamilyMap,
|
fontFamilyMap = fontFamilyMap,
|
||||||
|
|
@ -1037,6 +1045,8 @@ fun PaginatedReaderScreen(
|
||||||
if (anchorLocatorForReconfig != null) {
|
if (anchorLocatorForReconfig != null) {
|
||||||
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
|
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
|
||||||
|
|
||||||
|
try {
|
||||||
|
onReconfigurationRestoreActiveChanged(true)
|
||||||
snapshotFlow { paginator.isLoading }.filter { !it }.first()
|
snapshotFlow { paginator.isLoading }.filter { !it }.first()
|
||||||
|
|
||||||
val targetLocator = anchorLocatorForReconfig
|
val targetLocator = anchorLocatorForReconfig
|
||||||
|
|
@ -1047,16 +1057,21 @@ fun PaginatedReaderScreen(
|
||||||
|
|
||||||
if (page != null) {
|
if (page != null) {
|
||||||
pagerState.scrollToPage(page)
|
pagerState.scrollToPage(page)
|
||||||
|
paginator.onUserScrolledTo(page)
|
||||||
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
|
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
|
||||||
} else {
|
} else {
|
||||||
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
|
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
|
||||||
if (startPage != null) {
|
if (startPage != null) {
|
||||||
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
|
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
|
||||||
pagerState.scrollToPage(startPage)
|
pagerState.scrollToPage(startPage)
|
||||||
|
paginator.onUserScrolledTo(startPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
anchorLocatorForReconfig = null
|
anchorLocatorForReconfig = null
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
onReconfigurationRestoreActiveChanged(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1083,13 +1098,31 @@ fun PaginatedReaderScreen(
|
||||||
|
|
||||||
LaunchedEffect(pagerState, paginator) {
|
LaunchedEffect(pagerState, paginator) {
|
||||||
snapshotFlow { pagerState.currentPage }.debounce(500)
|
snapshotFlow { pagerState.currentPage }.debounce(500)
|
||||||
.collectLatest { page -> paginator.onUserScrolledTo(page) }
|
.collectLatest { page ->
|
||||||
|
if (anchorLocatorForReconfig == null) {
|
||||||
|
paginator.onUserScrolledTo(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(paginator, pagerState) {
|
LaunchedEffect(paginator, pagerState) {
|
||||||
paginator.pageShiftRequest.collect { shiftAmount ->
|
paginator.pageShiftRequest.collect { shiftAmount ->
|
||||||
|
val anchor = resolvePaginatedReconfigurationAnchor(
|
||||||
|
currentPageLocator = anchorLocatorForReconfig,
|
||||||
|
fallbackLocator = latestFallbackLocatorForReconfiguration
|
||||||
|
)
|
||||||
|
val resolvedPage = anchor?.let { locator ->
|
||||||
|
(paginator as? BookPaginator)?.findPageForLocator(locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPage != null) {
|
||||||
|
pagerState.scrollToPage(resolvedPage)
|
||||||
|
paginator.onUserScrolledTo(resolvedPage)
|
||||||
|
} else {
|
||||||
val newPage = pagerState.currentPage + shiftAmount
|
val newPage = pagerState.currentPage + shiftAmount
|
||||||
pagerState.scrollToPage(newPage)
|
pagerState.scrollToPage(newPage)
|
||||||
|
paginator.onUserScrolledTo(newPage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2218,6 +2251,13 @@ internal fun PaginatedReaderContent(
|
||||||
|
|
||||||
var pageContent by remember { mutableStateOf<Page?>(null) }
|
var pageContent by remember { mutableStateOf<Page?>(null) }
|
||||||
var currentChapterPath by remember { mutableStateOf<String?>(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) {
|
LaunchedEffect(pageIndex, uiState.generation) {
|
||||||
val fetchStartTime = System.currentTimeMillis()
|
val fetchStartTime = System.currentTimeMillis()
|
||||||
|
|
@ -2236,7 +2276,7 @@ internal fun PaginatedReaderContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val textBlocksOnPage =
|
val textBlocksOnPage =
|
||||||
pageContent?.content?.extractTextBlocks()
|
themedPageContent?.content?.extractTextBlocks()
|
||||||
?.filter { it.cfi != null } ?: emptyList()
|
?.filter { it.cfi != null } ?: emptyList()
|
||||||
val lastTextBlock = textBlocksOnPage.lastOrNull()
|
val lastTextBlock = textBlocksOnPage.lastOrNull()
|
||||||
val lastBlockAbs = lastTextBlock?.let {
|
val lastBlockAbs = lastTextBlock?.let {
|
||||||
|
|
@ -2379,7 +2419,8 @@ internal fun PaginatedReaderContent(
|
||||||
horizontal = horizontalPadding,
|
horizontal = horizontalPadding,
|
||||||
vertical = verticalPadding
|
vertical = verticalPadding
|
||||||
), contentAlignment = Alignment.TopStart) {
|
), contentAlignment = Alignment.TopStart) {
|
||||||
if (pageContent != null) {
|
if (themedPageContent != null) {
|
||||||
|
val displayPage = themedPageContent
|
||||||
val onGeneralTapCallback: (Offset) -> Unit = { offset ->
|
val onGeneralTapCallback: (Offset) -> Unit = { offset ->
|
||||||
activeSelection = null
|
activeSelection = null
|
||||||
onTap(offset)
|
onTap(offset)
|
||||||
|
|
@ -2405,7 +2446,7 @@ internal fun PaginatedReaderContent(
|
||||||
val ttsHighlightColor =
|
val ttsHighlightColor =
|
||||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
|
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
|
||||||
|
|
||||||
pageContent!!.content.forEach { block ->
|
displayPage.content.forEach { block ->
|
||||||
val marginModifier = Modifier.padding(
|
val marginModifier = Modifier.padding(
|
||||||
top = block.style.margin.top.coerceAtLeast(0.dp),
|
top = block.style.margin.top.coerceAtLeast(0.dp),
|
||||||
bottom = block.style.margin.bottom.coerceAtLeast(
|
bottom = block.style.margin.bottom.coerceAtLeast(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
|
@ -77,7 +78,8 @@ class PaginatedReaderViewModel : ViewModel() {
|
||||||
context: Context,
|
context: Context,
|
||||||
initialChapterToPaginate: Int?,
|
initialChapterToPaginate: Int?,
|
||||||
mathMLRenderer: MathMLRenderer,
|
mathMLRenderer: MathMLRenderer,
|
||||||
paragraphGapMultiplier: Float
|
paragraphGapMultiplier: Float,
|
||||||
|
bookId: String? = null
|
||||||
) {
|
) {
|
||||||
if (paginator != null) return
|
if (paginator != null) return
|
||||||
|
|
||||||
|
|
@ -88,16 +90,16 @@ class PaginatedReaderViewModel : ViewModel() {
|
||||||
val userAgentStylesheet = UserAgentStylesheet.default
|
val userAgentStylesheet = UserAgentStylesheet.default
|
||||||
var allRules = OptimizedCssRules()
|
var allRules = OptimizedCssRules()
|
||||||
val allFontFaces = mutableListOf<FontFaceInfo>()
|
val allFontFaces = mutableListOf<FontFaceInfo>()
|
||||||
|
val layoutTextStyle = textStyle.copy(color = Color.Unspecified)
|
||||||
|
|
||||||
val uaResult = CssParser.parse(
|
val uaResult = CssParser.parse(
|
||||||
cssContent = userAgentStylesheet,
|
cssContent = userAgentStylesheet,
|
||||||
cssPath = null,
|
cssPath = null,
|
||||||
baseFontSizeSp = textStyle.fontSize.value,
|
baseFontSizeSp = layoutTextStyle.fontSize.value,
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraints = textConstraints,
|
constraints = textConstraints,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = false,
|
||||||
themeBackgroundColor = themeBackgroundColor,
|
adaptThemeColors = false
|
||||||
themeTextColor = themeTextColor
|
|
||||||
)
|
)
|
||||||
allRules = allRules.merge(uaResult.rules)
|
allRules = allRules.merge(uaResult.rules)
|
||||||
allFontFaces.addAll(uaResult.fontFaces)
|
allFontFaces.addAll(uaResult.fontFaces)
|
||||||
|
|
@ -106,12 +108,11 @@ class PaginatedReaderViewModel : ViewModel() {
|
||||||
val bookCssResult = CssParser.parse(
|
val bookCssResult = CssParser.parse(
|
||||||
cssContent = content,
|
cssContent = content,
|
||||||
cssPath = path,
|
cssPath = path,
|
||||||
baseFontSizeSp = textStyle.fontSize.value,
|
baseFontSizeSp = layoutTextStyle.fontSize.value,
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraints = textConstraints,
|
constraints = textConstraints,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = false,
|
||||||
themeBackgroundColor = themeBackgroundColor,
|
adaptThemeColors = false
|
||||||
themeTextColor = themeTextColor
|
|
||||||
)
|
)
|
||||||
allRules = allRules.merge(bookCssResult.rules)
|
allRules = allRules.merge(bookCssResult.rules)
|
||||||
allFontFaces.addAll(bookCssResult.fontFaces)
|
allFontFaces.addAll(bookCssResult.fontFaces)
|
||||||
|
|
@ -120,21 +121,21 @@ class PaginatedReaderViewModel : ViewModel() {
|
||||||
fontFaces = allFontFaces,
|
fontFaces = allFontFaces,
|
||||||
extractionPath = book.extractionBasePath
|
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 bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
|
||||||
val newPaginator = BookPaginator(
|
val newPaginator = BookPaginator(
|
||||||
coroutineScope = viewModelScope,
|
coroutineScope = viewModelScope,
|
||||||
chapters = book.chaptersForPagination,
|
chapters = book.chaptersForPagination,
|
||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
constraints = textConstraints,
|
constraints = textConstraints,
|
||||||
textStyle = textStyle,
|
textStyle = layoutTextStyle,
|
||||||
extractionBasePath = book.extractionBasePath,
|
extractionBasePath = book.extractionBasePath,
|
||||||
density = density,
|
density = density,
|
||||||
fontFamilyMap = fontFamilyMap,
|
fontFamilyMap = fontFamilyMap,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
themeBackgroundColor = themeBackgroundColor,
|
themeBackgroundColor = themeBackgroundColor,
|
||||||
themeTextColor = themeTextColor,
|
themeTextColor = themeTextColor,
|
||||||
bookId = bookId,
|
bookId = cacheBookId,
|
||||||
bookCacheDao = bookCacheDao,
|
bookCacheDao = bookCacheDao,
|
||||||
proto = proto,
|
proto = proto,
|
||||||
initialChapterToPaginate = initialChapterToPaginate ?: 0,
|
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 androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val DEBUG_PAGINATION_LOGS = false
|
||||||
|
|
||||||
interface BlockMeasurementProvider {
|
interface BlockMeasurementProvider {
|
||||||
suspend fun measure(block: ContentBlock): Int
|
suspend fun measure(block: ContentBlock): Int
|
||||||
suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>?
|
suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>?
|
||||||
|
|
@ -53,9 +56,13 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
private val density: Density,
|
private val density: Density,
|
||||||
private val imageSizeMultiplier: Float
|
private val imageSizeMultiplier: Float
|
||||||
) : BlockMeasurementProvider {
|
) : BlockMeasurementProvider {
|
||||||
|
private val measurementCache = ConcurrentHashMap<Int, Int>()
|
||||||
|
|
||||||
override suspend fun measure(block: ContentBlock): Int {
|
override suspend fun measure(block: ContentBlock): Int {
|
||||||
return measureBlockHeight(
|
val cacheKey = blockMeasurementCacheKey(block)
|
||||||
|
measurementCache[cacheKey]?.let { return it }
|
||||||
|
|
||||||
|
val measured = measureBlockHeight(
|
||||||
block = block,
|
block = block,
|
||||||
textMeasurer = textMeasurer,
|
textMeasurer = textMeasurer,
|
||||||
constraints = constraints,
|
constraints = constraints,
|
||||||
|
|
@ -64,6 +71,17 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
density = density,
|
density = density,
|
||||||
imageSizeMultiplier = imageSizeMultiplier
|
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>? {
|
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)
|
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
|
||||||
}.roundToInt()
|
}.roundToInt()
|
||||||
|
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
|
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
|
||||||
|
}
|
||||||
currentHeight += decorationTop
|
currentHeight += decorationTop
|
||||||
|
|
||||||
for (i in block.rows.indices) {
|
for (i in block.rows.indices) {
|
||||||
|
|
@ -305,7 +325,9 @@ class SuspendingAndroidBlockMeasurementProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentHeight + maxRowHeight + decorationBottom > availableHeight) {
|
if (currentHeight + maxRowHeight + decorationBottom > availableHeight) {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
|
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
|
||||||
|
}
|
||||||
splitRowIndex = i
|
splitRowIndex = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +432,9 @@ suspend fun paginate(
|
||||||
if (blocks.isEmpty()) {
|
if (blocks.isEmpty()) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
|
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
|
||||||
|
}
|
||||||
|
|
||||||
val pages = mutableListOf<Page>()
|
val pages = mutableListOf<Page>()
|
||||||
var currentPageContent = mutableListOf<ContentBlock>()
|
var currentPageContent = mutableListOf<ContentBlock>()
|
||||||
|
|
@ -437,8 +461,10 @@ suspend fun paginate(
|
||||||
|
|
||||||
val spaceRequired = blockHeightWithSafetyMargin + spaceBetweenBlocks
|
val spaceRequired = blockHeightWithSafetyMargin + spaceBetweenBlocks
|
||||||
|
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG")
|
Timber.tag("PAGINATION_DEBUG")
|
||||||
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
|
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
|
||||||
|
}
|
||||||
|
|
||||||
if (spaceRequired <= remainingHeight) {
|
if (spaceRequired <= remainingHeight) {
|
||||||
var blockToAdd = block
|
var blockToAdd = block
|
||||||
|
|
@ -608,23 +634,31 @@ suspend fun paginate(
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
|
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
|
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!wasSplit) {
|
if (!wasSplit) {
|
||||||
if (currentPageContent.isEmpty()) {
|
if (currentPageContent.isEmpty()) {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG")
|
Timber.tag("PAGINATION_DEBUG")
|
||||||
.w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)")
|
.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 forcedHeight = blockHeight + spaceBetweenBlocks
|
||||||
val blockToAdd = setBlockExpectedHeight(block, forcedHeight)
|
val blockToAdd = setBlockExpectedHeight(block, forcedHeight)
|
||||||
currentPageContent.add(blockToAdd)
|
currentPageContent.add(blockToAdd)
|
||||||
} else {
|
} else {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG")
|
Timber.tag("PAGINATION_DEBUG")
|
||||||
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
|
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
|
||||||
|
}
|
||||||
remainingBlocks.add(0, block)
|
remainingBlocks.add(0, block)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -643,7 +677,9 @@ suspend fun paginate(
|
||||||
pages.add(Page(content = currentPageContent.toList()))
|
pages.add(Page(content = currentPageContent.toList()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
|
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
|
||||||
|
}
|
||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -906,7 +942,9 @@ private suspend fun measureBlockHeight(
|
||||||
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
|
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
|
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
|
||||||
|
}
|
||||||
return finalHeight
|
return finalHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -935,10 +973,14 @@ private suspend fun splitParagraphBlock(
|
||||||
|
|
||||||
val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
|
val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
|
||||||
|
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
|
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
|
||||||
|
}
|
||||||
|
|
||||||
if (availableTextHeight <= 0) {
|
if (availableTextHeight <= 0) {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0")
|
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0")
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -969,7 +1011,9 @@ private suspend fun splitParagraphBlock(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastVisibleLine == 0) {
|
if (lastVisibleLine == 0) {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
|
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -985,7 +1029,9 @@ private suspend fun splitParagraphBlock(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (part2Layout.lineCount == 1) {
|
if (part2Layout.lineCount == 1) {
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
|
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
|
||||||
|
}
|
||||||
lastVisibleLine--
|
lastVisibleLine--
|
||||||
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
|
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
|
||||||
}
|
}
|
||||||
|
|
@ -1046,7 +1092,9 @@ private suspend fun splitParagraphBlock(
|
||||||
endCharOffsetInSource = block.endCharOffsetInSource
|
endCharOffsetInSource = block.endCharOffsetInSource
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
|
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
|
||||||
|
}
|
||||||
|
|
||||||
return part1 to part2
|
return part1 to part2
|
||||||
}
|
}
|
||||||
|
|
@ -1090,8 +1138,10 @@ private suspend fun calculateContentHeightWithMargins(
|
||||||
}
|
}
|
||||||
}.roundToInt()
|
}.roundToInt()
|
||||||
totalHeight += (childHeight + margin)
|
totalHeight += (childHeight + margin)
|
||||||
|
if (DEBUG_PAGINATION_LOGS) {
|
||||||
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
|
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (children.isNotEmpty()) {
|
if (children.isNotEmpty()) {
|
||||||
totalHeight += with(density) { children.last().style.margin.bottom.toPx().roundToInt() }
|
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.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class BookCacheDao {
|
abstract class BookCacheDao {
|
||||||
|
|
@ -151,12 +153,19 @@ abstract class BookCacheDao {
|
||||||
@Query("DELETE FROM configuration_cache WHERE bookId = :bookId")
|
@Query("DELETE FROM configuration_cache WHERE bookId = :bookId")
|
||||||
abstract suspend fun deleteConfigurationCacheForBook(bookId: String)
|
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
|
@Transaction
|
||||||
open suspend fun deleteEntireBookCache(bookId: String) {
|
open suspend fun deleteEntireBookCache(bookId: String) {
|
||||||
deleteBook(bookId)
|
deleteBook(bookId)
|
||||||
deleteChaptersForBook(bookId)
|
deleteChaptersForBook(bookId)
|
||||||
deleteAnchorsForBook(bookId)
|
deleteAnchorsForBook(bookId)
|
||||||
deleteConfigurationCacheForBook(bookId)
|
deleteConfigurationCacheForBook(bookId)
|
||||||
|
deletePageCacheMetadataForBook(bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query("DELETE FROM anchor_index")
|
@Query("DELETE FROM anchor_index")
|
||||||
|
|
@ -165,12 +174,16 @@ abstract class BookCacheDao {
|
||||||
@Query("DELETE FROM configuration_cache")
|
@Query("DELETE FROM configuration_cache")
|
||||||
abstract suspend fun clearConfigurationCache()
|
abstract suspend fun clearConfigurationCache()
|
||||||
|
|
||||||
|
@Query("DELETE FROM page_cache_metadata")
|
||||||
|
protected abstract suspend fun clearPageCacheMetadata()
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun clearAllCache() {
|
open suspend fun clearAllCache() {
|
||||||
clearProcessedBooks()
|
clearProcessedBooks()
|
||||||
clearProcessedChapters()
|
clearProcessedChapters()
|
||||||
clearAnchors()
|
clearAnchors()
|
||||||
clearConfigurationCache()
|
clearConfigurationCache()
|
||||||
|
clearPageCacheMetadata()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
|
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
|
||||||
|
|
@ -188,6 +201,101 @@ abstract class BookCacheDao {
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
abstract suspend fun cleanupOldConfigurations(bookId: String)
|
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(
|
@Database(
|
||||||
|
|
@ -196,9 +304,12 @@ abstract class BookCacheDao {
|
||||||
ProcessedChapterMetadata::class,
|
ProcessedChapterMetadata::class,
|
||||||
ProcessedChapterChunk::class,
|
ProcessedChapterChunk::class,
|
||||||
ConfigurationCache::class,
|
ConfigurationCache::class,
|
||||||
AnchorIndexEntry::class
|
AnchorIndexEntry::class,
|
||||||
|
PageCacheMetadata::class,
|
||||||
|
PageCacheChunk::class,
|
||||||
|
PageIndexEntry::class
|
||||||
],
|
],
|
||||||
version = 10,
|
version = 11,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class BookCacheDatabase : RoomDatabase() {
|
abstract class BookCacheDatabase : RoomDatabase() {
|
||||||
|
|
@ -215,11 +326,73 @@ abstract class BookCacheDatabase : RoomDatabase() {
|
||||||
BookCacheDatabase::class.java,
|
BookCacheDatabase::class.java,
|
||||||
"book_cache_database"
|
"book_cache_database"
|
||||||
)
|
)
|
||||||
|
.addMigrations(MIGRATION_10_11)
|
||||||
.fallbackToDestructiveMigration(true)
|
.fallbackToDestructiveMigration(true)
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = 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.Index
|
||||||
import androidx.room.PrimaryKey
|
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")
|
@Entity(tableName = "processed_books")
|
||||||
data class ProcessedBook(
|
data class ProcessedBook(
|
||||||
|
|
@ -131,3 +132,121 @@ data class ConfigurationCache(
|
||||||
val configHash: Int,
|
val configHash: Int,
|
||||||
val chapterPageCounts: String
|
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(
|
data class SerializableEpubChapter(
|
||||||
@ProtoNumber(1) val htmlContent: String,
|
@ProtoNumber(1) val htmlContent: String,
|
||||||
@ProtoNumber(2) val title: 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)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|
@ -208,7 +209,8 @@ class BookProcessingWorker(
|
||||||
baseFontSizeSp = textStyle.fontSize.value,
|
baseFontSizeSp = textStyle.fontSize.value,
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraints = constraints,
|
constraints = constraints,
|
||||||
isDarkTheme = false // GUARANTEED LIGHT THEME
|
isDarkTheme = false,
|
||||||
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules)
|
lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules)
|
||||||
|
|
||||||
|
|
@ -219,7 +221,8 @@ class BookProcessingWorker(
|
||||||
baseFontSizeSp = textStyle.fontSize.value,
|
baseFontSizeSp = textStyle.fontSize.value,
|
||||||
density = density.density,
|
density = density.density,
|
||||||
constraints = constraints,
|
constraints = constraints,
|
||||||
isDarkTheme = false // GUARANTEED LIGHT THEME
|
isDarkTheme = false,
|
||||||
|
adaptThemeColors = false
|
||||||
)
|
)
|
||||||
lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules)
|
lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules)
|
||||||
}
|
}
|
||||||
|
|
@ -242,7 +245,20 @@ class BookProcessingWorker(
|
||||||
Timber.d("Async task started for chapter index $index.")
|
Timber.d("Async task started for chapter index $index.")
|
||||||
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
|
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
|
||||||
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
|
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 mathElements = document.select("math")
|
||||||
val svgResults = mutableMapOf<String, String>()
|
val svgResults = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
|
@ -294,7 +310,7 @@ class BookProcessingWorker(
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
chapterIndex = index,
|
chapterIndex = index,
|
||||||
contentBlocksProto = protoBytes,
|
contentBlocksProto = protoBytes,
|
||||||
estimatedPageCount = 0
|
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Timber.d("Chapter $index was already in the database. Skipping.")
|
Timber.d("Chapter $index was already in the database. Skipping.")
|
||||||
|
|
@ -371,4 +387,28 @@ class BookProcessingWorker(
|
||||||
blocks.forEach { walk(it) }
|
blocks.forEach { walk(it) }
|
||||||
return anchors
|
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 getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
|
||||||
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
|
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
|
||||||
@JvmStatic external fun checkActionSupport(): Boolean
|
@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_TEXT = PdfiumAnnotationSubtype.TEXT
|
||||||
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK
|
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -549,6 +549,7 @@ internal fun PdfPageComposable(
|
||||||
onNoteRequested: (String?) -> Unit = {},
|
onNoteRequested: (String?) -> Unit = {},
|
||||||
onTts: (Int, Int) -> Unit = { _, _ -> },
|
onTts: (Int, Int) -> Unit = { _, _ -> },
|
||||||
activeToolThickness: Float = 0f,
|
activeToolThickness: Float = 0f,
|
||||||
|
eraserToolThickness: Float = 0f,
|
||||||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||||
onPaletteClick: (() -> Unit)? = null,
|
onPaletteClick: (() -> Unit)? = null,
|
||||||
lockedState: Triple<Float, Float, Float>? = null,
|
lockedState: Triple<Float, Float, Float>? = null,
|
||||||
|
|
@ -4270,6 +4271,7 @@ internal fun PdfPageComposable(
|
||||||
eraserPosition = eraserPosition,
|
eraserPosition = eraserPosition,
|
||||||
isStylusEraserOverride = isStylusEraserOverride,
|
isStylusEraserOverride = isStylusEraserOverride,
|
||||||
activeToolThickness = activeToolThickness,
|
activeToolThickness = activeToolThickness,
|
||||||
|
eraserToolThickness = eraserToolThickness,
|
||||||
richTextController = richTextController,
|
richTextController = richTextController,
|
||||||
textBoxes = textBoxes,
|
textBoxes = textBoxes,
|
||||||
selectedTextBoxId = selectedTextBoxId,
|
selectedTextBoxId = selectedTextBoxId,
|
||||||
|
|
@ -5106,6 +5108,7 @@ private fun PdfPageRenderer(
|
||||||
onHighlightDelete: (String) -> Unit,
|
onHighlightDelete: (String) -> Unit,
|
||||||
onTts: (Int, Int) -> Unit,
|
onTts: (Int, Int) -> Unit,
|
||||||
activeToolThickness: Float,
|
activeToolThickness: Float,
|
||||||
|
eraserToolThickness: Float,
|
||||||
onNote: (String?) -> Unit,
|
onNote: (String?) -> Unit,
|
||||||
isBubbleZoomModeActive: Boolean = false,
|
isBubbleZoomModeActive: Boolean = false,
|
||||||
isActivePage: Boolean = true,
|
isActivePage: Boolean = true,
|
||||||
|
|
@ -5173,7 +5176,7 @@ private fun PdfPageRenderer(
|
||||||
val isEditable = isEditMode && selectedTool == InkType.TEXT
|
val isEditable = isEditMode && selectedTool == InkType.TEXT
|
||||||
val hasContent = richTextController.pageLayouts.any {
|
val hasContent = richTextController.pageLayouts.any {
|
||||||
it.pageIndex == selectionData.pageIndex
|
it.pageIndex == selectionData.pageIndex
|
||||||
}
|
} || richTextController.hasRenderableText
|
||||||
|
|
||||||
if (isEditable || hasContent) {
|
if (isEditable || hasContent) {
|
||||||
PdfRichTextLayer(
|
PdfRichTextLayer(
|
||||||
|
|
@ -5323,8 +5326,13 @@ private fun PdfPageRenderer(
|
||||||
|
|
||||||
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
|
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
|
val eraserStrokeWidth = resolveEraserStrokeWidth(
|
||||||
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
|
isStylusEraserOverride,
|
||||||
|
activeToolThickness,
|
||||||
|
eraserToolThickness
|
||||||
|
)
|
||||||
|
val radiusPx = if (eraserStrokeWidth > 0f && staticData.targetWidth > 0) {
|
||||||
|
eraserStrokeWidth * staticData.targetWidth * scale
|
||||||
} else {
|
} else {
|
||||||
8.dp.toPx()
|
8.dp.toPx()
|
||||||
}
|
}
|
||||||
|
|
@ -5798,7 +5806,7 @@ fun PdfRichTextLayer(
|
||||||
val textToRender = if (controller.activePageIndex == pageIndex) {
|
val textToRender = if (controller.activePageIndex == pageIndex) {
|
||||||
controller.localTextFieldValue.annotatedString
|
controller.localTextFieldValue.annotatedString
|
||||||
} else {
|
} else {
|
||||||
pageLayout?.visibleText
|
pageLayout?.visibleText?.withoutTrailingPdfPageBreakForRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textToRender != null) {
|
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 {
|
private fun getNativePointer(obj: Any): Long {
|
||||||
val priorityFields = listOf("pagePtr", "mNativePage", "page")
|
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 PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
|
||||||
private const val PDF_THEME_KEY = "pdf_reader_theme"
|
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_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
|
||||||
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
||||||
private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
|
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
|
||||||
private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
internal 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_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||||
|
|
||||||
enum class PdfReaderTool(val title: String, val category: String) {
|
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"),
|
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
|
||||||
AUTO_SCROLL("Auto Scroll", "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"),
|
||||||
BOOKMARK("Bookmark", "Overflow Menu"),
|
BOOKMARK("Bookmark", "Overflow Menu"),
|
||||||
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
|
||||||
REFLOW("Text View (Reflow)", "Overflow Menu"),
|
REFLOW("Text View (Reflow)", "Overflow Menu"),
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ internal fun PdfTopBar(
|
||||||
onToggleKeepScreenOn: () -> Unit,
|
onToggleKeepScreenOn: () -> Unit,
|
||||||
onStartAutoScroll: () -> Unit,
|
onStartAutoScroll: () -> Unit,
|
||||||
onShowTtsSettings: () -> Unit,
|
onShowTtsSettings: () -> Unit,
|
||||||
|
onShowTtsReplacements: () -> Unit,
|
||||||
onToggleBookmark: () -> Unit,
|
onToggleBookmark: () -> Unit,
|
||||||
onInsertPage: () -> Unit,
|
onInsertPage: () -> Unit,
|
||||||
onDeletePage: () -> Unit,
|
onDeletePage: () -> Unit,
|
||||||
|
|
@ -425,6 +426,15 @@ internal fun PdfTopBar(
|
||||||
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
onClick = { showMoreMenu = false; onShowTtsSettings() },
|
||||||
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
|
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)) {
|
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ internal fun PdfVerticalReader(
|
||||||
onNoteRequested: (String?) -> Unit = {},
|
onNoteRequested: (String?) -> Unit = {},
|
||||||
onTts: (Int, Int) -> Unit = { _, _ -> },
|
onTts: (Int, Int) -> Unit = { _, _ -> },
|
||||||
activeToolThickness: Float = 0f,
|
activeToolThickness: Float = 0f,
|
||||||
|
eraserToolThickness: Float = 0f,
|
||||||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||||
onPaletteClick: () -> Unit = {},
|
onPaletteClick: () -> Unit = {},
|
||||||
lockedState: Triple<Float, Float, Float>? = null,
|
lockedState: Triple<Float, Float, Float>? = null,
|
||||||
|
|
@ -1744,6 +1745,7 @@ internal fun PdfVerticalReader(
|
||||||
onNoteRequested = onNoteRequested,
|
onNoteRequested = onNoteRequested,
|
||||||
onTts = onTts,
|
onTts = onTts,
|
||||||
activeToolThickness = activeToolThickness,
|
activeToolThickness = activeToolThickness,
|
||||||
|
eraserToolThickness = eraserToolThickness,
|
||||||
customHighlightColors = customHighlightColors,
|
customHighlightColors = customHighlightColors,
|
||||||
onPaletteClick = onPaletteClick,
|
onPaletteClick = onPaletteClick,
|
||||||
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
|
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
|
||||||
|
|
@ -2151,8 +2153,13 @@ internal fun PdfVerticalReader(
|
||||||
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
|
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val pos = globalEraserPosition!!
|
val pos = globalEraserPosition!!
|
||||||
val radiusPx = if (activeToolThickness > 0f) {
|
val eraserStrokeWidth = resolveEraserStrokeWidth(
|
||||||
activeToolThickness * screenWidth * zoomAnimatable.value
|
isStylusEraserOverride,
|
||||||
|
activeToolThickness,
|
||||||
|
eraserToolThickness
|
||||||
|
)
|
||||||
|
val radiusPx = if (eraserStrokeWidth > 0f) {
|
||||||
|
eraserStrokeWidth * screenWidth * zoomAnimatable.value
|
||||||
} else {
|
} else {
|
||||||
8.dp.toPx()
|
8.dp.toPx()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,7 @@ import com.aryan.reader.SearchResult
|
||||||
import com.aryan.reader.SummarizationResult
|
import com.aryan.reader.SummarizationResult
|
||||||
import com.aryan.reader.SummaryCacheManager
|
import com.aryan.reader.SummaryCacheManager
|
||||||
import com.aryan.reader.TtsSettingsSheet
|
import com.aryan.reader.TtsSettingsSheet
|
||||||
|
import com.aryan.reader.TtsWordReplacementsSheet
|
||||||
import com.aryan.reader.ml.SpeechBubble
|
import com.aryan.reader.ml.SpeechBubble
|
||||||
import com.aryan.reader.epubreader.AutoScrollControls
|
import com.aryan.reader.epubreader.AutoScrollControls
|
||||||
import com.aryan.reader.epubreader.DictionarySettingsDialog
|
import com.aryan.reader.epubreader.DictionarySettingsDialog
|
||||||
|
|
@ -224,6 +225,7 @@ import com.aryan.reader.callByokGeminiInlineAi
|
||||||
import com.aryan.reader.isByokCloudTtsAvailable
|
import com.aryan.reader.isByokCloudTtsAvailable
|
||||||
import com.aryan.reader.loadCustomThemes
|
import com.aryan.reader.loadCustomThemes
|
||||||
import com.aryan.reader.loadGlobalTextureTransparency
|
import com.aryan.reader.loadGlobalTextureTransparency
|
||||||
|
import com.aryan.reader.loadTtsReplacementPreferences
|
||||||
import com.aryan.reader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotation
|
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.rememberSearchState
|
||||||
import com.aryan.reader.saveCustomThemes
|
import com.aryan.reader.saveCustomThemes
|
||||||
import com.aryan.reader.saveGlobalTextureTransparency
|
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.summarizationUrl
|
||||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
import com.aryan.reader.tts.rememberTtsController
|
import com.aryan.reader.tts.rememberTtsController
|
||||||
import com.aryan.reader.tts.splitTextIntoChunks
|
import com.aryan.reader.tts.splitTextIntoChunks
|
||||||
|
import com.aryan.reader.withTtsReplacements
|
||||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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.isBackPressed
|
||||||
import androidx.compose.ui.input.pointer.isForwardPressed
|
import androidx.compose.ui.input.pointer.isForwardPressed
|
||||||
|
|
||||||
|
internal fun resolveEraserStrokeWidth(
|
||||||
|
isEraserOverride: Boolean,
|
||||||
|
activeToolThickness: Float,
|
||||||
|
eraserToolThickness: Float
|
||||||
|
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions")
|
@Suppress("KotlinConstantConditions")
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
|
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
|
||||||
@ExperimentalMaterial3Api
|
@ExperimentalMaterial3Api
|
||||||
|
|
@ -441,6 +452,12 @@ fun PdfViewerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
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) {
|
DisposableEffect(isKeepScreenOn) {
|
||||||
view.keepScreenOn = isKeepScreenOn
|
view.keepScreenOn = isKeepScreenOn
|
||||||
|
|
@ -805,6 +822,7 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
val activeToolColor = toolSettings.getToolColor(selectedTool)
|
val activeToolColor = toolSettings.getToolColor(selectedTool)
|
||||||
val activeToolThickness = toolSettings.getToolThickness(selectedTool)
|
val activeToolThickness = toolSettings.getToolThickness(selectedTool)
|
||||||
|
val eraserToolThickness = toolSettings.getToolThickness(InkType.ERASER)
|
||||||
|
|
||||||
val fountainPenColor = toolSettings.getToolColor(InkType.FOUNTAIN_PEN)
|
val fountainPenColor = toolSettings.getToolColor(InkType.FOUNTAIN_PEN)
|
||||||
val markerColor = toolSettings.getToolColor(InkType.PEN)
|
val markerColor = toolSettings.getToolColor(InkType.PEN)
|
||||||
|
|
@ -824,6 +842,7 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
val currentStrokeColor by remember(activeToolColor) { derivedStateOf { activeToolColor } }
|
val currentStrokeColor by remember(activeToolColor) { derivedStateOf { activeToolColor } }
|
||||||
val currentStrokeWidth by remember(activeToolThickness) { derivedStateOf { activeToolThickness } }
|
val currentStrokeWidth by remember(activeToolThickness) { derivedStateOf { activeToolThickness } }
|
||||||
|
val currentEraserStrokeWidth by remember(eraserToolThickness) { derivedStateOf { eraserToolThickness } }
|
||||||
|
|
||||||
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
|
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
|
||||||
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
|
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
|
||||||
|
|
@ -1342,6 +1361,30 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
|
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) {
|
val onHighlightAdd = remember(pdfDocument, currentBookId) {
|
||||||
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
|
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
|
||||||
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...")
|
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) }
|
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
|
||||||
|
|
||||||
val saveLauncher = rememberLauncherForActivityResult(
|
val saveLauncher = rememberLauncherForActivityResult(
|
||||||
|
|
@ -2076,7 +2138,7 @@ fun PdfViewerScreen(
|
||||||
viewModel.saveOriginalPdf(effectivePdfUri, uri)
|
viewModel.saveOriginalPdf(effectivePdfUri, uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingSaveMode = null
|
pendingSaveMode = null
|
||||||
|
|
@ -2618,7 +2680,7 @@ fun PdfViewerScreen(
|
||||||
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
|
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
|
||||||
|
|
||||||
ttsController.start(
|
ttsController.start(
|
||||||
chunks = ttsChunks,
|
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
|
||||||
bookTitle = bookTitle,
|
bookTitle = bookTitle,
|
||||||
chapterTitle = pageTitle,
|
chapterTitle = pageTitle,
|
||||||
coverImageUri = null,
|
coverImageUri = null,
|
||||||
|
|
@ -3434,6 +3496,7 @@ fun PdfViewerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
showTtsSettingsSheet -> showTtsSettingsSheet = false
|
showTtsSettingsSheet -> showTtsSettingsSheet = false
|
||||||
|
showTtsReplacementsSheet -> showTtsReplacementsSheet = false
|
||||||
showThemePanel -> showThemePanel = false
|
showThemePanel -> showThemePanel = false
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|
@ -3712,6 +3775,9 @@ fun PdfViewerScreen(
|
||||||
val currentStrokeWidthState by rememberUpdatedState(
|
val currentStrokeWidthState by rememberUpdatedState(
|
||||||
currentStrokeWidth
|
currentStrokeWidth
|
||||||
)
|
)
|
||||||
|
val currentEraserStrokeWidthState by rememberUpdatedState(
|
||||||
|
currentEraserStrokeWidth
|
||||||
|
)
|
||||||
|
|
||||||
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
|
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
|
||||||
remember(pageIndex) {
|
remember(pageIndex) {
|
||||||
|
|
@ -3719,10 +3785,15 @@ fun PdfViewerScreen(
|
||||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||||
if (effectiveTool == InkType.TEXT) {
|
if (effectiveTool == InkType.TEXT) {
|
||||||
} else if (effectiveTool == InkType.ERASER) {
|
} else if (effectiveTool == InkType.ERASER) {
|
||||||
|
val eraserStrokeWidth = resolveEraserStrokeWidth(
|
||||||
|
isEraserOverride,
|
||||||
|
currentStrokeWidthState,
|
||||||
|
currentEraserStrokeWidthState
|
||||||
|
)
|
||||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||||
val toRemove = existing.filter {
|
val toRemove = existing.filter {
|
||||||
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
|
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
|
||||||
}
|
}
|
||||||
lastEraserPoint = point
|
lastEraserPoint = point
|
||||||
if (toRemove.isNotEmpty()) {
|
if (toRemove.isNotEmpty()) {
|
||||||
|
|
@ -3762,10 +3833,15 @@ fun PdfViewerScreen(
|
||||||
} else if (effectiveTool == InkType.ERASER) {
|
} else if (effectiveTool == InkType.ERASER) {
|
||||||
lastEraserPoint = point
|
lastEraserPoint = point
|
||||||
erasedAnnotationsFromStroke.clear()
|
erasedAnnotationsFromStroke.clear()
|
||||||
|
val eraserStrokeWidth = resolveEraserStrokeWidth(
|
||||||
|
isEraserOverride,
|
||||||
|
currentStrokeWidthState,
|
||||||
|
currentEraserStrokeWidthState
|
||||||
|
)
|
||||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||||
val toRemove = existing.filter {
|
val toRemove = existing.filter {
|
||||||
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
|
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
|
||||||
}
|
}
|
||||||
if (toRemove.isNotEmpty()) {
|
if (toRemove.isNotEmpty()) {
|
||||||
val batch =
|
val batch =
|
||||||
|
|
@ -3892,6 +3968,7 @@ fun PdfViewerScreen(
|
||||||
onNoteRequested = onNoteRequested,
|
onNoteRequested = onNoteRequested,
|
||||||
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
||||||
activeToolThickness = currentStrokeWidthState,
|
activeToolThickness = currentStrokeWidthState,
|
||||||
|
eraserToolThickness = currentEraserStrokeWidthState,
|
||||||
lockedState = lockedState,
|
lockedState = lockedState,
|
||||||
onZoomAndPanChanged = { newScale, newOffset ->
|
onZoomAndPanChanged = { newScale, newOffset ->
|
||||||
if (pagerState.currentPage == pageIndex) {
|
if (pagerState.currentPage == pageIndex) {
|
||||||
|
|
@ -4152,6 +4229,9 @@ fun PdfViewerScreen(
|
||||||
val currentStrokeWidthState by rememberUpdatedState(
|
val currentStrokeWidthState by rememberUpdatedState(
|
||||||
currentStrokeWidth
|
currentStrokeWidth
|
||||||
)
|
)
|
||||||
|
val currentEraserStrokeWidthState by rememberUpdatedState(
|
||||||
|
currentEraserStrokeWidth
|
||||||
|
)
|
||||||
|
|
||||||
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
|
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -4164,11 +4244,16 @@ fun PdfViewerScreen(
|
||||||
} else if (effectiveTool == InkType.ERASER) {
|
} else if (effectiveTool == InkType.ERASER) {
|
||||||
lastEraserPoint = point
|
lastEraserPoint = point
|
||||||
erasedAnnotationsFromStroke.clear()
|
erasedAnnotationsFromStroke.clear()
|
||||||
|
val eraserStrokeWidth = resolveEraserStrokeWidth(
|
||||||
|
isEraserOverride,
|
||||||
|
currentStrokeWidthState,
|
||||||
|
currentEraserStrokeWidthState
|
||||||
|
)
|
||||||
|
|
||||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||||
val toRemove = existing.filter {
|
val toRemove = existing.filter {
|
||||||
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
|
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
|
||||||
}
|
}
|
||||||
if (toRemove.isNotEmpty()) {
|
if (toRemove.isNotEmpty()) {
|
||||||
val batch =
|
val batch =
|
||||||
|
|
@ -4204,10 +4289,15 @@ fun PdfViewerScreen(
|
||||||
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
|
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
|
||||||
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
|
||||||
if (effectiveTool == InkType.ERASER) {
|
if (effectiveTool == InkType.ERASER) {
|
||||||
|
val eraserStrokeWidth = resolveEraserStrokeWidth(
|
||||||
|
isEraserOverride,
|
||||||
|
currentStrokeWidthState,
|
||||||
|
currentEraserStrokeWidthState
|
||||||
|
)
|
||||||
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
|
||||||
val existing = allAnnotations[pageIndex] ?: emptyList()
|
val existing = allAnnotations[pageIndex] ?: emptyList()
|
||||||
val toRemove = existing.filter {
|
val toRemove = existing.filter {
|
||||||
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
|
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
|
||||||
}
|
}
|
||||||
lastEraserPoint = point
|
lastEraserPoint = point
|
||||||
if (toRemove.isNotEmpty()) {
|
if (toRemove.isNotEmpty()) {
|
||||||
|
|
@ -4281,6 +4371,7 @@ fun PdfViewerScreen(
|
||||||
onNoteRequested = onNoteRequested,
|
onNoteRequested = onNoteRequested,
|
||||||
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
||||||
activeToolThickness = currentStrokeWidthState,
|
activeToolThickness = currentStrokeWidthState,
|
||||||
|
eraserToolThickness = currentEraserStrokeWidthState,
|
||||||
onLinkClicked = onLinkClickedStable,
|
onLinkClicked = onLinkClickedStable,
|
||||||
onInternalLinkClicked = onInternalLinkNavStable,
|
onInternalLinkClicked = onInternalLinkNavStable,
|
||||||
bookmarks = bookmarksHolder,
|
bookmarks = bookmarksHolder,
|
||||||
|
|
@ -5019,6 +5110,7 @@ fun PdfViewerScreen(
|
||||||
showBars = !isMusicianMode
|
showBars = !isMusicianMode
|
||||||
},
|
},
|
||||||
onShowTtsSettings = { showTtsSettingsSheet = true },
|
onShowTtsSettings = { showTtsSettingsSheet = true },
|
||||||
|
onShowTtsReplacements = { showTtsReplacementsSheet = true },
|
||||||
onToggleBookmark = onBookmarkClick,
|
onToggleBookmark = onBookmarkClick,
|
||||||
onInsertPage = onInsertPage,
|
onInsertPage = onInsertPage,
|
||||||
onDeletePage = onDeletePage,
|
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) {
|
if (showDictionarySettingsSheet) {
|
||||||
DictionarySettingsDialog(
|
DictionarySettingsDialog(
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
|
|
@ -6684,6 +6785,7 @@ fun PdfViewerScreen(
|
||||||
title = { Text(stringResource(R.string.title_save_to_device)) },
|
title = { Text(stringResource(R.string.title_save_to_device)) },
|
||||||
text = { Text(stringResource(R.string.desc_choose_format_save)) },
|
text = { Text(stringResource(R.string.desc_choose_format_save)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showSaveDialog = false
|
showSaveDialog = false
|
||||||
|
|
@ -6693,6 +6795,8 @@ fun PdfViewerScreen(
|
||||||
)
|
)
|
||||||
saveLauncher.launch(suggestedName)
|
saveLauncher.launch(suggestedName)
|
||||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||||
|
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Row {
|
Row {
|
||||||
|
|
@ -6723,6 +6827,7 @@ fun PdfViewerScreen(
|
||||||
title = { Text(stringResource(R.string.share_chooser_title)) },
|
title = { Text(stringResource(R.string.share_chooser_title)) },
|
||||||
text = { Text(stringResource(R.string.desc_choose_format_share)) },
|
text = { Text(stringResource(R.string.desc_choose_format_share)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showShareDialog = false
|
showShareDialog = false
|
||||||
|
|
@ -6748,6 +6853,8 @@ fun PdfViewerScreen(
|
||||||
isShareLoading = false
|
isShareLoading = false
|
||||||
}
|
}
|
||||||
}) { Text(stringResource(R.string.action_with_annotations)) }
|
}) { Text(stringResource(R.string.action_with_annotations)) }
|
||||||
|
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Row {
|
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.platform.SoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
|
@ -56,12 +57,16 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
const val PAGE_BREAK_CHAR = '\u000C'
|
const val PAGE_BREAK_CHAR = '\u000C'
|
||||||
private const val ZWSP = "\u200B"
|
private const val ZWSP = "\u200B"
|
||||||
|
|
||||||
|
internal fun String.hasRenderableRichText(): Boolean =
|
||||||
|
any { it != PAGE_BREAK_CHAR && !it.isWhitespace() }
|
||||||
|
|
||||||
object PdfFontCache {
|
object PdfFontCache {
|
||||||
private val cache = ConcurrentHashMap<String, FontFamily>()
|
private val cache = ConcurrentHashMap<String, FontFamily>()
|
||||||
private var assetManager: android.content.res.AssetManager? = null
|
private var assetManager: android.content.res.AssetManager? = null
|
||||||
|
|
@ -262,126 +267,200 @@ class TextPaginationEngine {
|
||||||
dirtyGlobalIndex: Int = 0
|
dirtyGlobalIndex: Int = 0
|
||||||
): List<PageTextLayout> {
|
): List<PageTextLayout> {
|
||||||
val totalLen = globalText.length
|
val totalLen = globalText.length
|
||||||
if (totalLen == 0) return listOf(
|
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||||
|
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
|
||||||
|
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
|
||||||
|
)
|
||||||
|
if (totalLen == 0) {
|
||||||
|
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
|
||||||
|
return listOf(
|
||||||
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
||||||
)
|
)
|
||||||
if (pageWidthPx <= 0 || pageHeightPx <= 0) return emptyList()
|
|
||||||
|
|
||||||
val validPages = if (dirtyGlobalIndex > 0 && previousLayouts.isNotEmpty()) {
|
|
||||||
previousLayouts.takeWhile { it.globalEndIndex < dirtyGlobalIndex }
|
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}
|
}
|
||||||
|
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
|
||||||
val startPageIndex = validPages.size
|
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
|
||||||
val measurementStartIndex = validPages.lastOrNull()?.globalEndIndex ?: 0
|
return emptyList()
|
||||||
|
}
|
||||||
if (measurementStartIndex >= totalLen) return validPages
|
|
||||||
|
|
||||||
val textToMeasure = globalText.subSequence(measurementStartIndex, totalLen)
|
|
||||||
val fullString = textToMeasure.text
|
|
||||||
|
|
||||||
val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f)
|
val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f)
|
||||||
val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f)
|
val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f)
|
||||||
|
|
||||||
val measureResult = textMeasurer.measure(
|
val newPages = mutableListOf<PageTextLayout>()
|
||||||
text = textToMeasure,
|
var currentPageIndex = 0
|
||||||
style = TextStyle(fontSize = 16.sp, color = Color.Black),
|
var segmentStart = 0
|
||||||
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
|
val rawText = globalText.text
|
||||||
|
|
||||||
|
while (segmentStart < totalLen) {
|
||||||
|
val breakIndex = rawText.indexOf(PAGE_BREAK_CHAR, startIndex = segmentStart)
|
||||||
|
val hasExplicitBreak = breakIndex != -1
|
||||||
|
val contentEnd = if (hasExplicitBreak) breakIndex else totalLen
|
||||||
|
val segmentEnd = if (hasExplicitBreak) breakIndex + 1 else totalLen
|
||||||
|
|
||||||
|
currentPageIndex = newPages.appendMeasuredAndroidRichTextSegment(
|
||||||
|
globalText = globalText,
|
||||||
|
segmentStart = segmentStart,
|
||||||
|
contentEnd = contentEnd,
|
||||||
|
explicitBreakEnd = if (hasExplicitBreak) segmentEnd else null,
|
||||||
|
pageIndex = currentPageIndex,
|
||||||
|
pageHeightPx = pageHeightPx,
|
||||||
|
editorWidth = editorWidth,
|
||||||
|
editorHeight = editorHeight,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
density = density
|
density = density
|
||||||
)
|
)
|
||||||
|
segmentStart = segmentEnd
|
||||||
val newPages = mutableListOf<PageTextLayout>()
|
|
||||||
var currentPageIndex = startPageIndex
|
|
||||||
var currentPageStartRel = 0
|
|
||||||
var currentPageAccumulatedHeight = 0f
|
|
||||||
|
|
||||||
var currentLineIndex = 0
|
|
||||||
val totalLines = measureResult.lineCount
|
|
||||||
|
|
||||||
Timber.tag("RichTextFlow").d("Pagination: Measuring ${fullString.length} chars from Global $measurementStartIndex. Lines: $totalLines")
|
|
||||||
|
|
||||||
while (currentLineIndex < totalLines) {
|
|
||||||
val lineTop = measureResult.getLineTop(currentLineIndex)
|
|
||||||
val lineBottom = measureResult.getLineBottom(currentLineIndex)
|
|
||||||
val lineHeight = lineBottom - lineTop
|
|
||||||
|
|
||||||
val lineStartRel = measureResult.getLineStart(currentLineIndex)
|
|
||||||
val lineEndRel = measureResult.getLineEnd(currentLineIndex)
|
|
||||||
|
|
||||||
val localStartOffset = (currentPageStartRel - lineStartRel).coerceAtLeast(0)
|
|
||||||
|
|
||||||
if (lineStartRel + localStartOffset >= lineEndRel && currentLineIndex < totalLines - 1) {
|
|
||||||
currentLineIndex++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val safeEndRel = lineEndRel.coerceAtMost(fullString.length)
|
val resultLayouts = newPages.withTrailingAndroidBlankRichTextPageIfNeeded(
|
||||||
val lineContent = fullString.substring(lineStartRel, safeEndRel)
|
globalText = globalText,
|
||||||
|
pageHeightPx = pageHeightPx
|
||||||
val breakIndexInLine = lineContent.indexOf(PAGE_BREAK_CHAR, localStartOffset)
|
)
|
||||||
val hasPageBreak = breakIndexInLine != -1
|
|
||||||
|
|
||||||
val isStartOfPage = (currentPageAccumulatedHeight == 0f)
|
|
||||||
val willOverflow = !isStartOfPage && (currentPageAccumulatedHeight + lineHeight > editorHeight)
|
|
||||||
|
|
||||||
if (hasPageBreak) {
|
|
||||||
val splitRelIndex = lineStartRel + breakIndexInLine + 1
|
|
||||||
val globalStart = measurementStartIndex + currentPageStartRel
|
|
||||||
val globalEnd = measurementStartIndex + splitRelIndex
|
|
||||||
|
|
||||||
Timber.tag("RichTextMigration").v("PaginationEngine: Found PAGE_BREAK_CHAR at relative ${breakIndexInLine}. Breaking Page $currentPageIndex at Global Index $globalEnd")
|
|
||||||
|
|
||||||
if (globalEnd > globalStart) {
|
|
||||||
val visibleText = globalText.subSequence(globalStart, globalEnd)
|
|
||||||
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
|
|
||||||
currentPageIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPageStartRel = splitRelIndex
|
|
||||||
currentPageAccumulatedHeight = 0f
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
else if (willOverflow) {
|
|
||||||
val globalStart = measurementStartIndex + currentPageStartRel
|
|
||||||
val globalEnd = measurementStartIndex + lineStartRel
|
|
||||||
|
|
||||||
if (globalEnd > globalStart) {
|
|
||||||
val visibleText = globalText.subSequence(globalStart, globalEnd)
|
|
||||||
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
|
|
||||||
Timber.tag("RichTextFlow").v("Page $currentPageIndex Created (Overflow): $globalStart -> $globalEnd")
|
|
||||||
currentPageIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPageStartRel = lineStartRel
|
|
||||||
currentPageAccumulatedHeight = 0f
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPageAccumulatedHeight += lineHeight
|
|
||||||
currentLineIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPageStartRel < fullString.length) {
|
|
||||||
val globalStart = measurementStartIndex + currentPageStartRel
|
|
||||||
val globalEnd = measurementStartIndex + fullString.length
|
|
||||||
val visibleText = globalText.subSequence(globalStart, globalEnd)
|
|
||||||
|
|
||||||
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
|
|
||||||
}
|
|
||||||
|
|
||||||
val resultLayouts = validPages + newPages
|
|
||||||
|
|
||||||
val mapLog = resultLayouts.joinToString("\n") {
|
val mapLog = resultLayouts.joinToString("\n") {
|
||||||
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
|
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
|
||||||
}
|
}
|
||||||
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
|
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
|
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) {
|
class PdfRichTextRepository(private val context: Context) {
|
||||||
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
|
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
|
||||||
val document = _document.asStateFlow()
|
val document = _document.asStateFlow()
|
||||||
|
|
@ -396,8 +475,12 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
suspend fun load(bookId: String) {
|
suspend fun load(bookId: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val file = getFile(bookId)
|
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()) {
|
if (!file.exists()) {
|
||||||
_document.value = GlobalRichDocument("", emptyList())
|
_document.value = GlobalRichDocument("", emptyList())
|
||||||
|
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -425,7 +508,11 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_document.value = GlobalRichDocument(text, spans)
|
_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) {
|
} 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")
|
Timber.e(e, "Failed to load rich text doc")
|
||||||
_document.value = GlobalRichDocument("", emptyList())
|
_document.value = GlobalRichDocument("", emptyList())
|
||||||
}
|
}
|
||||||
|
|
@ -436,6 +523,9 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
_document.value = document
|
_document.value = document
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
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 {
|
val obj = JSONObject().apply {
|
||||||
put("text", document.text)
|
put("text", document.text)
|
||||||
val spansArray = JSONArray()
|
val spansArray = JSONArray()
|
||||||
|
|
@ -456,14 +546,35 @@ class PdfRichTextRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
put("spans", spansArray)
|
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) {
|
} 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")
|
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
|
@Stable
|
||||||
class RichTextController(
|
class RichTextController(
|
||||||
private val repository: PdfRichTextRepository,
|
private val repository: PdfRichTextRepository,
|
||||||
|
|
@ -485,6 +596,9 @@ class RichTextController(
|
||||||
var pageLayouts by mutableStateOf(emptyList<PageTextLayout>())
|
var pageLayouts by mutableStateOf(emptyList<PageTextLayout>())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
val hasRenderableText: Boolean
|
||||||
|
get() = globalTextFieldValue.text.hasRenderableRichText()
|
||||||
|
|
||||||
var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp))
|
var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp))
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -720,9 +834,11 @@ class RichTextController(
|
||||||
val currentGlobal = globalTextFieldValue.annotatedString
|
val currentGlobal = globalTextFieldValue.annotatedString
|
||||||
|
|
||||||
// FIX: Strip ZWSP (index 0) from local text
|
// 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)
|
localTextFieldValue.annotatedString.subSequence(1, localTextFieldValue.annotatedString.length)
|
||||||
} else AnnotatedString("")
|
} 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}")
|
Timber.tag("RichTextFlow").d("Sync: Page $activePageIndex, GlobalRange [$globalStart..$globalEnd], LocalLen ${localText.length}")
|
||||||
|
|
||||||
|
|
@ -776,7 +892,7 @@ class RichTextController(
|
||||||
activePageIndex = newActiveLayout.pageIndex
|
activePageIndex = newActiveLayout.pageIndex
|
||||||
val reExtractedText = newGlobalAnnotated.subSequence(
|
val reExtractedText = newGlobalAnnotated.subSequence(
|
||||||
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
|
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
|
||||||
)
|
).withoutTrailingAndroidPageBreak()
|
||||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtractedText
|
val textWithZwsp = AnnotatedString(ZWSP) + reExtractedText
|
||||||
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1)
|
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1)
|
||||||
.coerceIn(0, textWithZwsp.length)
|
.coerceIn(0, textWithZwsp.length)
|
||||||
|
|
@ -865,28 +981,26 @@ class RichTextController(
|
||||||
val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f)
|
val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f)
|
||||||
|
|
||||||
val vText = currentLayout.visibleText
|
val vText = currentLayout.visibleText
|
||||||
|
val editableText = vText.withoutTrailingAndroidPageBreak()
|
||||||
// FIX: Prepend ZWSP to the visible text
|
// FIX: Prepend ZWSP to the visible text
|
||||||
val textWithZwsp = AnnotatedString(ZWSP) + vText
|
val textWithZwsp = AnnotatedString(ZWSP) + editableText
|
||||||
val safeLen = if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR) vText.length - 1 else vText.length
|
val safeLen = editableText.length
|
||||||
|
|
||||||
// FIX: Adjust initial selection by +1 because of ZWSP
|
// FIX: Adjust initial selection by +1 because of ZWSP
|
||||||
localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1))
|
localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1))
|
||||||
|
|
||||||
val measureResult = measurer.measure(
|
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),
|
style = TextStyle(fontSize = 16.sp, color = Color.Black),
|
||||||
constraints = Constraints(maxWidth = editorWidth.toInt()),
|
constraints = Constraints(maxWidth = editorWidth.toInt()),
|
||||||
density = density
|
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)
|
var localIndex = measureResult.getOffsetForPosition(localTapOffset)
|
||||||
|
localIndex = localIndex.coerceIn(0, editableText.length)
|
||||||
if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR && localIndex >= vText.length) {
|
|
||||||
localIndex = vText.length - 1
|
|
||||||
}
|
|
||||||
localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1))
|
localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1))
|
||||||
} else {
|
} else {
|
||||||
val gap = localTapOffset.y - textHeight
|
val gap = localTapOffset.y - textHeight
|
||||||
|
|
@ -1132,7 +1246,9 @@ class RichTextController(
|
||||||
val currentGlobal = globalTextFieldValue.annotatedString
|
val currentGlobal = globalTextFieldValue.annotatedString
|
||||||
|
|
||||||
val localAnnotatedRaw = localTextFieldValue.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 charBeforeSync = if (globalStart > 0) currentGlobal.text[globalStart - 1] else "START"
|
||||||
val charAfterSync = if (globalEnd < currentGlobal.length) currentGlobal.text[globalEnd] else "END"
|
val charAfterSync = if (globalEnd < currentGlobal.length) currentGlobal.text[globalEnd] else "END"
|
||||||
|
|
@ -1197,7 +1313,7 @@ class RichTextController(
|
||||||
|
|
||||||
val reExtracted = newGlobalAnnotated.subSequence(
|
val reExtracted = newGlobalAnnotated.subSequence(
|
||||||
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
|
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
|
||||||
)
|
).withoutTrailingAndroidPageBreak()
|
||||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
||||||
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
||||||
|
|
||||||
|
|
@ -1301,7 +1417,7 @@ class RichTextController(
|
||||||
activePageIndex = finalActiveLayout.pageIndex
|
activePageIndex = finalActiveLayout.pageIndex
|
||||||
val reExtracted = intermediateGlobal.subSequence(
|
val reExtracted = intermediateGlobal.subSequence(
|
||||||
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
|
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
|
||||||
)
|
).withoutTrailingAndroidPageBreak()
|
||||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
||||||
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
||||||
|
|
||||||
|
|
@ -1351,7 +1467,7 @@ class RichTextController(
|
||||||
|
|
||||||
val reExtracted = newGlobalText.subSequence(
|
val reExtracted = newGlobalText.subSequence(
|
||||||
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
|
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
|
||||||
)
|
).withoutTrailingAndroidPageBreak()
|
||||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
||||||
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -545,8 +545,11 @@ class OpdsStreamDocumentWrapper(
|
||||||
|
|
||||||
private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder()
|
private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder()
|
||||||
.apply {
|
.apply {
|
||||||
if (!catalog?.username.isNullOrBlank() && !catalog.password.isNullOrBlank()) {
|
val streamCatalog = catalog
|
||||||
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(catalog.username, catalog.password))
|
val username = streamCatalog?.username
|
||||||
|
val password = streamCatalog?.password
|
||||||
|
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
|
||||||
|
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(username, password))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.build()
|
.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 {
|
try {
|
||||||
val oldUrl = java.net.URL(urlTemplate)
|
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 oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
|
||||||
val newBase = "${newUrl.protocol}://${newUrl.authority}"
|
val newBase = "${newUrl.protocol}://${newUrl.authority}"
|
||||||
urlTemplate.replace(oldBase, newBase)
|
urlTemplate.replace(oldBase, newBase)
|
||||||
|
|
|
||||||
|
|
@ -186,11 +186,13 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
)
|
)
|
||||||
|
|
||||||
val textList = ArrayList(chunks.map { it.text })
|
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 cfiList = ArrayList(chunks.map { it.sourceCfi })
|
||||||
val offsetList = ArrayList(chunks.map { it.startOffsetInSource })
|
val offsetList = ArrayList(chunks.map { it.startOffsetInSource })
|
||||||
|
|
||||||
val args = Bundle().apply {
|
val args = Bundle().apply {
|
||||||
putStringArrayList(KEY_TEXT_CHUNKS, textList)
|
putStringArrayList(KEY_TEXT_CHUNKS, textList)
|
||||||
|
putStringArrayList(KEY_SPOKEN_TEXT_CHUNKS, spokenTextList)
|
||||||
putStringArrayList(KEY_SOURCE_CFIS, cfiList)
|
putStringArrayList(KEY_SOURCE_CFIS, cfiList)
|
||||||
putIntegerArrayList(KEY_START_OFFSETS, offsetList)
|
putIntegerArrayList(KEY_START_OFFSETS, offsetList)
|
||||||
putString(KEY_SPEAKER_ID, _ttsState.value.speakerId)
|
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 TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
|
||||||
|
|
||||||
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
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_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
||||||
const val KEY_START_OFFSETS = "KEY_START_OFFSETS"
|
const val KEY_START_OFFSETS = "KEY_START_OFFSETS"
|
||||||
const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID"
|
const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID"
|
||||||
|
|
@ -205,6 +206,7 @@ class TtsPlaybackManager(
|
||||||
)
|
)
|
||||||
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
|
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
|
||||||
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
|
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 speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
|
||||||
val bookTitle = args.getString(KEY_BOOK_TITLE)
|
val bookTitle = args.getString(KEY_BOOK_TITLE)
|
||||||
val chapterTitle = args.getString(KEY_CHAPTER_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) {
|
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
|
||||||
chunks.mapIndexed { index, text ->
|
chunks.mapIndexed { index, text ->
|
||||||
val safeOffset = offsets.getOrNull(index) ?: -1
|
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 {
|
} 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)
|
val authToken = args.getString(KEY_AUTH_TOKEN)
|
||||||
|
|
@ -337,7 +352,11 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
val slicedText = currentChunk.text.substring(relativeOffset)
|
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()
|
val mutableChunks = textChunks.toMutableList()
|
||||||
mutableChunks[currentIdx] = newChunk
|
mutableChunks[currentIdx] = newChunk
|
||||||
|
|
@ -540,7 +559,8 @@ class TtsPlaybackManager(
|
||||||
"Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
|
"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")
|
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
||||||
|
|
||||||
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
|
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
|
||||||
|
|
@ -572,7 +592,7 @@ class TtsPlaybackManager(
|
||||||
if (id != null) chunkStreamIds[startAtIndex] = id
|
if (id != null) chunkStreamIds[startAtIndex] = id
|
||||||
}
|
}
|
||||||
val pathToUse = streamUri ?: audioFile!!.absolutePath
|
val pathToUse = streamUri ?: audioFile!!.absolutePath
|
||||||
val mediaItem = createMediaItem(serverText, pathToUse, startAtIndex, updatedChunk)
|
val mediaItem = createMediaItem(updatedChunk.text, pathToUse, startAtIndex, updatedChunk)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val prepStartTime = System.currentTimeMillis()
|
val prepStartTime = System.currentTimeMillis()
|
||||||
|
|
@ -589,7 +609,7 @@ class TtsPlaybackManager(
|
||||||
_ttsState.value = _ttsState.value.copy(
|
_ttsState.value = _ttsState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isPlaying = playWhenReady,
|
isPlaying = playWhenReady,
|
||||||
currentText = serverText,
|
currentText = updatedChunk.text,
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
totalChapters = totalChapters,
|
totalChapters = totalChapters,
|
||||||
|
|
@ -618,6 +638,9 @@ class TtsPlaybackManager(
|
||||||
if (wordTimings.isNullOrEmpty()) {
|
if (wordTimings.isNullOrEmpty()) {
|
||||||
return originalChunk
|
return originalChunk
|
||||||
}
|
}
|
||||||
|
if (originalChunk.spokenText != originalChunk.text) {
|
||||||
|
return originalChunk.copy(timedWords = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
val timedWords = mutableListOf<TimedWord>()
|
val timedWords = mutableListOf<TimedWord>()
|
||||||
var currentSearchIndex = 0
|
var currentSearchIndex = 0
|
||||||
|
|
@ -823,7 +846,8 @@ class TtsPlaybackManager(
|
||||||
val prefetchStartTime = System.currentTimeMillis()
|
val prefetchStartTime = System.currentTimeMillis()
|
||||||
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
|
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")
|
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) {
|
if ((audioFile != null || streamUri != null) && serverText != null) {
|
||||||
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
|
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
|
||||||
val pathToUse = streamUri ?: audioFile!!.absolutePath
|
val pathToUse = streamUri ?: audioFile!!.absolutePath
|
||||||
val nextMediaItem = createMediaItem(serverText, pathToUse, targetIndex, updatedChunk)
|
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (audioFile != null) {
|
if (audioFile != null) {
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,8 @@
|
||||||
<string name="menu_auto_scroll">Auto Scroll</string>
|
<string name="menu_auto_scroll">Auto Scroll</string>
|
||||||
<!-- TTS = Text-to-Speech. -->
|
<!-- TTS = Text-to-Speech. -->
|
||||||
<string name="menu_tts_voice_settings">TTS Voice Settings</string>
|
<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. -->
|
<!-- TTS = Text-to-Speech. Debug-only menu item. -->
|
||||||
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
|
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
|
||||||
<string name="content_desc_navigate_slider">Navigate with slider</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
|
package com.aryan.reader
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class FileTypeResolverTest {
|
class FileTypeResolverTest {
|
||||||
|
|
@ -13,6 +15,27 @@ class FileTypeResolverTest {
|
||||||
assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt"))
|
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
|
@Test
|
||||||
fun `plain txt remains txt when inner extension is unsupported`() {
|
fun `plain txt remains txt when inner extension is unsupported`() {
|
||||||
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
|
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.app.Application
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
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 androidx.work.WorkManager
|
||||||
|
import com.android.billingclient.api.BillingClient
|
||||||
|
import com.android.billingclient.api.BillingResult
|
||||||
import com.aryan.reader.data.*
|
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 io.mockk.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -20,6 +33,7 @@ import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class MainViewModelTest {
|
class MainViewModelTest {
|
||||||
|
|
@ -33,9 +47,24 @@ class MainViewModelTest {
|
||||||
|
|
||||||
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
|
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
|
||||||
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
|
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
|
@Before
|
||||||
fun setup() {
|
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)
|
mockkStatic(Log::class)
|
||||||
every { Log.isLoggable(any(), any()) } returns false
|
every { Log.isLoggable(any(), any()) } returns false
|
||||||
every { Log.d(any(), any()) } returns 0
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
|
@ -49,10 +78,18 @@ class MainViewModelTest {
|
||||||
mockPrefs = mockk(relaxed = true)
|
mockPrefs = mockk(relaxed = true)
|
||||||
mockEditor = mockk(relaxed = true)
|
mockEditor = mockk(relaxed = true)
|
||||||
val mockResources = mockk<Resources>(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.applicationContext } returns mockApplication
|
||||||
every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs
|
every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs
|
||||||
every { mockApplication.resources } returns mockResources
|
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.edit() } returns mockEditor
|
||||||
|
|
||||||
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
|
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.getInt(any(), any()) } answers { secondArg() as Int }
|
||||||
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
|
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
|
||||||
|
|
||||||
mockkStatic(AppDatabase::class)
|
mockkObject(AppDatabase.Companion)
|
||||||
val mockDb = mockk<AppDatabase>(relaxed = true)
|
val mockDb = mockk<AppDatabase>(relaxed = true)
|
||||||
every { AppDatabase.getDatabase(any()) } returns mockDb
|
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)
|
mockkObject(WorkManager.Companion)
|
||||||
every { WorkManager.getInstance(any()) } returns mockk(relaxed = true)
|
val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||||
mockkStatic(PDFBoxResourceLoader::class)
|
every { WorkManager.getInstance(any()) } returns mockWorkManager
|
||||||
every { PDFBoxResourceLoader.init(any()) } just Runs
|
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(AuthRepository::class)
|
||||||
mockkConstructor(RecentFilesRepository::class)
|
mockkConstructor(RecentFilesRepository::class)
|
||||||
mockkConstructor(BillingClientWrapper::class)
|
mockkConstructor(BillingClientWrapper::class)
|
||||||
|
|
@ -76,18 +137,28 @@ class MainViewModelTest {
|
||||||
mockkConstructor(FirestoreRepository::class)
|
mockkConstructor(FirestoreRepository::class)
|
||||||
mockkConstructor(FeedbackRepository::class)
|
mockkConstructor(FeedbackRepository::class)
|
||||||
mockkConstructor(FontsRepository::class)
|
mockkConstructor(FontsRepository::class)
|
||||||
|
mockkConstructor(TtsController::class)
|
||||||
|
|
||||||
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
|
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
|
||||||
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
|
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
|
||||||
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
|
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
|
||||||
every { anyConstructed<RecentFilesRepository>().getRecentFilesFlow() } returns flowOf(emptyList())
|
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
|
||||||
every { anyConstructed<RecentFilesRepository>().activeShelvesFlow } returns flowOf(emptyList())
|
every { anyConstructed<TtsController>().ttsState } returns ttsStateFlow
|
||||||
every { anyConstructed<RecentFilesRepository>().shelfCrossRefsFlow } returns flowOf(emptyList())
|
every { anyConstructed<TtsController>().connect() } just Runs
|
||||||
every { anyConstructed<RecentFilesRepository>().tagsFlow } returns flowOf(emptyList())
|
every { anyConstructed<TtsController>().release() } just Runs
|
||||||
every { anyConstructed<RecentFilesRepository>().tagCrossRefsFlow } returns flowOf(emptyList())
|
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>().migrateLegacyShelvesToRoom() } just Runs
|
||||||
coEvery { anyConstructed<RecentFilesRepository>().seedTagsIfEmpty(any()) } 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
|
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
|
||||||
|
|
||||||
|
|
@ -109,8 +180,11 @@ class MainViewModelTest {
|
||||||
viewModel.setSearchActive(true)
|
viewModel.setSearchActive(true)
|
||||||
viewModel.onSearchQueryChange("Moby Dick")
|
viewModel.onSearchQueryChange("Moby Dick")
|
||||||
|
|
||||||
assertEquals("Moby Dick", viewModel.uiState.value.searchQuery)
|
val state = viewModel.uiState.first {
|
||||||
assertTrue(viewModel.uiState.value.isSearchActive)
|
it.searchQuery == "Moby Dick" && it.isSearchActive
|
||||||
|
}
|
||||||
|
assertEquals("Moby Dick", state.searchQuery)
|
||||||
|
assertTrue(state.isSearchActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -127,6 +201,18 @@ class MainViewModelTest {
|
||||||
assertFalse(viewModel.uiState.value.isSearchActive)
|
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
|
@Test
|
||||||
fun `switching theme updates internal state and preferences`() = runTest {
|
fun `switching theme updates internal state and preferences`() = runTest {
|
||||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
|
@ -135,7 +221,8 @@ class MainViewModelTest {
|
||||||
|
|
||||||
viewModel.setAppThemeMode(AppThemeMode.DARK)
|
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) }
|
verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,10 +234,688 @@ class MainViewModelTest {
|
||||||
|
|
||||||
viewModel.setTabsEnabled(true)
|
viewModel.setTabsEnabled(true)
|
||||||
|
|
||||||
assertTrue(viewModel.uiState.value.isTabsEnabled)
|
val state = viewModel.uiState.first { it.isTabsEnabled }
|
||||||
|
assertTrue(state.isTabsEnabled)
|
||||||
verify { mockEditor.putBoolean("tabs_enabled", true) }
|
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
|
@Test
|
||||||
fun `banner message logic works correctly`() = runTest {
|
fun `banner message logic works correctly`() = runTest {
|
||||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
|
@ -159,11 +924,46 @@ class MainViewModelTest {
|
||||||
|
|
||||||
viewModel.showBanner("Test Message", isError = true)
|
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)
|
assertEquals("Test Message", currentBanner?.message)
|
||||||
assertTrue(currentBanner?.isError == true)
|
assertTrue(currentBanner?.isError == true)
|
||||||
|
|
||||||
viewModel.bannerMessageShown()
|
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))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,4 +6,5 @@ plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
alias(libs.plugins.compose.multiplatform) apply false
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
|
alias(libs.plugins.kover) apply false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,14 @@ kotlin {
|
||||||
implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
|
implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
implementation("net.java.dev.jna:jna:5.17.0")
|
implementation("net.java.dev.jna:jna:5.17.0")
|
||||||
|
implementation("org.apache.commons:commons-compress:1.28.0")
|
||||||
|
implementation("org.tukaani:xz:1.10")
|
||||||
|
implementation("com.twelvemonkeys.imageio:imageio-webp:3.13.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val desktopTest by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,527 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID
|
||||||
|
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
|
||||||
|
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||||
|
import com.sun.jna.Memory
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.Structure
|
||||||
|
import com.sun.jna.WString
|
||||||
|
import com.sun.jna.ptr.PointerByReference
|
||||||
|
import com.sun.jna.win32.StdCallLibrary
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
private const val WINDOWS_CRED_TYPE_GENERIC = 1
|
||||||
|
private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2
|
||||||
|
private const val WINDOWS_ERROR_NOT_FOUND = 1168
|
||||||
|
|
||||||
|
internal class DesktopAiByokStore(
|
||||||
|
private val settingsFile: File = defaultSettingsFile(),
|
||||||
|
private val secretCodec: DesktopSecretCodec = DesktopSecretCodec.platform()
|
||||||
|
) {
|
||||||
|
val isSecureStorageAvailable: Boolean
|
||||||
|
get() = secretCodec.isAvailable.also { available ->
|
||||||
|
logDesktopTts("settings_secure_available codec=${secretCodec.name} available=$available")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(): ReaderAiByokSettings {
|
||||||
|
logDesktopTts(
|
||||||
|
"settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
|
||||||
|
"exists=${settingsFile.exists()} secureStorage=${secretCodec.isAvailable}"
|
||||||
|
)
|
||||||
|
if (!settingsFile.exists()) {
|
||||||
|
logDesktopTts("settings_load_empty reason=file_missing")
|
||||||
|
return ReaderAiByokSettings()
|
||||||
|
}
|
||||||
|
val properties = Properties()
|
||||||
|
return runCatching {
|
||||||
|
settingsFile.inputStream().use(properties::load)
|
||||||
|
val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "")
|
||||||
|
val legacyGroqKey = properties.getProperty(LegacyGroqKey, "")
|
||||||
|
val loadedSettings = ReaderAiByokSettings(
|
||||||
|
geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey),
|
||||||
|
groqKey = loadSecret(properties, GroqKey, legacyGroqKey),
|
||||||
|
useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true,
|
||||||
|
modelForAll = properties.getProperty("modelForAll", ""),
|
||||||
|
defineModel = properties.getProperty("defineModel", ""),
|
||||||
|
summarizeModel = properties.getProperty("summarizeModel", ""),
|
||||||
|
recapModel = properties.getProperty("recapModel", ""),
|
||||||
|
ttsModel = properties.getProperty("ttsModel", ""),
|
||||||
|
hideReaderAiFeatures = properties.getProperty("hideReaderAiFeatures", "false").toBooleanStrictOrNull() ?: false,
|
||||||
|
ttsSpeakerId = properties.getProperty("ttsSpeakerId", DEFAULT_CLOUD_TTS_SPEAKER_ID)
|
||||||
|
).sanitized()
|
||||||
|
val settings = if (loadedSettings.geminiKey.isNotBlank() && loadedSettings.ttsModel.isBlank()) {
|
||||||
|
loadedSettings.copy(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID)
|
||||||
|
} else {
|
||||||
|
loadedSettings
|
||||||
|
}
|
||||||
|
if (secretCodec.isAvailable &&
|
||||||
|
(legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings)
|
||||||
|
) {
|
||||||
|
logDesktopTts(
|
||||||
|
"settings_load_migrate legacyGemini=${legacyGeminiKey.isNotBlank()} " +
|
||||||
|
"legacyGroq=${legacyGroqKey.isNotBlank()} autoTtsModel=${settings != loadedSettings}"
|
||||||
|
)
|
||||||
|
runCatching { save(settings) }
|
||||||
|
}
|
||||||
|
logDesktopTts(
|
||||||
|
"settings_load_complete geminiKey=${settings.geminiKey.isNotBlank()} groqKey=${settings.groqKey.isNotBlank()} " +
|
||||||
|
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" cloudAvailable=${settings.isCloudTtsAvailable}"
|
||||||
|
)
|
||||||
|
settings
|
||||||
|
}.getOrElse { error ->
|
||||||
|
logDesktopTts("settings_load_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
ReaderAiByokSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(settings: ReaderAiByokSettings) {
|
||||||
|
val sanitized = settings.sanitized()
|
||||||
|
logDesktopTts(
|
||||||
|
"settings_save_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
|
||||||
|
"secureStorage=${secretCodec.isAvailable} geminiKey=${sanitized.geminiKey.isNotBlank()} " +
|
||||||
|
"groqKey=${sanitized.groqKey.isNotBlank()} ttsModel=\"${sanitized.ttsModel.desktopTtsPreview()}\""
|
||||||
|
)
|
||||||
|
val properties = Properties().apply {
|
||||||
|
setProtectedSecret(GeminiKey, sanitized.geminiKey)
|
||||||
|
setProtectedSecret(GroqKey, sanitized.groqKey)
|
||||||
|
setProperty("useOneModel", sanitized.useOneModel.toString())
|
||||||
|
setProperty("modelForAll", sanitized.modelForAll)
|
||||||
|
setProperty("defineModel", sanitized.defineModel)
|
||||||
|
setProperty("summarizeModel", sanitized.summarizeModel)
|
||||||
|
setProperty("recapModel", sanitized.recapModel)
|
||||||
|
setProperty("ttsModel", sanitized.ttsModel)
|
||||||
|
setProperty("hideReaderAiFeatures", sanitized.hideReaderAiFeatures.toString())
|
||||||
|
setProperty("ttsSpeakerId", sanitized.ttsSpeakerId)
|
||||||
|
}
|
||||||
|
settingsFile.parentFile?.mkdirs()
|
||||||
|
settingsFile.outputStream().use { output ->
|
||||||
|
properties.store(output, "Episteme desktop AI keys and models")
|
||||||
|
}
|
||||||
|
logDesktopTts(
|
||||||
|
"settings_save_complete geminiProtected=${properties.getProperty(GeminiKey, "").isNotBlank()} " +
|
||||||
|
"groqProtected=${properties.getProperty(GroqKey, "").isNotBlank()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSecret(properties: Properties, key: String, legacyPlaintext: String): String {
|
||||||
|
val protectedValue = properties.getProperty(key, "")
|
||||||
|
val decrypted = protectedValue
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.let {
|
||||||
|
runCatching { secretCodec.unprotect(key, it) }
|
||||||
|
.onFailure { error -> logDesktopTts("settings_secret_unprotect_failed key=$key error=\"${error.desktopTtsSummary()}\"") }
|
||||||
|
.getOrDefault("")
|
||||||
|
}
|
||||||
|
.orEmpty()
|
||||||
|
if (decrypted.isNotBlank()) return decrypted
|
||||||
|
return legacyPlaintext.takeIf { secretCodec.isAvailable }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Properties.setProtectedSecret(key: String, value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
secretCodec.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching { secretCodec.protect(key, trimmed) }
|
||||||
|
.onSuccess { protectedValue ->
|
||||||
|
if (protectedValue.isBlank()) {
|
||||||
|
logDesktopTts("settings_secret_protect_empty key=$key codec=${secretCodec.name}")
|
||||||
|
} else {
|
||||||
|
setProperty(key, protectedValue)
|
||||||
|
logDesktopTts("settings_secret_protect_success key=$key codec=${secretCodec.name} prefix=\"${protectedValue.substringBefore(':', protectedValue)}\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopTts("settings_secret_protect_failed key=$key codec=${secretCodec.name} error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val GeminiKey = "geminiKeyProtected"
|
||||||
|
private const val GroqKey = "groqKeyProtected"
|
||||||
|
private const val LegacyGeminiKey = "geminiKey"
|
||||||
|
private const val LegacyGroqKey = "groqKey"
|
||||||
|
|
||||||
|
fun defaultSettingsFile(): File {
|
||||||
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||||
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||||
|
return File(baseDir, "Episteme/ai-byok.properties")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface DesktopSecretCodec {
|
||||||
|
val name: String get() = this::class.java.simpleName.ifBlank { "DesktopSecretCodec" }
|
||||||
|
val isAvailable: Boolean
|
||||||
|
fun protect(value: String): String
|
||||||
|
fun unprotect(value: String): String
|
||||||
|
fun protect(keyName: String, value: String): String = protect(value)
|
||||||
|
fun unprotect(keyName: String, value: String): String = unprotect(value)
|
||||||
|
fun delete(keyName: String) = Unit
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun platform(): DesktopSecretCodec {
|
||||||
|
val osName = System.getProperty("os.name").orEmpty()
|
||||||
|
val codec = if (osName.startsWith("Windows", ignoreCase = true)) {
|
||||||
|
WindowsSecretCodec
|
||||||
|
} else {
|
||||||
|
UnavailableDesktopSecretCodec
|
||||||
|
}
|
||||||
|
logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}")
|
||||||
|
return codec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object UnavailableDesktopSecretCodec : DesktopSecretCodec {
|
||||||
|
override val name: String = "unavailable"
|
||||||
|
override val isAvailable: Boolean = false
|
||||||
|
override fun protect(value: String): String {
|
||||||
|
throw IllegalStateException("Secure key storage is unavailable on this operating system.")
|
||||||
|
}
|
||||||
|
override fun unprotect(value: String): String = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private object WindowsSecretCodec : DesktopSecretCodec {
|
||||||
|
override val name: String = "windows"
|
||||||
|
|
||||||
|
override val isAvailable: Boolean
|
||||||
|
get() {
|
||||||
|
val wincred = WindowsCredentialSecretCodec.isAvailable
|
||||||
|
val dpapi = WindowsDpapiSecretCodec.isAvailable
|
||||||
|
logDesktopTts("settings_windows_available wincred=$wincred dpapi=$dpapi")
|
||||||
|
return wincred || dpapi
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun protect(value: String): String {
|
||||||
|
return protect("secret", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unprotect(value: String): String {
|
||||||
|
return unprotect("secret", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun protect(keyName: String, value: String): String {
|
||||||
|
val wincredFailure = runCatching { return WindowsCredentialSecretCodec.protect(keyName, value) }
|
||||||
|
.exceptionOrNull()
|
||||||
|
?.also { error -> logDesktopTts("settings_wincred_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
|
||||||
|
val dpapiFailure = runCatching { return WindowsDpapiSecretCodec.protect(value) }
|
||||||
|
.exceptionOrNull()
|
||||||
|
?.also { error -> logDesktopTts("settings_dpapi_protect_failed key=$keyName error=\"${error.desktopTtsSummary()}\"") }
|
||||||
|
throw IllegalStateException(
|
||||||
|
"No Windows secure key store write succeeded. " +
|
||||||
|
"Credential Manager: ${wincredFailure?.desktopTtsSummary() ?: "not attempted"}; " +
|
||||||
|
"DPAPI: ${dpapiFailure?.desktopTtsSummary() ?: "not attempted"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unprotect(keyName: String, value: String): String {
|
||||||
|
return when {
|
||||||
|
value.startsWith(WindowsCredentialSecretCodec.Prefix) -> WindowsCredentialSecretCodec.unprotect(keyName, value)
|
||||||
|
value.startsWith(WindowsDpapiSecretCodec.Prefix) -> WindowsDpapiSecretCodec.unprotect(value)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(keyName: String) {
|
||||||
|
if (WindowsCredentialSecretCodec.isAvailable) WindowsCredentialSecretCodec.delete(keyName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object WindowsCredentialSecretCodec : DesktopSecretCodec {
|
||||||
|
override val name: String = "wincred"
|
||||||
|
|
||||||
|
const val Prefix = "wincred:"
|
||||||
|
|
||||||
|
override val isAvailable: Boolean by lazy {
|
||||||
|
val probeKey = "probe"
|
||||||
|
val probe = "episteme-wincred-probe"
|
||||||
|
logDesktopTts("settings_wincred_probe_start")
|
||||||
|
runCatching {
|
||||||
|
val reference = protect(probeKey, probe)
|
||||||
|
val restored = unprotect(probeKey, reference)
|
||||||
|
val matches = restored == probe
|
||||||
|
logDesktopTts(
|
||||||
|
"settings_wincred_probe_result matches=$matches referencePrefix=\"${reference.substringBefore(':', reference)}\" " +
|
||||||
|
"restoredChars=${restored.length}"
|
||||||
|
)
|
||||||
|
matches
|
||||||
|
}.onFailure { error ->
|
||||||
|
logDesktopTts("settings_wincred_unavailable error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
}.also {
|
||||||
|
runCatching { delete(probeKey) }
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun protect(value: String): String {
|
||||||
|
return protect("secret", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unprotect(value: String): String {
|
||||||
|
return unprotect("secret", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun protect(keyName: String, value: String): String {
|
||||||
|
val target = credentialTarget(keyName)
|
||||||
|
logDesktopTts("settings_wincred_write_start key=$keyName target=\"$target\" valueChars=${value.length}")
|
||||||
|
val credential = WindowsCredential(target, value)
|
||||||
|
credential.write()
|
||||||
|
val ok = Advapi32.INSTANCE.CredWriteW(credential, 0)
|
||||||
|
val errorCode = Native.getLastError()
|
||||||
|
logDesktopTts("settings_wincred_write_result key=$keyName ok=$ok error=$errorCode")
|
||||||
|
if (!ok) throw IllegalStateException("Windows Credential Manager write failed: $errorCode")
|
||||||
|
return Prefix + target
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unprotect(keyName: String, value: String): String {
|
||||||
|
if (!value.startsWith(Prefix)) return ""
|
||||||
|
val target = value.removePrefix(Prefix).ifBlank { credentialTarget(keyName) }
|
||||||
|
logDesktopTts("settings_wincred_read_start key=$keyName target=\"$target\"")
|
||||||
|
val credentialPointer = PointerByReference()
|
||||||
|
val ok = Advapi32.INSTANCE.CredReadW(WString(target), WINDOWS_CRED_TYPE_GENERIC, 0, credentialPointer)
|
||||||
|
val errorCode = Native.getLastError()
|
||||||
|
logDesktopTts("settings_wincred_read_result key=$keyName ok=$ok error=$errorCode hasPointer=${credentialPointer.value != null}")
|
||||||
|
if (!ok) throw IllegalStateException("Windows Credential Manager read failed: $errorCode")
|
||||||
|
val pointer = credentialPointer.value ?: return ""
|
||||||
|
return try {
|
||||||
|
val credential = WindowsCredential(pointer)
|
||||||
|
val blobPointer = credential.CredentialBlob ?: return ""
|
||||||
|
logDesktopTts("settings_wincred_read_blob key=$keyName bytes=${credential.CredentialBlobSize}")
|
||||||
|
String(blobPointer.getByteArray(0, credential.CredentialBlobSize), Charsets.UTF_8)
|
||||||
|
} finally {
|
||||||
|
Advapi32.INSTANCE.CredFree(pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(keyName: String) {
|
||||||
|
val ok = Advapi32.INSTANCE.CredDeleteW(WString(credentialTarget(keyName)), WINDOWS_CRED_TYPE_GENERIC, 0)
|
||||||
|
val errorCode = Native.getLastError()
|
||||||
|
logDesktopTts("settings_wincred_delete_result key=$keyName ok=$ok error=$errorCode")
|
||||||
|
if (!ok && errorCode != WINDOWS_ERROR_NOT_FOUND) {
|
||||||
|
logDesktopTts("settings_wincred_delete_failed key=$keyName error=$errorCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun credentialTarget(keyName: String): String {
|
||||||
|
return "Episteme.Reader.AI.$keyName"
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface Advapi32 : StdCallLibrary {
|
||||||
|
fun CredWriteW(credential: WindowsCredential, flags: Int): Boolean
|
||||||
|
fun CredReadW(targetName: WString, type: Int, flags: Int, credential: PointerByReference): Boolean
|
||||||
|
fun CredDeleteW(targetName: WString, type: Int, flags: Int): Boolean
|
||||||
|
fun CredFree(buffer: Pointer?)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val INSTANCE: Advapi32 by lazy {
|
||||||
|
Native.load("Advapi32", Advapi32::class.java) as Advapi32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object WindowsDpapiSecretCodec : DesktopSecretCodec {
|
||||||
|
override val name: String = "dpapi"
|
||||||
|
|
||||||
|
const val Prefix = "dpapi:"
|
||||||
|
|
||||||
|
override val isAvailable: Boolean by lazy {
|
||||||
|
logDesktopTts("settings_dpapi_probe_start")
|
||||||
|
runCatching {
|
||||||
|
Crypt32.INSTANCE
|
||||||
|
Kernel32.INSTANCE
|
||||||
|
val probe = "episteme-dpapi-probe"
|
||||||
|
val encrypted = protect(probe)
|
||||||
|
val restored = unprotect(encrypted)
|
||||||
|
val matches = restored == probe
|
||||||
|
logDesktopTts("settings_dpapi_probe_result matches=$matches encryptedChars=${encrypted.length} restoredChars=${restored.length}")
|
||||||
|
matches
|
||||||
|
}.onFailure { error ->
|
||||||
|
logDesktopTts("settings_dpapi_unavailable error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun protect(value: String): String {
|
||||||
|
val input = DataBlob(value.toByteArray(Charsets.UTF_8))
|
||||||
|
val output = DataBlob()
|
||||||
|
logDesktopTts("settings_dpapi_protect_start bytes=${value.toByteArray(Charsets.UTF_8).size}")
|
||||||
|
val ok = Crypt32.INSTANCE.CryptProtectData(input, null, null, null, null, 0, output)
|
||||||
|
val errorCode = Native.getLastError()
|
||||||
|
logDesktopTts("settings_dpapi_protect_result ok=$ok error=$errorCode")
|
||||||
|
if (!ok) throw IllegalStateException("Windows DPAPI protect failed: $errorCode")
|
||||||
|
return try {
|
||||||
|
Prefix + Base64.getEncoder().encodeToString(output.toByteArray())
|
||||||
|
} finally {
|
||||||
|
output.free()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unprotect(value: String): String {
|
||||||
|
if (!value.startsWith(Prefix)) return ""
|
||||||
|
val encrypted = Base64.getDecoder().decode(value.removePrefix(Prefix))
|
||||||
|
val input = DataBlob(encrypted)
|
||||||
|
val output = DataBlob()
|
||||||
|
logDesktopTts("settings_dpapi_unprotect_start bytes=${encrypted.size}")
|
||||||
|
val ok = Crypt32.INSTANCE.CryptUnprotectData(input, null, null, null, null, 0, output)
|
||||||
|
val errorCode = Native.getLastError()
|
||||||
|
logDesktopTts("settings_dpapi_unprotect_result ok=$ok error=$errorCode")
|
||||||
|
if (!ok) throw IllegalStateException("Windows DPAPI unprotect failed: $errorCode")
|
||||||
|
return try {
|
||||||
|
String(output.toByteArray(), Charsets.UTF_8)
|
||||||
|
} finally {
|
||||||
|
output.free()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Structure.FieldOrder("dwLowDateTime", "dwHighDateTime")
|
||||||
|
internal class WindowsFileTime : Structure() {
|
||||||
|
@JvmField
|
||||||
|
var dwLowDateTime: Int = 0
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var dwHighDateTime: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Structure.FieldOrder(
|
||||||
|
"Flags",
|
||||||
|
"Type",
|
||||||
|
"TargetName",
|
||||||
|
"Comment",
|
||||||
|
"LastWritten",
|
||||||
|
"CredentialBlobSize",
|
||||||
|
"CredentialBlob",
|
||||||
|
"Persist",
|
||||||
|
"AttributeCount",
|
||||||
|
"Attributes",
|
||||||
|
"TargetAlias",
|
||||||
|
"UserName"
|
||||||
|
)
|
||||||
|
internal open class WindowsCredential : Structure {
|
||||||
|
@JvmField
|
||||||
|
var Flags: Int = 0
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var Type: Int = WINDOWS_CRED_TYPE_GENERIC
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var TargetName: WString? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var Comment: WString? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var LastWritten: WindowsFileTime = WindowsFileTime()
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var CredentialBlobSize: Int = 0
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var CredentialBlob: Pointer? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var Persist: Int = WINDOWS_CRED_PERSIST_LOCAL_MACHINE
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var AttributeCount: Int = 0
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var Attributes: Pointer? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var TargetAlias: WString? = null
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var UserName: WString? = WString("Episteme")
|
||||||
|
|
||||||
|
private var blobMemory: Memory? = null
|
||||||
|
|
||||||
|
constructor() : super()
|
||||||
|
|
||||||
|
constructor(pointer: Pointer) : super(pointer) {
|
||||||
|
read()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(target: String, secret: String) : super() {
|
||||||
|
val bytes = secret.toByteArray(Charsets.UTF_8)
|
||||||
|
TargetName = WString(target)
|
||||||
|
CredentialBlobSize = bytes.size
|
||||||
|
blobMemory = Memory(bytes.size.toLong()).also { memory ->
|
||||||
|
memory.write(0, bytes, 0, bytes.size)
|
||||||
|
CredentialBlob = memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Structure.FieldOrder("cbData", "pbData")
|
||||||
|
internal open class DataBlob() : Structure() {
|
||||||
|
@JvmField
|
||||||
|
var cbData: Int = 0
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var pbData: Pointer? = null
|
||||||
|
|
||||||
|
private var memory: Memory? = null
|
||||||
|
|
||||||
|
constructor(bytes: ByteArray) : this() {
|
||||||
|
cbData = bytes.size
|
||||||
|
memory = Memory(bytes.size.toLong()).also { allocated ->
|
||||||
|
allocated.write(0, bytes, 0, bytes.size)
|
||||||
|
pbData = allocated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toByteArray(): ByteArray {
|
||||||
|
read()
|
||||||
|
return pbData?.getByteArray(0, cbData) ?: ByteArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun free() {
|
||||||
|
pbData?.let { Kernel32.INSTANCE.LocalFree(it) }
|
||||||
|
pbData = null
|
||||||
|
cbData = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface Crypt32 : StdCallLibrary {
|
||||||
|
fun CryptProtectData(
|
||||||
|
pDataIn: DataBlob,
|
||||||
|
szDataDescr: String?,
|
||||||
|
pOptionalEntropy: DataBlob?,
|
||||||
|
pvReserved: Pointer?,
|
||||||
|
pPromptStruct: Pointer?,
|
||||||
|
dwFlags: Int,
|
||||||
|
pDataOut: DataBlob
|
||||||
|
): Boolean
|
||||||
|
|
||||||
|
fun CryptUnprotectData(
|
||||||
|
pDataIn: DataBlob,
|
||||||
|
ppszDataDescr: Pointer?,
|
||||||
|
pOptionalEntropy: DataBlob?,
|
||||||
|
pvReserved: Pointer?,
|
||||||
|
pPromptStruct: Pointer?,
|
||||||
|
dwFlags: Int,
|
||||||
|
pDataOut: DataBlob
|
||||||
|
): Boolean
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val INSTANCE: Crypt32 by lazy {
|
||||||
|
Native.load("Crypt32", Crypt32::class.java) as Crypt32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface Kernel32 : StdCallLibrary {
|
||||||
|
fun LocalFree(hMem: Pointer?): Pointer?
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val INSTANCE: Kernel32 by lazy {
|
||||||
|
Native.load("Kernel32", Kernel32::class.java) as Kernel32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.AiAdapter
|
||||||
|
import com.aryan.reader.shared.AiDefinitionResult
|
||||||
|
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||||
|
import com.aryan.reader.shared.ReaderAiFeature
|
||||||
|
import com.aryan.reader.shared.ReaderByokTextRequest
|
||||||
|
import com.aryan.reader.shared.ReaderByokTextRequestResult
|
||||||
|
import com.aryan.reader.shared.ReaderByokTextRequests
|
||||||
|
import com.aryan.reader.shared.RecapResult
|
||||||
|
import com.aryan.reader.shared.SummarizationResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class DesktopByokAiAdapter(
|
||||||
|
private val settingsProvider: () -> ReaderAiByokSettings
|
||||||
|
) : AiAdapter {
|
||||||
|
override val isAvailable: Boolean
|
||||||
|
get() = settingsProvider().sanitized().areReaderAiFeaturesAvailable
|
||||||
|
|
||||||
|
override suspend fun define(text: String, context: String?): AiDefinitionResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
|
||||||
|
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun summarize(text: String): SummarizationResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text)
|
||||||
|
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun recap(textBeforeCurrentLocation: String): RecapResult {
|
||||||
|
val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation)
|
||||||
|
return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun callTextAi(
|
||||||
|
feature: ReaderAiFeature,
|
||||||
|
text: String,
|
||||||
|
context: String? = null
|
||||||
|
): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
|
||||||
|
when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) {
|
||||||
|
ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden."))
|
||||||
|
is ReaderByokTextRequestResult.MissingKey -> {
|
||||||
|
Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models."))
|
||||||
|
}
|
||||||
|
is ReaderByokTextRequestResult.MissingModel -> {
|
||||||
|
Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models."))
|
||||||
|
}
|
||||||
|
is ReaderByokTextRequestResult.Ready -> runCatching {
|
||||||
|
requestResult.request.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReaderByokTextRequest.execute(): String {
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
val url = if (model.provider == "groq") {
|
||||||
|
URL("https://api.groq.com/openai/v1/chat/completions")
|
||||||
|
} else {
|
||||||
|
URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey")
|
||||||
|
}
|
||||||
|
connection = (url.openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
if (model.provider == "groq") {
|
||||||
|
setRequestProperty("Authorization", "Bearer $apiKey")
|
||||||
|
}
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 120_000
|
||||||
|
doOutput = true
|
||||||
|
doInput = true
|
||||||
|
}
|
||||||
|
val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this)
|
||||||
|
connection.outputStream.use { output ->
|
||||||
|
output.write(payload.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
val errorBody = runCatching {
|
||||||
|
connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||||
|
}.getOrNull()
|
||||||
|
throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
||||||
|
}
|
||||||
|
val text = if (model.provider == "groq") {
|
||||||
|
streamGroqResponse(connection)
|
||||||
|
} else {
|
||||||
|
streamGeminiResponse(connection)
|
||||||
|
}.trim()
|
||||||
|
if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.")
|
||||||
|
return text
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGroqPayload(request: ReaderByokTextRequest): String {
|
||||||
|
return buildJsonObject {
|
||||||
|
put("model", JsonPrimitive(request.model.name))
|
||||||
|
put(
|
||||||
|
"messages",
|
||||||
|
buildJsonArray {
|
||||||
|
add(buildJsonObject {
|
||||||
|
put("role", JsonPrimitive("system"))
|
||||||
|
put("content", JsonPrimitive(request.systemInstruction))
|
||||||
|
})
|
||||||
|
add(buildJsonObject {
|
||||||
|
put("role", JsonPrimitive("user"))
|
||||||
|
put("content", JsonPrimitive(request.userPrompt))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put("temperature", JsonPrimitive(request.temperature))
|
||||||
|
put("top_p", JsonPrimitive(0.95))
|
||||||
|
put("max_tokens", JsonPrimitive(request.maxTokens))
|
||||||
|
put("stream", JsonPrimitive(true))
|
||||||
|
if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none"))
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGeminiPayload(request: ReaderByokTextRequest): String {
|
||||||
|
return buildJsonObject {
|
||||||
|
put(
|
||||||
|
"contents",
|
||||||
|
buildJsonArray {
|
||||||
|
add(buildJsonObject {
|
||||||
|
put("parts", buildJsonArray {
|
||||||
|
add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"systemInstruction",
|
||||||
|
buildJsonObject {
|
||||||
|
put("parts", buildJsonArray {
|
||||||
|
add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"generationConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put("temperature", JsonPrimitive(request.temperature))
|
||||||
|
put("topP", JsonPrimitive(0.95))
|
||||||
|
put("topK", JsonPrimitive(40))
|
||||||
|
put("maxOutputTokens", JsonPrimitive(request.maxTokens))
|
||||||
|
put("response_mime_type", JsonPrimitive("text/plain"))
|
||||||
|
if (request.model.name.startsWith("gemini")) {
|
||||||
|
put(
|
||||||
|
"thinkingConfig",
|
||||||
|
buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamGeminiResponse(connection: HttpURLConnection): String {
|
||||||
|
val output = StringBuilder()
|
||||||
|
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
var buffer = ""
|
||||||
|
var line: String?
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
buffer += line
|
||||||
|
while (true) {
|
||||||
|
val start = buffer.indexOf('{')
|
||||||
|
if (start == -1) {
|
||||||
|
buffer = ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var depth = 0
|
||||||
|
var end = -1
|
||||||
|
scan@ for (index in start until buffer.length) {
|
||||||
|
when (buffer[index]) {
|
||||||
|
'{' -> depth++
|
||||||
|
'}' -> {
|
||||||
|
depth--
|
||||||
|
if (depth == 0) {
|
||||||
|
end = index
|
||||||
|
break@scan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end == -1) break
|
||||||
|
val jsonObject = buffer.substring(start, end + 1)
|
||||||
|
buffer = buffer.substring(end + 1)
|
||||||
|
val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull()
|
||||||
|
output.append(parsed.geminiTextChunk())
|
||||||
|
if (parsed.geminiFinishReason() == "SAFETY") {
|
||||||
|
throw IllegalStateException("Blocked for safety reasons.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamGroqResponse(connection: HttpURLConnection): String {
|
||||||
|
val output = StringBuilder()
|
||||||
|
var inThink = false
|
||||||
|
var thinkBuffer = ""
|
||||||
|
|
||||||
|
fun cleanChunk(text: String): String {
|
||||||
|
thinkBuffer += text
|
||||||
|
val cleaned = StringBuilder()
|
||||||
|
while (true) {
|
||||||
|
if (inThink) {
|
||||||
|
val end = thinkBuffer.indexOf("</think>")
|
||||||
|
if (end == -1) {
|
||||||
|
if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
inThink = false
|
||||||
|
thinkBuffer = thinkBuffer.substring(end + 8)
|
||||||
|
} else {
|
||||||
|
val start = thinkBuffer.indexOf("<think>")
|
||||||
|
if (start == -1) {
|
||||||
|
if (thinkBuffer.length > 6) {
|
||||||
|
cleaned.append(thinkBuffer.dropLast(6))
|
||||||
|
thinkBuffer = thinkBuffer.takeLast(6)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cleaned.append(thinkBuffer.substring(0, start))
|
||||||
|
inThink = true
|
||||||
|
thinkBuffer = thinkBuffer.substring(start + 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
var line: String?
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
val trimmed = line!!.trim()
|
||||||
|
if (!trimmed.startsWith("data: ")) continue
|
||||||
|
val data = trimmed.removePrefix("data: ").trim()
|
||||||
|
if (data == "[DONE]") continue
|
||||||
|
val chunk = runCatching {
|
||||||
|
DesktopAiJson.parseToJsonElement(data).jsonObject["choices"]
|
||||||
|
?.jsonArray
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObject
|
||||||
|
?.get("delta")
|
||||||
|
?.jsonObject
|
||||||
|
?.get("content")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.contentOrNull
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
output.append(cleanChunk(chunk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inThink && thinkBuffer.isNotBlank()) output.append(thinkBuffer)
|
||||||
|
return output.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DesktopAiJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun JsonObject?.geminiTextChunk(): String {
|
||||||
|
if (this == null) return ""
|
||||||
|
return this["candidates"]
|
||||||
|
?.jsonArrayOrNull()
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("content")
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("parts")
|
||||||
|
?.jsonArrayOrNull()
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("text")
|
||||||
|
?.jsonPrimitiveOrNull()
|
||||||
|
?.contentOrNull
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject?.geminiFinishReason(): String? {
|
||||||
|
if (this == null) return null
|
||||||
|
return this["candidates"]
|
||||||
|
?.jsonArrayOrNull()
|
||||||
|
?.firstOrNull()
|
||||||
|
?.jsonObjectOrNull()
|
||||||
|
?.get("finishReason")
|
||||||
|
?.jsonPrimitiveOrNull()
|
||||||
|
?.contentOrNull
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
|
||||||
|
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive
|
||||||
|
|
@ -0,0 +1,586 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.FileType
|
||||||
|
import com.aryan.reader.shared.opds.OpdsCatalog
|
||||||
|
import com.aryan.reader.shared.opds.OpdsStreamReference
|
||||||
|
import com.sun.jna.Library
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.ptr.PointerByReference
|
||||||
|
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||||
|
import java.awt.Font
|
||||||
|
import java.awt.Color
|
||||||
|
import java.awt.RenderingHints
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal object DesktopComicArchive {
|
||||||
|
private val comicTypes = setOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||||
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||||
|
|
||||||
|
fun canLoad(type: FileType): Boolean = type in comicTypes
|
||||||
|
|
||||||
|
fun load(file: File, type: FileType): DesktopComicDocument {
|
||||||
|
require(file.isFile) { "Missing comic archive: ${file.absolutePath}" }
|
||||||
|
require(canLoad(type)) { "${type.name} is not a comic archive type." }
|
||||||
|
return when (type) {
|
||||||
|
FileType.CBZ -> loadZip(file)
|
||||||
|
FileType.CBR -> loadRar(file)
|
||||||
|
FileType.CB7 -> loadSevenZ(file)
|
||||||
|
else -> error("${type.name} is not a comic archive type.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadOpdsStream(
|
||||||
|
path: String,
|
||||||
|
title: String,
|
||||||
|
reference: OpdsStreamReference,
|
||||||
|
catalog: OpdsCatalog?
|
||||||
|
): DesktopComicDocument {
|
||||||
|
val cacheDir = File(
|
||||||
|
DesktopLibraryDatabase.defaultDatabaseFile().parentFile,
|
||||||
|
"opds_stream_cache/${reference.id.hashCode()}"
|
||||||
|
).apply { mkdirs() }
|
||||||
|
val pages = (0 until reference.count).map { pageIndex ->
|
||||||
|
DesktopComicPage(
|
||||||
|
name = "opds_${pageIndex + 1}.jpg",
|
||||||
|
width = 800,
|
||||||
|
height = 1200,
|
||||||
|
source = OpdsStreamComicPageSource(
|
||||||
|
pageIndex = pageIndex,
|
||||||
|
urlTemplate = reference.urlTemplate,
|
||||||
|
catalog = catalog,
|
||||||
|
cacheDir = cacheDir
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DesktopComicDocument(
|
||||||
|
path = path,
|
||||||
|
title = title,
|
||||||
|
pages = pages,
|
||||||
|
closeAction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadZip(file: File): DesktopComicDocument {
|
||||||
|
val zip = ZipFile(file)
|
||||||
|
return try {
|
||||||
|
val pages = zip.entries()
|
||||||
|
.asSequence()
|
||||||
|
.filter { entry -> !entry.isDirectory && entry.name.isComicImageName() }
|
||||||
|
.sortedBy { entry -> entry.name.comicSortKey() }
|
||||||
|
.mapNotNull { entry ->
|
||||||
|
val bytes = zip.getInputStream(entry).use { it.readBytes() }
|
||||||
|
val size = decodeImageSize(bytes) ?: return@mapNotNull null
|
||||||
|
DesktopComicPage(
|
||||||
|
name = entry.name,
|
||||||
|
width = size.first,
|
||||||
|
height = size.second,
|
||||||
|
source = ZipComicPageSource(zip, entry.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||||
|
DesktopComicDocument(
|
||||||
|
path = file.absolutePath,
|
||||||
|
title = file.nameWithoutExtension,
|
||||||
|
pages = pages,
|
||||||
|
closeAction = { zip.close() }
|
||||||
|
)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
runCatching { zip.close() }
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRar(file: File): DesktopComicDocument {
|
||||||
|
val nativeResult = runCatching { loadRarWithNativeLibarchive(file) }
|
||||||
|
if (nativeResult.isSuccess) return nativeResult.getOrThrow()
|
||||||
|
|
||||||
|
return runCatching { loadWithArchiveCommand(file) }
|
||||||
|
.getOrElse { commandError ->
|
||||||
|
error(
|
||||||
|
"Could not open CBR with libarchive. " +
|
||||||
|
"Bundle archive.dll/libarchive for desktop, or keep Windows tar/bsdtar available. " +
|
||||||
|
"Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
|
||||||
|
"Command: ${commandError.shortMessage()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRarWithNativeLibarchive(file: File): DesktopComicDocument {
|
||||||
|
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||||
|
return try {
|
||||||
|
val extracted = DesktopLibarchive.extractImagePages(file, tempDir, imageExtensions)
|
||||||
|
documentFromExtracted(file, extracted, tempDir)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
runCatching { tempDir.deleteRecursively() }
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSevenZ(file: File): DesktopComicDocument {
|
||||||
|
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||||
|
val commonsResult = runCatching {
|
||||||
|
val extracted = mutableListOf<ExtractedComicPage>()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
SevenZFile(file).use { archive ->
|
||||||
|
var entry = archive.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val name = entry.name.orEmpty()
|
||||||
|
if (!entry.isDirectory && name.isComicImageName()) {
|
||||||
|
val target = File(tempDir, "page_${extracted.size}.${name.imageExtension()}")
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
archive.copyCurrentEntryTo(output)
|
||||||
|
}
|
||||||
|
extracted += ExtractedComicPage(name = name, file = target)
|
||||||
|
}
|
||||||
|
entry = archive.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentFromExtracted(file, extracted, tempDir)
|
||||||
|
}
|
||||||
|
if (commonsResult.isSuccess) return commonsResult.getOrThrow()
|
||||||
|
|
||||||
|
runCatching { tempDir.deleteRecursively() }
|
||||||
|
return runCatching { loadWithArchiveCommand(file) }
|
||||||
|
.getOrElse { commandError ->
|
||||||
|
error(
|
||||||
|
"Could not open CB7 with Commons Compress or system tar/bsdtar. " +
|
||||||
|
"Commons: ${commonsResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
|
||||||
|
"Command: ${commandError.shortMessage()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadWithArchiveCommand(file: File): DesktopComicDocument {
|
||||||
|
val tempDir = Files.createTempDirectory("reader-comic-").toFile()
|
||||||
|
return try {
|
||||||
|
val extracted = DesktopArchiveCommand.extractImagePages(file, tempDir, imageExtensions)
|
||||||
|
documentFromExtracted(file, extracted, tempDir)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
runCatching { tempDir.deleteRecursively() }
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun documentFromExtracted(
|
||||||
|
file: File,
|
||||||
|
extracted: List<ExtractedComicPage>,
|
||||||
|
tempDir: File
|
||||||
|
): DesktopComicDocument {
|
||||||
|
val pages = extracted
|
||||||
|
.sortedBy { page -> page.name.comicSortKey() }
|
||||||
|
.mapNotNull { page ->
|
||||||
|
val bytes = page.file.readBytes()
|
||||||
|
val size = decodeImageSize(bytes) ?: return@mapNotNull null
|
||||||
|
DesktopComicPage(
|
||||||
|
name = page.name,
|
||||||
|
width = size.first,
|
||||||
|
height = size.second,
|
||||||
|
source = FileComicPageSource(page.file)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
require(pages.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||||
|
return DesktopComicDocument(
|
||||||
|
path = file.absolutePath,
|
||||||
|
title = file.nameWithoutExtension,
|
||||||
|
pages = pages,
|
||||||
|
closeAction = { tempDir.deleteRecursively() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SevenZFile.copyCurrentEntryTo(output: OutputStream) {
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val read = read(buffer, 0, buffer.size)
|
||||||
|
if (read < 0) break
|
||||||
|
if (read > 0) output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeImageSize(bytes: ByteArray): Pair<Int, Int>? {
|
||||||
|
val image = ByteArrayInputStream(bytes).use { input ->
|
||||||
|
ImageIO.read(input)
|
||||||
|
} ?: return null
|
||||||
|
return image.width.coerceAtLeast(1) to image.height.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.isComicImageName(): Boolean {
|
||||||
|
return imageExtension() in imageExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.imageExtension(): String {
|
||||||
|
return substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "img")
|
||||||
|
.lowercase()
|
||||||
|
.takeIf { it in imageExtensions }
|
||||||
|
?: "img"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.comicSortKey(): String {
|
||||||
|
return replace('\\', '/').lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Throwable.shortMessage(): String {
|
||||||
|
return message
|
||||||
|
?.replace(Regex("\\s+"), " ")
|
||||||
|
?.take(240)
|
||||||
|
?.ifBlank { null }
|
||||||
|
?: javaClass.simpleName
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ExtractedComicPage(
|
||||||
|
val name: String,
|
||||||
|
val file: File
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DesktopComicDocument(
|
||||||
|
val path: String,
|
||||||
|
val title: String,
|
||||||
|
pages: List<DesktopComicPage>,
|
||||||
|
private val closeAction: () -> Unit
|
||||||
|
) {
|
||||||
|
private val pages = pages.toList()
|
||||||
|
|
||||||
|
val pageCount: Int = pages.size
|
||||||
|
val pageSizes: List<DesktopPdfPageSize> = pages.map { page ->
|
||||||
|
DesktopPdfPageSize(page.width.toFloat(), page.height.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderPageBufferedImage(pageIndex: Int, scale: Float): BufferedImage {
|
||||||
|
val page = pages.getOrNull(pageIndex) ?: error("Invalid comic page index $pageIndex.")
|
||||||
|
val sourceImage = ByteArrayInputStream(page.source.readBytes()).use { input ->
|
||||||
|
ImageIO.read(input)
|
||||||
|
} ?: error("Could not decode comic page ${pageIndex + 1}.")
|
||||||
|
val safeScale = scale.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
|
val sourceWidth = sourceImage.width.coerceAtLeast(1)
|
||||||
|
val sourceHeight = sourceImage.height.coerceAtLeast(1)
|
||||||
|
val width = (sourceWidth * safeScale).roundToInt().coerceAtLeast(1)
|
||||||
|
val height = (sourceHeight * safeScale).roundToInt().coerceAtLeast(1)
|
||||||
|
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val graphics = image.createGraphics()
|
||||||
|
try {
|
||||||
|
graphics.color = Color.WHITE
|
||||||
|
graphics.fillRect(0, 0, width, height)
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||||
|
graphics.drawImage(sourceImage, 0, 0, width, height, null)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
sourceImage.flush()
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
closeAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DesktopComicPage(
|
||||||
|
val name: String,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val source: ComicPageSource
|
||||||
|
)
|
||||||
|
|
||||||
|
internal interface ComicPageSource {
|
||||||
|
fun readBytes(): ByteArray
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ZipComicPageSource(
|
||||||
|
private val zip: ZipFile,
|
||||||
|
private val entryName: String
|
||||||
|
) : ComicPageSource {
|
||||||
|
override fun readBytes(): ByteArray {
|
||||||
|
val entry = zip.getEntry(entryName) ?: error("Missing comic page entry: $entryName")
|
||||||
|
return zip.getInputStream(entry).use { it.readBytes() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FileComicPageSource(
|
||||||
|
private val file: File
|
||||||
|
) : ComicPageSource {
|
||||||
|
override fun readBytes(): ByteArray = file.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OpdsStreamComicPageSource(
|
||||||
|
private val pageIndex: Int,
|
||||||
|
private val urlTemplate: String,
|
||||||
|
private val catalog: OpdsCatalog?,
|
||||||
|
private val cacheDir: File
|
||||||
|
) : ComicPageSource {
|
||||||
|
override fun readBytes(): ByteArray {
|
||||||
|
val cachedFile = File(cacheDir, "page_$pageIndex.jpg")
|
||||||
|
if (cachedFile.isFile && cachedFile.length() > 0L) {
|
||||||
|
return cachedFile.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val bytes = DesktopOpdsHttp.fetchBytes(streamPageUrl(), catalog)
|
||||||
|
if (bytes.isNotEmpty()) {
|
||||||
|
cachedFile.writeBytes(bytes)
|
||||||
|
bytes
|
||||||
|
} else {
|
||||||
|
error("Empty OPDS stream page response.")
|
||||||
|
}
|
||||||
|
}.getOrElse {
|
||||||
|
errorPageBytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamPageUrl(): String {
|
||||||
|
return effectiveTemplate()
|
||||||
|
.replace("{pageNumber}", pageIndex.toString())
|
||||||
|
.replace("{page}", pageIndex.toString())
|
||||||
|
.replace("{maxWidth}", "1600")
|
||||||
|
.replace("{maxHeight}", "2400")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun effectiveTemplate(): String {
|
||||||
|
val catalogUrl = catalog?.url ?: return urlTemplate
|
||||||
|
if (!urlTemplate.startsWith("http", ignoreCase = true)) return urlTemplate
|
||||||
|
return runCatching {
|
||||||
|
val oldUrl = URL(urlTemplate)
|
||||||
|
val newUrl = URL(catalogUrl)
|
||||||
|
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
|
||||||
|
val newBase = "${newUrl.protocol}://${newUrl.authority}"
|
||||||
|
urlTemplate.replace(oldBase, newBase)
|
||||||
|
}.getOrDefault(urlTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorPageBytes(): ByteArray {
|
||||||
|
val image = BufferedImage(800, 1200, BufferedImage.TYPE_INT_RGB)
|
||||||
|
val graphics = image.createGraphics()
|
||||||
|
try {
|
||||||
|
graphics.color = Color.DARK_GRAY
|
||||||
|
graphics.fillRect(0, 0, image.width, image.height)
|
||||||
|
graphics.color = Color.WHITE
|
||||||
|
graphics.font = Font(Font.SANS_SERIF, Font.BOLD, 36)
|
||||||
|
val text = "Page unavailable"
|
||||||
|
val metrics = graphics.fontMetrics
|
||||||
|
graphics.drawString(text, (image.width - metrics.stringWidth(text)) / 2, image.height / 2)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
}
|
||||||
|
return ByteArrayOutputStream().use { output ->
|
||||||
|
ImageIO.write(image, "jpg", output)
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DesktopArchiveCommand {
|
||||||
|
private const val EXTRACT_TIMEOUT_SECONDS = 60L
|
||||||
|
|
||||||
|
fun extractImagePages(
|
||||||
|
file: File,
|
||||||
|
tempDir: File,
|
||||||
|
imageExtensions: Set<String>
|
||||||
|
): List<DesktopComicArchive.ExtractedComicPage> {
|
||||||
|
val command = resolveCommand()
|
||||||
|
?: error("No tar/bsdtar command was found on PATH.")
|
||||||
|
val names = listArchiveEntries(command, file)
|
||||||
|
.filter { name ->
|
||||||
|
val extension = name.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
extension in imageExtensions
|
||||||
|
}
|
||||||
|
.sortedBy { name -> with(DesktopComicArchive) { name.comicSortKey() } }
|
||||||
|
require(names.isNotEmpty()) { "No readable image pages were found in ${file.name}." }
|
||||||
|
return names.mapIndexed { index, name ->
|
||||||
|
val extension = name.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "img")
|
||||||
|
.lowercase()
|
||||||
|
.ifBlank { "img" }
|
||||||
|
val target = File(tempDir, "page_$index.$extension")
|
||||||
|
extractEntry(command, file, name, target)
|
||||||
|
DesktopComicArchive.ExtractedComicPage(name = name, file = target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCommand(): String? {
|
||||||
|
val override = System.getProperty("reader.archive.command")
|
||||||
|
?: System.getenv("READER_ARCHIVE_COMMAND")
|
||||||
|
return listOfNotNull(override?.takeIf { it.isNotBlank() }, "bsdtar", "tar")
|
||||||
|
.firstOrNull(::isCommandAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCommandAvailable(command: String): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
val process = ProcessBuilder(command, "--version")
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
process.inputStream.use { it.readBytes() }
|
||||||
|
process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listArchiveEntries(command: String, file: File): List<String> {
|
||||||
|
val process = ProcessBuilder(command, "-tf", file.absolutePath)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
error("$command timed out while listing ${file.name}.")
|
||||||
|
}
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
error(output.ifBlank { "$command could not list ${file.name}." })
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
.lineSequence()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractEntry(command: String, file: File, entryName: String, target: File) {
|
||||||
|
val process = ProcessBuilder(command, "-xOf", file.absolutePath, entryName)
|
||||||
|
.start()
|
||||||
|
val errorText = StringBuilder()
|
||||||
|
val errorThread = Thread {
|
||||||
|
process.errorStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
errorText.append(reader.readText())
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
process.inputStream.use { input ->
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val finished = process.waitFor(EXTRACT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
error("$command timed out while extracting $entryName.")
|
||||||
|
}
|
||||||
|
errorThread.join(1000)
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
error(errorText.toString().ifBlank { "$command could not extract $entryName." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DesktopLibarchive {
|
||||||
|
private const val ARCHIVE_OK = 0
|
||||||
|
private const val ARCHIVE_EOF = 1
|
||||||
|
private const val ARCHIVE_ENTRY_DIRECTORY = 0x4000
|
||||||
|
private const val BUFFER_SIZE = 128 * 1024
|
||||||
|
|
||||||
|
fun extractImagePages(
|
||||||
|
file: File,
|
||||||
|
tempDir: File,
|
||||||
|
imageExtensions: Set<String>
|
||||||
|
): List<DesktopComicArchive.ExtractedComicPage> {
|
||||||
|
val archive = api.archive_read_new()
|
||||||
|
?: error("libarchive could not allocate a reader.")
|
||||||
|
try {
|
||||||
|
checkArchive(api.archive_read_support_filter_all(archive), archive, "enable archive filters")
|
||||||
|
checkArchive(api.archive_read_support_format_all(archive), archive, "enable archive formats")
|
||||||
|
checkArchive(
|
||||||
|
api.archive_read_open_filename(archive, file.absolutePath, BUFFER_SIZE.toLong()),
|
||||||
|
archive,
|
||||||
|
"open ${file.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val pages = mutableListOf<DesktopComicArchive.ExtractedComicPage>()
|
||||||
|
val entryRef = PointerByReference()
|
||||||
|
while (true) {
|
||||||
|
when (val status = api.archive_read_next_header(archive, entryRef)) {
|
||||||
|
ARCHIVE_OK -> Unit
|
||||||
|
ARCHIVE_EOF -> break
|
||||||
|
else -> checkArchive(status, archive, "read archive header")
|
||||||
|
}
|
||||||
|
|
||||||
|
val entry = entryRef.value ?: continue
|
||||||
|
val name = api.archive_entry_pathname_utf8(entry)
|
||||||
|
?: api.archive_entry_pathname(entry)
|
||||||
|
?: continue
|
||||||
|
val extension = name.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
if (api.archive_entry_filetype(entry) == ARCHIVE_ENTRY_DIRECTORY || extension !in imageExtensions) {
|
||||||
|
api.archive_read_data_skip(archive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = File(tempDir, "page_${pages.size}.${extension.ifBlank { "img" }}")
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val read = api.archive_read_data(archive, buffer, buffer.size.toLong())
|
||||||
|
when {
|
||||||
|
read > 0 -> output.write(buffer, 0, read.toInt())
|
||||||
|
read == 0L -> break
|
||||||
|
else -> checkArchive(read.toInt(), archive, "extract $name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages += DesktopComicArchive.ExtractedComicPage(name = name, file = target)
|
||||||
|
}
|
||||||
|
return pages.sortedBy { page -> with(DesktopComicArchive) { page.name.comicSortKey() } }
|
||||||
|
} finally {
|
||||||
|
api.archive_read_free(archive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val api: LibarchiveLibrary by lazy {
|
||||||
|
val overridePath = System.getProperty("reader.libarchive.path")
|
||||||
|
?: System.getenv("READER_LIBARCHIVE_PATH")
|
||||||
|
val candidates = if (overridePath.isNullOrBlank()) {
|
||||||
|
listOf("archive", "libarchive", "libarchive-13", "libarchive-14")
|
||||||
|
} else {
|
||||||
|
listOf(File(overridePath).absolutePath, overridePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.firstNotNullOfOrNull { candidate ->
|
||||||
|
runCatching { Native.load(candidate, LibarchiveLibrary::class.java) }
|
||||||
|
.getOrNull()
|
||||||
|
} ?: error(
|
||||||
|
"Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " +
|
||||||
|
"or bundle archive.dll/libarchive for this platform."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkArchive(status: Int, archive: Pointer, action: String) {
|
||||||
|
if (status >= ARCHIVE_OK) return
|
||||||
|
val message = api.archive_error_string(archive).orEmpty()
|
||||||
|
error("libarchive could not $action: ${message.ifBlank { "error code $status" }}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
private interface LibarchiveLibrary : Library {
|
||||||
|
fun archive_read_new(): Pointer?
|
||||||
|
fun archive_read_support_filter_all(archive: Pointer): Int
|
||||||
|
fun archive_read_support_format_all(archive: Pointer): Int
|
||||||
|
fun archive_read_open_filename(archive: Pointer, fileName: String, blockSize: Long): Int
|
||||||
|
fun archive_read_next_header(archive: Pointer, entry: PointerByReference): Int
|
||||||
|
fun archive_read_data(archive: Pointer, buffer: ByteArray, size: Long): Long
|
||||||
|
fun archive_read_data_skip(archive: Pointer): Int
|
||||||
|
fun archive_read_free(archive: Pointer): Int
|
||||||
|
fun archive_error_string(archive: Pointer): String?
|
||||||
|
fun archive_entry_pathname_utf8(entry: Pointer): String?
|
||||||
|
fun archive_entry_pathname(entry: Pointer): String?
|
||||||
|
fun archive_entry_filetype(entry: Pointer): Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.CustomFontItem
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class DesktopCustomFontStore(
|
||||||
|
private val fontsDir: File = defaultFontsDir()
|
||||||
|
) {
|
||||||
|
private var googleFontsCache: List<String>? = null
|
||||||
|
|
||||||
|
fun importFont(source: File, displayNameOverride: String? = null): Result<CustomFontItem> {
|
||||||
|
if (!source.isFile) {
|
||||||
|
return Result.failure(IllegalArgumentException("Choose a font file."))
|
||||||
|
}
|
||||||
|
val extension = source.extension.lowercase()
|
||||||
|
if (extension !in SupportedFontExtensions) {
|
||||||
|
return Result.failure(IllegalArgumentException("Unsupported font format. Use TTF, OTF, or WOFF2."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
fontsDir.mkdirs()
|
||||||
|
val fontId = UUID.randomUUID().toString()
|
||||||
|
val fileName = "font_$fontId.$extension"
|
||||||
|
val destination = File(fontsDir, fileName)
|
||||||
|
source.inputStream().use { input ->
|
||||||
|
destination.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
CustomFontItem(
|
||||||
|
id = fontId,
|
||||||
|
displayName = displayNameOverride?.takeIf { it.isNotBlank() }
|
||||||
|
?: source.nameWithoutExtension.ifBlank { "Imported font" },
|
||||||
|
fileName = fileName,
|
||||||
|
fileExtension = extension,
|
||||||
|
path = destination.absolutePath,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteFont(font: CustomFontItem): Boolean {
|
||||||
|
val target = runCatching { File(font.path).canonicalFile }.getOrNull() ?: return false
|
||||||
|
val root = runCatching { fontsDir.canonicalFile }.getOrNull() ?: return false
|
||||||
|
val insideFontStore = generateSequence(target) { it.parentFile }.any { it == root }
|
||||||
|
if (!insideFontStore) return false
|
||||||
|
return !target.exists() || target.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadGoogleFontsList(): List<String> {
|
||||||
|
googleFontsCache?.let { return it }
|
||||||
|
val loaded = runCatching {
|
||||||
|
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(GoogleFontsResource)
|
||||||
|
?: DesktopCustomFontStore::class.java.classLoader?.getResourceAsStream(GoogleFontsResource)
|
||||||
|
?: return@runCatching emptyList()
|
||||||
|
stream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
googleFontsFromJson(reader.readText())
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
googleFontsCache = loaded
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadGoogleFont(fontName: String): Result<CustomFontItem> {
|
||||||
|
val normalizedFontName = fontName.trim()
|
||||||
|
if (normalizedFontName.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("Choose a Google Font."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val encodedName = URLEncoder.encode(normalizedFontName, Charsets.UTF_8.name())
|
||||||
|
val cssConnection = URL("https://fonts.googleapis.com/css?family=$encodedName")
|
||||||
|
.openConnection() as HttpURLConnection
|
||||||
|
cssConnection.setRequestProperty("User-Agent", GoogleFontsSafariUserAgent)
|
||||||
|
cssConnection.connectTimeout = 15_000
|
||||||
|
cssConnection.readTimeout = 15_000
|
||||||
|
|
||||||
|
if (cssConnection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
throw IllegalStateException("Font '$normalizedFontName' was not found on Google Fonts.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val css = cssConnection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
val fontUrl = googleFontDownloadUrlFromCss(css)
|
||||||
|
?: throw IllegalStateException("Could not parse a download link for $normalizedFontName.")
|
||||||
|
val extension = googleFontFileExtension(fontUrl)
|
||||||
|
if (extension !in SupportedFontExtensions) {
|
||||||
|
throw IllegalStateException("Unsupported format ($extension) returned for $normalizedFontName.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempFile = File.createTempFile("episteme_google_font_", ".$extension")
|
||||||
|
try {
|
||||||
|
val fontConnection = URL(fontUrl).openConnection() as HttpURLConnection
|
||||||
|
fontConnection.connectTimeout = 15_000
|
||||||
|
fontConnection.readTimeout = 30_000
|
||||||
|
fontConnection.inputStream.use { input ->
|
||||||
|
tempFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
importFont(tempFile, displayNameOverride = normalizedFontName).getOrThrow()
|
||||||
|
} finally {
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val SupportedFontExtensions = setOf("ttf", "otf", "woff2")
|
||||||
|
private const val GoogleFontsResource = "google_fonts.json"
|
||||||
|
private const val GoogleFontsSafariUserAgent =
|
||||||
|
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1"
|
||||||
|
|
||||||
|
fun defaultFontsDir(): File {
|
||||||
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||||
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||||
|
return File(baseDir, "Episteme/custom_fonts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun googleFontDownloadUrlFromCss(css: String): String? {
|
||||||
|
return Regex("""url\((https://[^)]+)\)""")
|
||||||
|
.find(css)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun googleFontFileExtension(fontUrl: String): String {
|
||||||
|
return fontUrl.substringBefore('?')
|
||||||
|
.substringAfterLast('.', "ttf")
|
||||||
|
.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun googleFontsFromJson(rawJson: String): List<String> {
|
||||||
|
return Json.parseToJsonElement(rawJson)
|
||||||
|
.jsonArray
|
||||||
|
.mapNotNull { element ->
|
||||||
|
runCatching { element.jsonPrimitive.content.trim().takeIf { it.isNotBlank() } }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,282 +1,12 @@
|
||||||
package com.aryan.reader.desktop
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import com.aryan.reader.shared.FileType
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.Density
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aryan.reader.paginatedreader.CssParser
|
|
||||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
|
||||||
import com.aryan.reader.paginatedreader.UserAgentStylesheet
|
|
||||||
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
|
|
||||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
import com.aryan.reader.shared.reader.SharedJvmBookLoader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Base64
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
object DesktopEpubLoader {
|
object DesktopEpubLoader {
|
||||||
fun load(file: File): SharedEpubBook {
|
fun load(file: File): SharedEpubBook {
|
||||||
ZipFile(file).use { zip ->
|
return SharedJvmBookLoader.load(file, FileType.EPUB)
|
||||||
val container = zip.readText("META-INF/container.xml")
|
|
||||||
val opfPath = container
|
|
||||||
.substringAfter("full-path=\"", missingDelimiterValue = "")
|
|
||||||
.substringBefore("\"")
|
|
||||||
.ifBlank { error("EPUB container does not point to an OPF package.") }
|
|
||||||
val opf = zip.readText(opfPath)
|
|
||||||
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
|
||||||
.let { if (it.isBlank()) "" else "$it/" }
|
|
||||||
|
|
||||||
val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
|
|
||||||
val author = opf.tagText("creator").ifBlank { null }
|
|
||||||
val manifest = parseManifest(opf)
|
|
||||||
val cssByPath = loadCss(zip, manifest, basePath)
|
|
||||||
val cssRules = parseCssRules(cssByPath)
|
|
||||||
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
|
|
||||||
.findAll(opf)
|
|
||||||
.mapNotNull { match -> manifest[match.groupValues[1]] }
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
val chapterPaths = spine.ifEmpty {
|
|
||||||
manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val chapters = chapterPaths.mapIndexedNotNull { index, href ->
|
|
||||||
val path = normalizeZipPath(basePath + href)
|
|
||||||
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
|
|
||||||
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
|
|
||||||
val text = htmlToText(html)
|
|
||||||
val semanticBlocks = runCatching {
|
|
||||||
htmlToSemanticBlocks(
|
|
||||||
html = resourceReadyHtml,
|
|
||||||
cssRules = cssRules,
|
|
||||||
textStyle = TextStyle(fontSize = 18.sp),
|
|
||||||
chapterAbsPath = path,
|
|
||||||
extractionBasePath = "",
|
|
||||||
density = Density(1f),
|
|
||||||
fontFamilyMap = emptyMap(),
|
|
||||||
constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
|
||||||
)
|
|
||||||
}.getOrElse { emptyList() }
|
|
||||||
if (text.isBlank()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
SharedEpubChapter(
|
|
||||||
id = "chapter_$index",
|
|
||||||
title = html.tagText("h1")
|
|
||||||
.ifBlank { html.tagText("h2") }
|
|
||||||
.ifBlank { html.tagText("title") }
|
|
||||||
.ifBlank { "Chapter ${index + 1}" },
|
|
||||||
plainText = text,
|
|
||||||
semanticBlocks = semanticBlocks,
|
|
||||||
htmlContent = resourceReadyHtml.extractBodyOrSelf(),
|
|
||||||
baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SharedEpubBook(
|
|
||||||
id = file.absolutePath,
|
|
||||||
fileName = file.name,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
css = cssByPath,
|
|
||||||
chapters = chapters.ifEmpty {
|
|
||||||
listOf(
|
|
||||||
SharedEpubChapter(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
title = title,
|
|
||||||
plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseManifest(opf: String): Map<String, String> {
|
|
||||||
return Regex("<item\\s+[^>]*>").findAll(opf).mapNotNull { match ->
|
|
||||||
val item = match.value
|
|
||||||
val id = item.attr("id")
|
|
||||||
val href = item.attr("href")
|
|
||||||
if (id.isBlank() || href.isBlank()) null else id to href
|
|
||||||
}.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
|
|
||||||
return manifest.values
|
|
||||||
.filter { it.endsWith(".css", ignoreCase = true) }
|
|
||||||
.mapNotNull { href ->
|
|
||||||
val path = normalizeZipPath(basePath + href)
|
|
||||||
val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
|
|
||||||
if (css.isBlank()) null else path to css
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseCssRules(cssByPath: Map<String, String>): OptimizedCssRules {
|
|
||||||
val constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
|
||||||
val baseRules = CssParser.parse(
|
|
||||||
cssContent = UserAgentStylesheet.default,
|
|
||||||
cssPath = null,
|
|
||||||
baseFontSizeSp = 18f,
|
|
||||||
density = 1f,
|
|
||||||
constraints = constraints,
|
|
||||||
isDarkTheme = false
|
|
||||||
).rules
|
|
||||||
|
|
||||||
return cssByPath.entries
|
|
||||||
.fold(baseRules) { rules, (path, css) ->
|
|
||||||
if (css.isBlank()) {
|
|
||||||
rules
|
|
||||||
} else {
|
|
||||||
rules.merge(
|
|
||||||
CssParser.parse(
|
|
||||||
cssContent = css,
|
|
||||||
cssPath = path,
|
|
||||||
baseFontSizeSp = 18f,
|
|
||||||
density = 1f,
|
|
||||||
constraints = constraints,
|
|
||||||
isDarkTheme = false
|
|
||||||
).rules
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ZipFile.readText(path: String): String {
|
|
||||||
val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
|
|
||||||
return getInputStream(entry).bufferedReader().use { it.readText() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ZipFile.readTextOrNull(path: String): String? {
|
|
||||||
val entry = getEntry(path) ?: return null
|
|
||||||
return getInputStream(entry).bufferedReader().use { it.readText() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.attr(name: String): String {
|
|
||||||
return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.tagText(tag: String): String {
|
|
||||||
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
|
|
||||||
.find(this)
|
|
||||||
?.groupValues
|
|
||||||
?.get(1)
|
|
||||||
?.let(::htmlToText)
|
|
||||||
.orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeZipPath(path: String): String {
|
|
||||||
val parts = ArrayDeque<String>()
|
|
||||||
path.split('/').forEach { part ->
|
|
||||||
when (part) {
|
|
||||||
"", "." -> Unit
|
|
||||||
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
|
||||||
else -> parts.addLast(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts.joinToString("/")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
|
|
||||||
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
|
|
||||||
val attr = match.groupValues[1]
|
|
||||||
val raw = match.groupValues[2]
|
|
||||||
if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
|
|
||||||
return@replace match.value
|
|
||||||
}
|
|
||||||
val dataUri = zip.toDataUri(raw, chapterPath)
|
|
||||||
if (dataUri != null) "$attr=\"$dataUri\"" else match.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.looksLikeEmbeddableResource(): Boolean {
|
|
||||||
return substringBefore('#')
|
|
||||||
.substringBefore('?')
|
|
||||||
.substringAfterLast('.', "")
|
|
||||||
.lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.sanitizeReaderHtml(): String {
|
|
||||||
return replace(Regex("(?is)<script\\b.*?</script>"), "")
|
|
||||||
.replace(Regex("(?is)<object\\b.*?</object>"), "")
|
|
||||||
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
|
|
||||||
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
|
|
||||||
return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
|
|
||||||
val raw = match.groupValues[2].trim()
|
|
||||||
val dataUri = zip.toDataUri(raw, cssPath)
|
|
||||||
if (dataUri != null) "url('$dataUri')" else match.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
|
|
||||||
val ref = rawRef.substringBefore('#').trim()
|
|
||||||
if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
|
|
||||||
if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
|
|
||||||
val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
|
|
||||||
val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
|
|
||||||
val entry = getEntry(path) ?: return null
|
|
||||||
val bytes = getInputStream(entry).use { it.readBytes() }
|
|
||||||
return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mimeType(path: String): String {
|
|
||||||
return when (path.substringAfterLast('.', "").lowercase()) {
|
|
||||||
"jpg", "jpeg" -> "image/jpeg"
|
|
||||||
"png" -> "image/png"
|
|
||||||
"gif" -> "image/gif"
|
|
||||||
"svg" -> "image/svg+xml"
|
|
||||||
"webp" -> "image/webp"
|
|
||||||
"ttf" -> "font/ttf"
|
|
||||||
"otf" -> "font/otf"
|
|
||||||
"woff" -> "font/woff"
|
|
||||||
"woff2" -> "font/woff2"
|
|
||||||
"css" -> "text/css"
|
|
||||||
"js" -> "text/javascript"
|
|
||||||
else -> "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.extractBodyOrSelf(): String {
|
|
||||||
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
|
|
||||||
.find(this)
|
|
||||||
?.groupValues
|
|
||||||
?.get(1)
|
|
||||||
?.trim()
|
|
||||||
?: this
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun htmlToText(html: String): String {
|
|
||||||
return html
|
|
||||||
.replace(Regex("(?is)<script.*?</script>"), "")
|
|
||||||
.replace(Regex("(?is)<style.*?</style>"), "")
|
|
||||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
|
||||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
|
||||||
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
|
|
||||||
.replace(Regex("<[^>]+>"), " ")
|
|
||||||
.decodeEntities()
|
|
||||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
|
||||||
.replace(Regex(" *\\n *"), "\n")
|
|
||||||
.replace(Regex("\\n{3,}"), "\n\n")
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.decodeEntities(): String {
|
|
||||||
return replace(" ", " ")
|
|
||||||
.replace("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace(""", "\"")
|
|
||||||
.replace("'", "'")
|
|
||||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
|
||||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
|
||||||
}
|
|
||||||
.replace(Regex("&#(\\d+);")) { match ->
|
|
||||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,530 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.BookItem
|
||||||
|
import com.aryan.reader.shared.FileType
|
||||||
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
import com.aryan.reader.shared.reader.SharedJvmBookLoader
|
||||||
|
import java.awt.Color
|
||||||
|
import java.awt.Font
|
||||||
|
import java.awt.GradientPaint
|
||||||
|
import java.awt.RenderingHints
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
data class DesktopFolderMetadataExtractionResult(
|
||||||
|
val books: List<BookItem>,
|
||||||
|
val stats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DesktopFolderMetadataExtractionStats(
|
||||||
|
val processedBooks: Int = 0,
|
||||||
|
val updatedBooks: Int = 0,
|
||||||
|
val coversUpdated: Int = 0,
|
||||||
|
val failedBooks: Int = 0
|
||||||
|
) {
|
||||||
|
operator fun plus(other: DesktopFolderMetadataExtractionStats): DesktopFolderMetadataExtractionStats {
|
||||||
|
return DesktopFolderMetadataExtractionStats(
|
||||||
|
processedBooks = processedBooks + other.processedBooks,
|
||||||
|
updatedBooks = updatedBooks + other.updatedBooks,
|
||||||
|
coversUpdated = coversUpdated + other.coversUpdated,
|
||||||
|
failedBooks = failedBooks + other.failedBooks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object DesktopFolderMetadataExtractor {
|
||||||
|
private val textMetadataTypes = setOf(
|
||||||
|
FileType.PDF,
|
||||||
|
FileType.EPUB,
|
||||||
|
FileType.HTML,
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.FB2,
|
||||||
|
FileType.DOCX,
|
||||||
|
FileType.ODT,
|
||||||
|
FileType.FODT
|
||||||
|
)
|
||||||
|
private val generatedCoverTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
|
fun enrichFolderBooks(
|
||||||
|
books: List<BookItem>,
|
||||||
|
sourceFolder: String
|
||||||
|
): DesktopFolderMetadataExtractionResult {
|
||||||
|
return enrichBooks(books) { book -> book.sourceFolder == sourceFolder }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enrichImportedBooks(
|
||||||
|
books: List<BookItem>,
|
||||||
|
importedBookIds: Set<String>
|
||||||
|
): DesktopFolderMetadataExtractionResult {
|
||||||
|
if (importedBookIds.isEmpty()) {
|
||||||
|
return DesktopFolderMetadataExtractionResult(books)
|
||||||
|
}
|
||||||
|
return enrichBooks(books) { book -> book.id in importedBookIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enrichBooks(
|
||||||
|
books: List<BookItem>,
|
||||||
|
shouldConsider: (BookItem) -> Boolean
|
||||||
|
): DesktopFolderMetadataExtractionResult {
|
||||||
|
var stats = DesktopFolderMetadataExtractionStats()
|
||||||
|
val updatedBooks = books.map { book ->
|
||||||
|
if (!shouldConsider(book) || !book.needsFolderMetadataExtraction()) {
|
||||||
|
return@map book
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = stats.copy(processedBooks = stats.processedBooks + 1)
|
||||||
|
val updated = runCatching { enrichBook(book) }
|
||||||
|
.onFailure { stats = stats.copy(failedBooks = stats.failedBooks + 1) }
|
||||||
|
.getOrDefault(book)
|
||||||
|
|
||||||
|
if (updated != book) {
|
||||||
|
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
|
||||||
|
if (updated.coverImagePath != book.coverImagePath) {
|
||||||
|
stats = stats.copy(coversUpdated = stats.coversUpdated + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
return DesktopFolderMetadataExtractionResult(updatedBooks, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookItem.needsFolderMetadataExtraction(): Boolean {
|
||||||
|
val path = path?.takeIf { it.isNotBlank() } ?: return false
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.isFile) return false
|
||||||
|
val needsTextMetadata = type in textMetadataTypes && !folderTextMetadataParsed
|
||||||
|
val needsCover = type in generatedCoverTypes && coverImagePath?.let { File(it).isFile } != true
|
||||||
|
return needsTextMetadata || needsCover
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enrichBook(book: BookItem): BookItem {
|
||||||
|
val file = File(book.path.orEmpty())
|
||||||
|
val size = file.length().takeIf { it > 0L } ?: book.fileSize
|
||||||
|
var title = book.title
|
||||||
|
var author = book.author
|
||||||
|
var textMetadataParsed = book.folderTextMetadataParsed
|
||||||
|
var embeddedCover: EmbeddedCover? = null
|
||||||
|
|
||||||
|
when (book.type) {
|
||||||
|
FileType.EPUB -> {
|
||||||
|
val metadata = parseEpubMetadata(file)
|
||||||
|
title = sanitizeTitle(metadata.title) ?: title
|
||||||
|
author = sanitizeAuthor(metadata.author) ?: author
|
||||||
|
embeddedCover = metadata.cover
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
FileType.PDF -> {
|
||||||
|
val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull()
|
||||||
|
title = sanitizeTitle(metadata?.title) ?: title
|
||||||
|
author = sanitizeAuthor(metadata?.author) ?: author
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
FileType.HTML -> {
|
||||||
|
title = sanitizeTitle(parseHtmlTitle(file)) ?: title
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.FB2,
|
||||||
|
FileType.DOCX,
|
||||||
|
FileType.ODT,
|
||||||
|
FileType.FODT -> {
|
||||||
|
runCatching { SharedJvmBookLoader.load(file, book.type) }
|
||||||
|
.onSuccess { loaded ->
|
||||||
|
title = sanitizeTitle(loaded.title) ?: title
|
||||||
|
author = sanitizeAuthor(loaded.author) ?: author
|
||||||
|
textMetadataParsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
val coverPath = book.coverImagePath?.takeIf { File(it).isFile }
|
||||||
|
?: saveEmbeddedCover(book, embeddedCover)
|
||||||
|
?: renderReaderSurfaceCover(book, file)
|
||||||
|
?: saveGeneratedCover(book)
|
||||||
|
|
||||||
|
return book.copy(
|
||||||
|
title = title ?: file.nameWithoutExtension,
|
||||||
|
author = author,
|
||||||
|
fileSize = size,
|
||||||
|
coverImagePath = coverPath,
|
||||||
|
folderTextMetadataParsed = textMetadataParsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubMetadata(file: File): ExtractedBookMetadata {
|
||||||
|
ZipFile(file).use { zip ->
|
||||||
|
val containerXml = zip.readTextOrNull("META-INF/container.xml")
|
||||||
|
val opfPath = containerXml
|
||||||
|
?.let(::parseEpubRootfilePath)
|
||||||
|
?: zip.entries().asSequence()
|
||||||
|
.map { it.name }
|
||||||
|
.firstOrNull { it.endsWith(".opf", ignoreCase = true) }
|
||||||
|
?: return ExtractedBookMetadata()
|
||||||
|
val opf = zip.readTextOrNull(opfPath) ?: return ExtractedBookMetadata()
|
||||||
|
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
.let { if (it.isBlank()) "" else "$it/" }
|
||||||
|
val manifest = parseEpubManifest(opf)
|
||||||
|
val cover = findEpubCover(opf, manifest)
|
||||||
|
?.takeIf { it.isRasterCover }
|
||||||
|
?.let { item ->
|
||||||
|
val coverPath = normalizeZipPath(basePath + item.href)
|
||||||
|
zip.readBytesOrNull(coverPath)?.let { bytes ->
|
||||||
|
EmbeddedCover(bytes = bytes, extension = item.rasterExtension ?: "png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractedBookMetadata(
|
||||||
|
title = opf.tagText("title"),
|
||||||
|
author = opf.tagText("creator"),
|
||||||
|
cover = cover
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubRootfilePath(containerXml: String): String? {
|
||||||
|
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(containerXml)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
|
||||||
|
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(opf)
|
||||||
|
.mapNotNull { match ->
|
||||||
|
val item = match.value
|
||||||
|
val id = item.attr("id")
|
||||||
|
val href = item.attr("href")
|
||||||
|
if (id.isBlank() || href.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
EpubManifestItem(
|
||||||
|
id = id,
|
||||||
|
href = href,
|
||||||
|
mediaType = item.attr("media-type"),
|
||||||
|
properties = item.attr("properties")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
|
||||||
|
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(opf)
|
||||||
|
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.attr("content")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
return manifest.firstOrNull { it.id == coverId }
|
||||||
|
?: manifest.firstOrNull { it.properties.split(Regex("\\s+")).any { property -> property == "cover-image" } }
|
||||||
|
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("cover", ignoreCase = true) }
|
||||||
|
?: manifest.firstOrNull { it.isRasterCover && it.href.contains("front", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHtmlTitle(file: File): String? {
|
||||||
|
return runCatching {
|
||||||
|
val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
buildString {
|
||||||
|
var remaining = 64 * 1024
|
||||||
|
val buffer = CharArray(2048)
|
||||||
|
while (remaining > 0) {
|
||||||
|
val read = reader.read(buffer, 0, minOf(buffer.size, remaining))
|
||||||
|
if (read <= 0) break
|
||||||
|
append(buffer, 0, read)
|
||||||
|
remaining -= read
|
||||||
|
if (contains("</title>", ignoreCase = true)) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
head.tagText("title")
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? {
|
||||||
|
if (cover == null || cover.bytes.isEmpty()) return null
|
||||||
|
val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null
|
||||||
|
return runCatching {
|
||||||
|
deleteExistingCoverFiles(book)
|
||||||
|
val target = coverCacheFile(book, extension)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val temp = File(target.parentFile, "${target.name}.tmp")
|
||||||
|
temp.writeBytes(cover.bytes)
|
||||||
|
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
target.absolutePath
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderReaderSurfaceCover(book: BookItem, file: File): String? {
|
||||||
|
if (book.type == FileType.PDF && !DesktopPdfium.isAvailable()) return null
|
||||||
|
if (book.type != FileType.PDF && !DesktopComicArchive.canLoad(book.type)) return null
|
||||||
|
return runCatching {
|
||||||
|
val document = if (book.type == FileType.PDF) {
|
||||||
|
DesktopPdfium.load(file)
|
||||||
|
} else {
|
||||||
|
DesktopPdfium.loadComic(file, book.type)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (document.pageCount <= 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val firstPage = document.pageSizes.first()
|
||||||
|
val scale = 800f / firstPage.height.coerceAtLeast(1f)
|
||||||
|
val image = DesktopPdfium.renderPageBufferedImage(
|
||||||
|
document = document,
|
||||||
|
pageIndex = 0,
|
||||||
|
scale = scale,
|
||||||
|
renderAnnotations = false
|
||||||
|
)
|
||||||
|
saveCoverImage(book, image)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
document.close()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveGeneratedCover(book: BookItem): String? {
|
||||||
|
if (book.type !in generatedCoverTypes) return null
|
||||||
|
return saveCoverImage(book, generatedCoverImage(book))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCoverImage(book: BookItem, image: BufferedImage): String? {
|
||||||
|
return runCatching {
|
||||||
|
deleteExistingCoverFiles(book)
|
||||||
|
val target = coverCacheFile(book, "png")
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val temp = File(target.parentFile, "${target.name}.tmp")
|
||||||
|
ImageIO.write(image, "png", temp)
|
||||||
|
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
target.absolutePath
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generatedCoverImage(book: BookItem): BufferedImage {
|
||||||
|
val width = 480
|
||||||
|
val height = 720
|
||||||
|
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val base = coverColor(book.type)
|
||||||
|
val title = book.title?.takeIf { it.isNotBlank() }
|
||||||
|
?: book.displayName.substringBeforeLast('.', missingDelimiterValue = book.displayName)
|
||||||
|
val author = book.author?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val g = image.createGraphics()
|
||||||
|
try {
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||||
|
g.paint = GradientPaint(0f, 0f, base.brighter(), 0f, height.toFloat(), base.darker())
|
||||||
|
g.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
g.color = Color(255, 255, 255, 36)
|
||||||
|
g.fillRoundRect(42, 42, width - 84, height - 84, 36, 36)
|
||||||
|
g.color = Color(255, 255, 255, 210)
|
||||||
|
g.font = Font("SansSerif", Font.BOLD, 34)
|
||||||
|
g.drawString(book.type.name, 64, 104)
|
||||||
|
|
||||||
|
g.font = Font("Serif", Font.BOLD, 48)
|
||||||
|
val titleLines = wrapText(title, g.fontMetrics, width - 128, maxLines = 6)
|
||||||
|
var y = 250
|
||||||
|
titleLines.forEach { line ->
|
||||||
|
g.drawString(line, 64, y)
|
||||||
|
y += 58
|
||||||
|
}
|
||||||
|
|
||||||
|
g.font = Font("SansSerif", Font.PLAIN, 28)
|
||||||
|
val footer = author ?: book.displayName
|
||||||
|
val footerLines = wrapText(footer, g.fontMetrics, width - 128, maxLines = 2)
|
||||||
|
val footerStart = max(y + 40, height - 150)
|
||||||
|
footerLines.forEachIndexed { index, line ->
|
||||||
|
g.drawString(line, 64, footerStart + index * 34)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
g.dispose()
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrapText(text: String, metrics: java.awt.FontMetrics, maxWidth: Int, maxLines: Int): List<String> {
|
||||||
|
val words = text.replace(Regex("\\s+"), " ").trim().split(' ').filter { it.isNotBlank() }
|
||||||
|
if (words.isEmpty()) return listOf("Untitled")
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
var current = ""
|
||||||
|
|
||||||
|
for (word in words) {
|
||||||
|
val candidate = if (current.isBlank()) word else "$current $word"
|
||||||
|
if (metrics.stringWidth(candidate) <= maxWidth) {
|
||||||
|
current = candidate
|
||||||
|
} else {
|
||||||
|
if (current.isNotBlank()) lines += current
|
||||||
|
current = trimToWidth(word, metrics, maxWidth)
|
||||||
|
}
|
||||||
|
if (lines.size == maxLines) break
|
||||||
|
}
|
||||||
|
if (lines.size < maxLines && current.isNotBlank()) lines += current
|
||||||
|
return lines.take(maxLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimToWidth(text: String, metrics: java.awt.FontMetrics, maxWidth: Int): String {
|
||||||
|
if (metrics.stringWidth(text) <= maxWidth) return text
|
||||||
|
var candidate = text
|
||||||
|
while (candidate.length > 1 && metrics.stringWidth("$candidate...") > maxWidth) {
|
||||||
|
candidate = candidate.dropLast(1)
|
||||||
|
}
|
||||||
|
return "$candidate..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coverColor(type: FileType): Color {
|
||||||
|
return when (type) {
|
||||||
|
FileType.PDF -> Color(156, 65, 70)
|
||||||
|
FileType.EPUB -> Color(0, 108, 76)
|
||||||
|
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(112, 93, 73)
|
||||||
|
FileType.MD -> Color(83, 101, 120)
|
||||||
|
FileType.HTML -> Color(122, 87, 42)
|
||||||
|
FileType.TXT -> Color(74, 92, 112)
|
||||||
|
else -> Color(93, 107, 130)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coverCacheFile(book: BookItem, extension: String): File {
|
||||||
|
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
|
||||||
|
val hash = Integer.toUnsignedString(key.hashCode())
|
||||||
|
return File(coverCacheDir(), "cover_$hash.$extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteExistingCoverFiles(book: BookItem) {
|
||||||
|
val key = book.path?.takeIf { it.isNotBlank() } ?: book.id
|
||||||
|
val hash = Integer.toUnsignedString(key.hashCode())
|
||||||
|
coverCacheDir().listFiles()
|
||||||
|
?.filter { it.isFile && it.name.startsWith("cover_$hash.") }
|
||||||
|
?.forEach { runCatching { it.delete() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coverCacheDir(): File {
|
||||||
|
val overridePath = System.getProperty("reader.cover.cache.dir")
|
||||||
|
?: System.getenv("READER_COVER_CACHE_DIR")
|
||||||
|
if (!overridePath.isNullOrBlank()) {
|
||||||
|
return File(overridePath).apply { mkdirs() }
|
||||||
|
}
|
||||||
|
val root = DesktopLibraryDatabase.defaultDatabaseFile().parentFile
|
||||||
|
?: File(System.getProperty("user.home"), "AppData/Roaming/Episteme")
|
||||||
|
return File(root, "cover_cache").apply { mkdirs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.readTextOrNull(path: String): String? {
|
||||||
|
val entry = getEntry(path) ?: return null
|
||||||
|
return getInputStream(entry).bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.readBytesOrNull(path: String): ByteArray? {
|
||||||
|
val entry = getEntry(path) ?: return null
|
||||||
|
return getInputStream(entry).use { it.readBytes() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.attr(name: String): String {
|
||||||
|
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.tagText(tag: String): String {
|
||||||
|
return Regex(
|
||||||
|
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
||||||
|
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
||||||
|
)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.replace(Regex("<[^>]+>"), " ")
|
||||||
|
?.decodeEntities()
|
||||||
|
?.replace(Regex("\\s+"), " ")
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeEntities(): String {
|
||||||
|
return replace(" ", " ")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
.replace(Regex("&#(\\d+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeZipPath(path: String): String {
|
||||||
|
val parts = ArrayDeque<String>()
|
||||||
|
path.split('/').forEach { part ->
|
||||||
|
when (part) {
|
||||||
|
"", "." -> Unit
|
||||||
|
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||||
|
else -> parts.addLast(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeTitle(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeAuthor(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val EpubManifestItem.isRasterCover: Boolean
|
||||||
|
get() = rasterExtension != null
|
||||||
|
|
||||||
|
private val EpubManifestItem.rasterExtension: String?
|
||||||
|
get() {
|
||||||
|
val extension = href.substringBefore('?')
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
.lowercase()
|
||||||
|
if (extension in rasterCoverExtensions) return extension
|
||||||
|
return when {
|
||||||
|
mediaType.equals("image/jpeg", ignoreCase = true) -> "jpg"
|
||||||
|
mediaType.equals("image/png", ignoreCase = true) -> "png"
|
||||||
|
mediaType.equals("image/gif", ignoreCase = true) -> "gif"
|
||||||
|
mediaType.equals("image/webp", ignoreCase = true) -> "webp"
|
||||||
|
mediaType.equals("image/bmp", ignoreCase = true) -> "bmp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ExtractedBookMetadata(
|
||||||
|
val title: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val cover: EmbeddedCover? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class EmbeddedCover(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val extension: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class EpubManifestItem(
|
||||||
|
val id: String,
|
||||||
|
val href: String,
|
||||||
|
val mediaType: String,
|
||||||
|
val properties: String
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,732 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL
|
||||||
|
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||||
|
import com.aryan.reader.shared.ReaderTtsCacheSummary
|
||||||
|
import com.aryan.reader.shared.ReaderTtsChunk
|
||||||
|
import com.aryan.reader.shared.ReaderTtsFileCacheManager
|
||||||
|
import com.aryan.reader.shared.ReaderTtsReadScope
|
||||||
|
import com.aryan.reader.shared.TtsAdapter
|
||||||
|
import com.aryan.reader.shared.createReaderTtsWavHeaderUnknownLength
|
||||||
|
import com.aryan.reader.shared.patchReaderTtsWavHeader
|
||||||
|
import com.aryan.reader.shared.splitReaderTextIntoTtsChunks
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.WebSocket
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.CompletionStage
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.sound.sampled.AudioFormat
|
||||||
|
import javax.sound.sampled.AudioSystem
|
||||||
|
import javax.sound.sampled.SourceDataLine
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
private data class DesktopTtsSequenceChunk(
|
||||||
|
val text: String,
|
||||||
|
val chapterTitle: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
class DesktopGeminiCloudTtsAdapter(
|
||||||
|
private val settingsProvider: () -> ReaderAiByokSettings,
|
||||||
|
private val httpClient: HttpClient = HttpClient.newHttpClient(),
|
||||||
|
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
|
||||||
|
) : TtsAdapter {
|
||||||
|
@Volatile
|
||||||
|
private var activeLine: SourceDataLine? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var activeWebSocket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var activePlayer: DesktopStreamingPcmPlayer? = null
|
||||||
|
|
||||||
|
override val isAvailable: Boolean
|
||||||
|
get() = settingsProvider().sanitized().isCloudTtsAvailable
|
||||||
|
|
||||||
|
override suspend fun speak(text: String) {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
logDesktopTts("speak_start textChars=${trimmed.length}")
|
||||||
|
if (trimmed.isBlank()) return
|
||||||
|
speakSequence(splitReaderTextIntoTtsChunks(trimmed).ifEmpty { listOf(trimmed.take(5_000)) })
|
||||||
|
logDesktopTts("speak_finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun speakSequence(
|
||||||
|
texts: List<String>,
|
||||||
|
onChunkStart: suspend (Int) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val normalizedChunks = texts
|
||||||
|
.flatMap { text -> splitReaderTextIntoTtsChunks(text).ifEmpty { listOf(text.trim()) } }
|
||||||
|
.map { text -> DesktopTtsSequenceChunk(text = text.trim().take(5_000), chapterTitle = null) }
|
||||||
|
.filter { it.text.isNotBlank() }
|
||||||
|
logDesktopTts(
|
||||||
|
"sequence_speak_start chunks=${normalizedChunks.size} totalTextChars=${normalizedChunks.sumOf { it.text.length }}"
|
||||||
|
)
|
||||||
|
if (normalizedChunks.isEmpty()) return
|
||||||
|
val callbackContext = coroutineContext
|
||||||
|
stop()
|
||||||
|
streamSequence("Desktop selection", normalizedChunks, callbackContext, onChunkStart)
|
||||||
|
logDesktopTts("sequence_speak_finished chunks=${normalizedChunks.size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun speakChunks(
|
||||||
|
bookTitle: String,
|
||||||
|
readScope: ReaderTtsReadScope,
|
||||||
|
chunks: List<ReaderTtsChunk>,
|
||||||
|
onChunkStart: suspend (Int) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val sequenceChunks = chunks
|
||||||
|
.map { chunk ->
|
||||||
|
DesktopTtsSequenceChunk(
|
||||||
|
text = chunk.spokenText.trim().ifBlank { chunk.text.trim() }.take(5_000),
|
||||||
|
chapterTitle = chunk.chapterTitle.ifBlank { readScope.label }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { it.text.isNotBlank() }
|
||||||
|
logDesktopTts(
|
||||||
|
"chunk_sequence_speak_start book=\"${bookTitle.desktopTtsPreview()}\" scope=${readScope.name} " +
|
||||||
|
"chunks=${sequenceChunks.size} totalTextChars=${sequenceChunks.sumOf { it.text.length }}"
|
||||||
|
)
|
||||||
|
if (sequenceChunks.isEmpty()) return
|
||||||
|
val callbackContext = coroutineContext
|
||||||
|
stop()
|
||||||
|
streamSequence(bookTitle.ifBlank { "Untitled" }, sequenceChunks, callbackContext, onChunkStart)
|
||||||
|
logDesktopTts("chunk_sequence_speak_finished chunks=${sequenceChunks.size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pause() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
activePlayer?.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resume() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
activePlayer?.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheSummary(bookTitle: String, speakerId: String? = settingsProvider().sanitized().ttsSpeakerId): ReaderTtsCacheSummary {
|
||||||
|
return cacheManager.getCacheSummary(bookTitle.ifBlank { "Untitled" }, speakerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBookCacheForSpeaker(bookTitle: String, speakerId: String = settingsProvider().sanitized().ttsSpeakerId) {
|
||||||
|
cacheManager.clearBookCacheForSpeaker(bookTitle.ifBlank { "Untitled" }, speakerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBookCache(bookTitle: String) {
|
||||||
|
cacheManager.clearBookCache(bookTitle.ifBlank { "Untitled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stop() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
logDesktopTts("stop_requested hasWebSocket=${activeWebSocket != null} hasLine=${activeLine != null}")
|
||||||
|
runCatching { activeWebSocket?.abort() }
|
||||||
|
activeWebSocket = null
|
||||||
|
runCatching { activePlayer?.closeNow() }
|
||||||
|
activePlayer = null
|
||||||
|
runCatching { activeLine?.stop() }
|
||||||
|
runCatching { activeLine?.flush() }
|
||||||
|
runCatching { activeLine?.close() }
|
||||||
|
activeLine = null
|
||||||
|
logDesktopTts("stop_complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun streamSequence(
|
||||||
|
bookTitle: String,
|
||||||
|
chunks: List<DesktopTtsSequenceChunk>,
|
||||||
|
callbackContext: CoroutineContext,
|
||||||
|
onChunkStart: suspend (Int) -> Unit
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
val settings = settingsProvider().sanitized()
|
||||||
|
val totalTextChars = chunks.sumOf { it.text.length }
|
||||||
|
logDesktopTts(
|
||||||
|
"stream_start book=\"${bookTitle.desktopTtsPreview()}\" chunks=${chunks.size} totalTextChars=$totalTextChars keyPresent=${settings.geminiKey.isNotBlank()} " +
|
||||||
|
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " +
|
||||||
|
"available=${settings.isCloudTtsAvailable}"
|
||||||
|
)
|
||||||
|
if (!settings.isCloudTtsAvailable) {
|
||||||
|
logDesktopTts("stream_blocked reason=not_available")
|
||||||
|
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioBytesReceived = AtomicLong(0)
|
||||||
|
val currentTurnAudioBytesReceived = AtomicLong(0)
|
||||||
|
val player = DesktopStreamingPcmPlayer { activeLine = it }
|
||||||
|
activePlayer = player
|
||||||
|
val setupComplete = CompletableDeferred<Unit>()
|
||||||
|
val currentTurnComplete = AtomicReference<CompletableDeferred<Unit>?>(null)
|
||||||
|
val activeCacheOutput = AtomicReference<FileOutputStream?>(null)
|
||||||
|
val failure = CompletableDeferred<Throwable>()
|
||||||
|
val messageBuffer = StringBuilder()
|
||||||
|
var webSocket: WebSocket? = null
|
||||||
|
var activeTempCacheFile: File? = null
|
||||||
|
|
||||||
|
fun handleMessage(message: String) {
|
||||||
|
handleGeminiTtsMessage(
|
||||||
|
message = message,
|
||||||
|
setupComplete = setupComplete,
|
||||||
|
turnComplete = currentTurnComplete.get(),
|
||||||
|
failure = failure,
|
||||||
|
onAudioPart = { bytes ->
|
||||||
|
audioBytesReceived.addAndGet(bytes.size.toLong())
|
||||||
|
currentTurnAudioBytesReceived.addAndGet(bytes.size.toLong())
|
||||||
|
activeCacheOutput.get()?.let { output ->
|
||||||
|
runCatching { output.write(bytes) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopTts("cache_write_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching { player.write(bytes) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopTts("stream_audio_write_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val listener = object : WebSocket.Listener {
|
||||||
|
override fun onOpen(webSocket: WebSocket) {
|
||||||
|
activeWebSocket = webSocket
|
||||||
|
webSocket.request(1)
|
||||||
|
logDesktopTts("ws_open send_setup model=\"$GEMINI_CLOUD_TTS_MODEL\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\"")
|
||||||
|
webSocket.sendText(buildGeminiTtsSetup(settings.ttsSpeakerId), true)
|
||||||
|
.whenComplete { _, error ->
|
||||||
|
if (error != null) {
|
||||||
|
logDesktopTts("ws_setup_send_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
} else {
|
||||||
|
logDesktopTts("ws_setup_send_complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*> {
|
||||||
|
messageBuffer.append(data)
|
||||||
|
logDesktopTts("ws_message_text chunkChars=${data.length} last=$last bufferChars=${messageBuffer.length}")
|
||||||
|
if (last) {
|
||||||
|
val message = messageBuffer.toString()
|
||||||
|
messageBuffer.clear()
|
||||||
|
handleMessage(message)
|
||||||
|
}
|
||||||
|
webSocket.request(1)
|
||||||
|
return CompletableFuture.completedFuture(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBinary(webSocket: WebSocket, data: ByteBuffer, last: Boolean): CompletionStage<*> {
|
||||||
|
val bytes = ByteArray(data.remaining())
|
||||||
|
data.get(bytes)
|
||||||
|
messageBuffer.append(bytes.decodeToString())
|
||||||
|
logDesktopTts("ws_message_binary chunkBytes=${bytes.size} last=$last bufferChars=${messageBuffer.length}")
|
||||||
|
if (last) {
|
||||||
|
val message = messageBuffer.toString()
|
||||||
|
messageBuffer.clear()
|
||||||
|
handleMessage(message)
|
||||||
|
}
|
||||||
|
webSocket.request(1)
|
||||||
|
return CompletableFuture.completedFuture(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(webSocket: WebSocket, error: Throwable) {
|
||||||
|
logDesktopTts("ws_error error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
failure.complete(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String): CompletionStage<*> {
|
||||||
|
val activeTurn = currentTurnComplete.get()
|
||||||
|
logDesktopTts(
|
||||||
|
"ws_close status=$statusCode reason=\"${reason.desktopTtsPreview()}\" " +
|
||||||
|
"setupComplete=${setupComplete.isCompleted} turnComplete=${activeTurn?.isCompleted}"
|
||||||
|
)
|
||||||
|
if (!setupComplete.isCompleted && !failure.isCompleted) {
|
||||||
|
failure.complete(IllegalStateException("Cloud TTS connection closed before setup: $reason"))
|
||||||
|
} else if (activeTurn != null && !activeTurn.isCompleted && !failure.isCompleted) {
|
||||||
|
failure.complete(IllegalStateException("Cloud TTS connection closed: $reason"))
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ensureWebSocket(): WebSocket {
|
||||||
|
webSocket?.let { return it }
|
||||||
|
val encodedKey = URLEncoder.encode(settings.geminiKey, Charsets.UTF_8.name())
|
||||||
|
val uri = URI("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$encodedKey")
|
||||||
|
logDesktopTts("ws_connect_start endpoint=GeminiLive keyChars=${settings.geminiKey.length}")
|
||||||
|
val connectedWebSocket = runCatching {
|
||||||
|
httpClient.newWebSocketBuilder()
|
||||||
|
.buildAsync(uri, listener)
|
||||||
|
.get(15, TimeUnit.SECONDS)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
logDesktopTts("ws_connect_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
activeWebSocket = connectedWebSocket
|
||||||
|
webSocket = connectedWebSocket
|
||||||
|
logDesktopTts("ws_connect_complete")
|
||||||
|
|
||||||
|
logDesktopTts("setup_wait_start timeoutMs=15000")
|
||||||
|
withTimeout(15_000) {
|
||||||
|
select<Unit> {
|
||||||
|
setupComplete.onAwait { }
|
||||||
|
failure.onAwait { throw it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logDesktopTts("setup_wait_complete")
|
||||||
|
return connectedWebSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val totalChunksByChapter = chunks.groupingBy { it.chapterTitle }.eachCount()
|
||||||
|
chunks.forEach { chunk ->
|
||||||
|
cacheManager.saveTotalChunks(
|
||||||
|
bookTitle = bookTitle,
|
||||||
|
chapterTitle = chunk.chapterTitle,
|
||||||
|
totalChunks = totalChunksByChapter[chunk.chapterTitle] ?: chunks.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
chunks.forEachIndexed { index, chunk ->
|
||||||
|
val text = chunk.text
|
||||||
|
val turnComplete = CompletableDeferred<Unit>()
|
||||||
|
currentTurnAudioBytesReceived.set(0)
|
||||||
|
currentTurnComplete.set(turnComplete)
|
||||||
|
logDesktopTts("sequence_turn_start index=${index + 1}/${chunks.size} textChars=${text.length}")
|
||||||
|
withContext(callbackContext) {
|
||||||
|
onChunkStart(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cacheFile = cacheManager.getCacheFile(bookTitle, chunk.chapterTitle, text, settings.ttsSpeakerId)
|
||||||
|
if (cacheFile.exists() && cacheFile.length() > 44) {
|
||||||
|
logDesktopTts(
|
||||||
|
"cache_hit index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
|
||||||
|
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
|
||||||
|
)
|
||||||
|
val cachedBytes = playCachedWav(cacheFile, player)
|
||||||
|
currentTurnAudioBytesReceived.set(cachedBytes)
|
||||||
|
audioBytesReceived.addAndGet(cachedBytes)
|
||||||
|
logDesktopTts("cache_play_complete index=${index + 1}/${chunks.size} audioBytes=$cachedBytes")
|
||||||
|
currentTurnComplete.compareAndSet(turnComplete, null)
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
val socket = ensureWebSocket()
|
||||||
|
val tempCacheFile = File(cacheFile.absolutePath + ".tmp")
|
||||||
|
activeTempCacheFile = tempCacheFile
|
||||||
|
runCatching {
|
||||||
|
tempCacheFile.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(tempCacheFile).also { output ->
|
||||||
|
output.write(createReaderTtsWavHeaderUnknownLength(24_000))
|
||||||
|
activeCacheOutput.set(output)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
activeCacheOutput.set(null)
|
||||||
|
tempCacheFile.delete()
|
||||||
|
logDesktopTts("cache_prepare_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logDesktopTts("text_send_start index=${index + 1}/${chunks.size} textChars=${text.length}")
|
||||||
|
runCatching { socket.sendText(buildGeminiTtsTextInput(text), true).join() }
|
||||||
|
.onFailure { error ->
|
||||||
|
logDesktopTts("text_send_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
logDesktopTts("text_send_complete index=${index + 1}/${chunks.size}")
|
||||||
|
|
||||||
|
val turnTimeoutMs = (30_000L + text.length * 80L).coerceIn(60_000L, 600_000L)
|
||||||
|
logDesktopTts("turn_wait_start index=${index + 1}/${chunks.size} timeoutMs=$turnTimeoutMs")
|
||||||
|
withTimeout(turnTimeoutMs) {
|
||||||
|
select<Unit> {
|
||||||
|
turnComplete.onAwait { }
|
||||||
|
failure.onAwait { throw it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val turnAudioBytes = currentTurnAudioBytesReceived.get()
|
||||||
|
logDesktopTts(
|
||||||
|
"turn_wait_complete index=${index + 1}/${chunks.size} " +
|
||||||
|
"turnAudioBytes=$turnAudioBytes totalAudioBytes=${audioBytesReceived.get()}"
|
||||||
|
)
|
||||||
|
if (turnAudioBytes == 0L) {
|
||||||
|
logDesktopTts("stream_failed reason=empty_turn_audio index=${index + 1}/${chunks.size}")
|
||||||
|
throw IllegalStateException("Cloud TTS returned no audio for a text chunk.")
|
||||||
|
}
|
||||||
|
activeCacheOutput.getAndSet(null)?.close()
|
||||||
|
runCatching {
|
||||||
|
patchReaderTtsWavHeader(tempCacheFile, turnAudioBytes.toInt())
|
||||||
|
if (cacheFile.exists()) cacheFile.delete()
|
||||||
|
if (!tempCacheFile.renameTo(cacheFile)) {
|
||||||
|
throw IllegalStateException("Could not move temp cache file into place.")
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
logDesktopTts(
|
||||||
|
"cache_store_complete index=${index + 1}/${chunks.size} bytes=${cacheFile.length()} " +
|
||||||
|
"file=\"${cacheFile.absolutePath.desktopTtsPreview(220)}\""
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
tempCacheFile.delete()
|
||||||
|
logDesktopTts("cache_store_failed index=${index + 1}/${chunks.size} error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
}
|
||||||
|
activeTempCacheFile = null
|
||||||
|
} finally {
|
||||||
|
activeCacheOutput.getAndSet(null)?.let { output ->
|
||||||
|
runCatching { output.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentTurnComplete.compareAndSet(turnComplete, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioBytesReceived.get() == 0L) {
|
||||||
|
logDesktopTts("stream_failed reason=empty_audio")
|
||||||
|
throw IllegalStateException("Cloud TTS returned no audio.")
|
||||||
|
}
|
||||||
|
player.drainAndClose()
|
||||||
|
webSocket?.let { socket -> runCatching { socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join() } }
|
||||||
|
activeWebSocket = null
|
||||||
|
activePlayer = null
|
||||||
|
logDesktopTts("stream_complete chunks=${chunks.size} audioBytes=${audioBytesReceived.get()}")
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
currentTurnComplete.set(null)
|
||||||
|
activeCacheOutput.getAndSet(null)?.let { output -> runCatching { output.close() } }
|
||||||
|
activeTempCacheFile?.delete()
|
||||||
|
activeTempCacheFile = null
|
||||||
|
runCatching { webSocket?.abort() }
|
||||||
|
activeWebSocket = null
|
||||||
|
activePlayer = null
|
||||||
|
player.closeNow()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer): Long {
|
||||||
|
var totalBytes = 0L
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
var skipped = 0L
|
||||||
|
while (skipped < 44L) {
|
||||||
|
val next = input.skip(44L - skipped)
|
||||||
|
if (next <= 0L) break
|
||||||
|
skipped += next
|
||||||
|
}
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
while (true) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read <= 0) break
|
||||||
|
player.write(buffer.copyOf(read))
|
||||||
|
totalBytes += read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDesktopTtsCacheRoot(): File {
|
||||||
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||||
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||||
|
return File(baseDir, "Episteme/TTS_Cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGeminiTtsSetup(speakerId: String): String {
|
||||||
|
val systemPrompt = """
|
||||||
|
You are a professional audiobook narrator.
|
||||||
|
Read the exact text provided, word for word, with neutral emotion and good pacing.
|
||||||
|
Do not add conversational filler, acknowledgments, extra words, summaries, or commentary.
|
||||||
|
Skip non-verbal symbols or formatting noise that cannot be read naturally.
|
||||||
|
""".trimIndent()
|
||||||
|
return buildJsonObject {
|
||||||
|
put(
|
||||||
|
"setup",
|
||||||
|
buildJsonObject {
|
||||||
|
put("model", JsonPrimitive("models/$GEMINI_CLOUD_TTS_MODEL"))
|
||||||
|
put(
|
||||||
|
"systemInstruction",
|
||||||
|
buildJsonObject {
|
||||||
|
put("parts", buildJsonArray {
|
||||||
|
add(buildJsonObject { put("text", JsonPrimitive(systemPrompt)) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"generationConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put("responseModalities", buildJsonArray { add(JsonPrimitive("AUDIO")) })
|
||||||
|
put(
|
||||||
|
"speechConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put(
|
||||||
|
"voiceConfig",
|
||||||
|
buildJsonObject {
|
||||||
|
put(
|
||||||
|
"prebuiltVoiceConfig",
|
||||||
|
buildJsonObject { put("voiceName", JsonPrimitive(speakerId)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGeminiTtsTextInput(text: String): String {
|
||||||
|
return buildJsonObject {
|
||||||
|
put(
|
||||||
|
"realtimeInput",
|
||||||
|
buildJsonObject {
|
||||||
|
put("text", JsonPrimitive(text))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGeminiTtsMessage(
|
||||||
|
message: String,
|
||||||
|
setupComplete: CompletableDeferred<Unit>,
|
||||||
|
turnComplete: CompletableDeferred<Unit>?,
|
||||||
|
failure: CompletableDeferred<Throwable>,
|
||||||
|
onAudioPart: (ByteArray) -> Unit
|
||||||
|
) {
|
||||||
|
logDesktopTts("message_handle chars=${message.length} preview=\"${message.desktopTtsPreview()}\"")
|
||||||
|
val json = runCatching { DesktopGeminiTtsJson.parseToJsonElement(message).jsonObject }.getOrElse { error ->
|
||||||
|
logDesktopTts("message_parse_failed error=\"${error.desktopTtsSummary()}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json["error"]?.let { error ->
|
||||||
|
logDesktopTts("message_provider_error body=\"${error.toString().desktopTtsPreview(300)}\"")
|
||||||
|
failure.complete(IllegalStateException(error.toString()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (json.containsKey("setupComplete") || json.containsKey("setup_complete")) {
|
||||||
|
logDesktopTts("message_setup_complete")
|
||||||
|
setupComplete.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverContent = json.jsonObjectValue("serverContent", "server_content") ?: return
|
||||||
|
val modelTurn = serverContent.jsonObjectValue("modelTurn", "model_turn")
|
||||||
|
val parts = modelTurn?.get("parts")?.jsonArray
|
||||||
|
parts?.forEach { part ->
|
||||||
|
val inlineData = part.jsonObjectOrNull()?.jsonObjectValue("inlineData", "inline_data")
|
||||||
|
val encoded = inlineData?.get("data")?.jsonPrimitive?.contentOrNull
|
||||||
|
if (!encoded.isNullOrBlank()) {
|
||||||
|
val decoded = Base64.getMimeDecoder().decode(encoded)
|
||||||
|
onAudioPart(decoded)
|
||||||
|
logDesktopTts("message_audio_part bytes=${decoded.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (serverContent.booleanValue("turnComplete", "turn_complete")) {
|
||||||
|
logDesktopTts("message_turn_complete")
|
||||||
|
turnComplete?.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DesktopGeminiTtsJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun JsonObject.jsonObjectValue(vararg keys: String): JsonObject? {
|
||||||
|
return keys.firstNotNullOfOrNull { key -> get(key) as? JsonObject }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.booleanValue(vararg keys: String): Boolean {
|
||||||
|
return keys.any { key -> get(key)?.jsonPrimitive?.booleanOrNull == true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||||
|
return this as? JsonObject
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.upsample16BitMonoLe2x(): ByteArray {
|
||||||
|
if (size < 2) return this
|
||||||
|
val sampleCount = size / 2
|
||||||
|
val output = ByteArray(sampleCount * 4)
|
||||||
|
var outputIndex = 0
|
||||||
|
fun sampleAt(index: Int): Int {
|
||||||
|
val byteIndex = index * 2
|
||||||
|
val lo = this[byteIndex].toInt() and 0xFF
|
||||||
|
val hi = this[byteIndex + 1].toInt()
|
||||||
|
return (hi shl 8) or lo
|
||||||
|
}
|
||||||
|
fun writeSample(sample: Int) {
|
||||||
|
output[outputIndex] = (sample and 0xFF).toByte()
|
||||||
|
output[outputIndex + 1] = ((sample shr 8) and 0xFF).toByte()
|
||||||
|
outputIndex += 2
|
||||||
|
}
|
||||||
|
for (index in 0 until sampleCount) {
|
||||||
|
val current = sampleAt(index)
|
||||||
|
val next = sampleAt((index + 1).coerceAtMost(sampleCount - 1))
|
||||||
|
writeSample(current)
|
||||||
|
writeSample(((current + next) / 2).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DesktopStreamingPcmPlayer(
|
||||||
|
private val onLineChanged: (SourceDataLine?) -> Unit
|
||||||
|
) {
|
||||||
|
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||||
|
private val stateLock = java.lang.Object()
|
||||||
|
private var line: SourceDataLine? = null
|
||||||
|
private var fallbackTo48Khz = true
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
@Volatile
|
||||||
|
private var paused = false
|
||||||
|
private var bytesWritten = 0L
|
||||||
|
|
||||||
|
init {
|
||||||
|
logDesktopTts("play_stream_start mixers=\"${availableAudioMixers().desktopTtsPreview(260)}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
synchronized(stateLock) {
|
||||||
|
if (closed || paused) return
|
||||||
|
paused = true
|
||||||
|
runCatching { line?.stop() }
|
||||||
|
logDesktopTts("play_stream_paused totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume() {
|
||||||
|
synchronized(stateLock) {
|
||||||
|
if (closed || !paused) return
|
||||||
|
paused = false
|
||||||
|
runCatching { line?.start() }
|
||||||
|
stateLock.notifyAll()
|
||||||
|
logDesktopTts("play_stream_resumed totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun write(pcm24Khz: ByteArray) {
|
||||||
|
if (closed || pcm24Khz.isEmpty()) return
|
||||||
|
waitIfPaused()
|
||||||
|
val activeLine = synchronized(stateLock) {
|
||||||
|
if (closed) return
|
||||||
|
line ?: openBestLine()
|
||||||
|
}
|
||||||
|
val bytes = if (fallbackTo48Khz) pcm24Khz.upsample16BitMonoLe2x() else pcm24Khz
|
||||||
|
var offset = 0
|
||||||
|
var lineStarted = activeLine.isRunning
|
||||||
|
val primeTargetBytes = (activeLine.bufferSize / 2).coerceAtLeast(8192)
|
||||||
|
while (offset < bytes.size && !closed) {
|
||||||
|
waitIfPaused()
|
||||||
|
val maxWrite = if (lineStarted) 8192 else primeTargetBytes
|
||||||
|
val written = activeLine.write(bytes, offset, (bytes.size - offset).coerceAtMost(maxWrite))
|
||||||
|
if (written <= 0) break
|
||||||
|
offset += written
|
||||||
|
bytesWritten += written
|
||||||
|
if (!lineStarted && (offset >= bytes.size || offset >= primeTargetBytes)) {
|
||||||
|
activeLine.start()
|
||||||
|
lineStarted = true
|
||||||
|
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lineStarted && !closed) {
|
||||||
|
activeLine.start()
|
||||||
|
logDesktopTts("play_line_started_after_prime primeBytes=$offset")
|
||||||
|
}
|
||||||
|
logDesktopTts("play_stream_write inputBytes=${pcm24Khz.size} writtenBytes=$offset totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drainAndClose() {
|
||||||
|
val activeLine = line
|
||||||
|
if (activeLine != null && !closed) {
|
||||||
|
logDesktopTts("play_stream_drain totalWritten=$bytesWritten")
|
||||||
|
runCatching { activeLine.drain() }
|
||||||
|
.onFailure { error -> logDesktopTts("play_stream_drain_failed error=\"${error.desktopTtsSummary()}\"") }
|
||||||
|
}
|
||||||
|
closeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeNow() {
|
||||||
|
val activeLine = synchronized(stateLock) {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
paused = false
|
||||||
|
stateLock.notifyAll()
|
||||||
|
line.also { line = null }
|
||||||
|
}
|
||||||
|
activeLine?.let {
|
||||||
|
runCatching { it.stop() }
|
||||||
|
runCatching { it.flush() }
|
||||||
|
runCatching { it.close() }
|
||||||
|
}
|
||||||
|
onLineChanged(null)
|
||||||
|
logDesktopTts("play_stream_closed totalWritten=$bytesWritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitIfPaused() {
|
||||||
|
synchronized(stateLock) {
|
||||||
|
while (paused && !closed) {
|
||||||
|
stateLock.wait(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openBestLine(): SourceDataLine {
|
||||||
|
fallbackTo48Khz = true
|
||||||
|
return runCatching {
|
||||||
|
openLine(48_000f)
|
||||||
|
}.getOrElse { firstError ->
|
||||||
|
logDesktopTts("play_primary_failed sampleRate=48000 error=\"${firstError.desktopTtsSummary()}\"")
|
||||||
|
fallbackTo48Khz = false
|
||||||
|
runCatching {
|
||||||
|
openLine(24_000f)
|
||||||
|
}.onFailure { secondError ->
|
||||||
|
logDesktopTts("play_fallback_failed sampleRate=24000 error=\"${secondError.desktopTtsSummary()}\"")
|
||||||
|
secondError.printStackTrace()
|
||||||
|
}.getOrElse {
|
||||||
|
throw firstError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openLine(sampleRate: Float): SourceDataLine {
|
||||||
|
val format = AudioFormat(sampleRate, 16, 1, true, false)
|
||||||
|
val bufferBytes = sampleRate.toInt().coerceAtLeast(16_384)
|
||||||
|
logDesktopTts("play_line_request sampleRate=${sampleRate.toInt()} bufferBytes=$bufferBytes")
|
||||||
|
val openedLine = AudioSystem.getSourceDataLine(format)
|
||||||
|
openedLine.open(format, bufferBytes)
|
||||||
|
line = openedLine
|
||||||
|
onLineChanged(openedLine)
|
||||||
|
logDesktopTts(
|
||||||
|
"play_line_opened sampleRate=${sampleRate.toInt()} output48Khz=$fallbackTo48Khz " +
|
||||||
|
"line=\"${openedLine.lineInfo.toString().desktopTtsPreview(160)}\""
|
||||||
|
)
|
||||||
|
return openedLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun availableAudioMixers(): String {
|
||||||
|
return runCatching {
|
||||||
|
AudioSystem.getMixerInfo()
|
||||||
|
.joinToString(limit = 8, truncated = "...") { "${it.name}/${it.description}" }
|
||||||
|
.ifBlank { "none" }
|
||||||
|
}.getOrDefault("unavailable")
|
||||||
|
}
|
||||||
|
|
@ -1,66 +1,20 @@
|
||||||
package com.aryan.reader.desktop
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
import com.aryan.reader.shared.BookItem
|
import com.aryan.reader.shared.SharedLibrarySnapshot
|
||||||
import com.aryan.reader.shared.BookShelfRef
|
import com.aryan.reader.shared.SharedLibrarySnapshotJson
|
||||||
import com.aryan.reader.shared.FileType
|
|
||||||
import com.aryan.reader.shared.ShelfRecord
|
|
||||||
import com.aryan.reader.shared.Tag
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonNull
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
|
||||||
import kotlinx.serialization.json.floatOrNull
|
|
||||||
import kotlinx.serialization.json.jsonArray
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import kotlinx.serialization.json.longOrNull
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
data class DesktopLibrarySnapshot(
|
|
||||||
val books: List<BookItem> = emptyList(),
|
|
||||||
val shelfRecords: List<ShelfRecord> = emptyList(),
|
|
||||||
val shelfRefs: List<BookShelfRef> = emptyList(),
|
|
||||||
val tags: List<Tag> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
class DesktopLibraryDatabase(
|
class DesktopLibraryDatabase(
|
||||||
private val databaseFile: File = defaultDatabaseFile()
|
private val databaseFile: File = defaultDatabaseFile()
|
||||||
) {
|
) {
|
||||||
private val json = Json {
|
fun load(): SharedLibrarySnapshot {
|
||||||
prettyPrint = true
|
if (!databaseFile.exists()) return SharedLibrarySnapshot()
|
||||||
ignoreUnknownKeys = true
|
return SharedLibrarySnapshotJson.decodeOrEmpty(databaseFile.readText())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(): DesktopLibrarySnapshot {
|
fun save(snapshot: SharedLibrarySnapshot) {
|
||||||
if (!databaseFile.exists()) return DesktopLibrarySnapshot()
|
|
||||||
val root = runCatching {
|
|
||||||
json.parseToJsonElement(databaseFile.readText()).jsonObject
|
|
||||||
}.getOrNull() ?: return DesktopLibrarySnapshot()
|
|
||||||
|
|
||||||
return DesktopLibrarySnapshot(
|
|
||||||
books = root.array("books").mapNotNull { it.asBookItemOrNull() },
|
|
||||||
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
|
|
||||||
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
|
|
||||||
tags = root.array("tags").mapNotNull { it.asTagOrNull() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save(snapshot: DesktopLibrarySnapshot) {
|
|
||||||
databaseFile.parentFile?.mkdirs()
|
databaseFile.parentFile?.mkdirs()
|
||||||
val root = JsonObject(
|
databaseFile.writeText(SharedLibrarySnapshotJson.encode(snapshot))
|
||||||
mapOf(
|
|
||||||
"schemaVersion" to JsonPrimitive(1),
|
|
||||||
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
|
|
||||||
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
|
|
||||||
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
|
|
||||||
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
databaseFile.writeText(root.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -71,135 +25,3 @@ class DesktopLibraryDatabase(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonObject.array(name: String): List<JsonElement> {
|
|
||||||
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonObject.string(name: String): String? {
|
|
||||||
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
|
|
||||||
return this[name]?.jsonPrimitive?.longOrNull ?: fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonObject.float(name: String): Float? {
|
|
||||||
return this[name]?.jsonPrimitive?.floatOrNull
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonObject.double(name: String): Double? {
|
|
||||||
return this[name]?.jsonPrimitive?.doubleOrNull
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
|
|
||||||
return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
|
||||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
|
||||||
val id = obj.string("id") ?: return null
|
|
||||||
val displayName = obj.string("displayName") ?: return null
|
|
||||||
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
|
|
||||||
return BookItem(
|
|
||||||
id = id,
|
|
||||||
path = obj.string("path"),
|
|
||||||
type = type,
|
|
||||||
displayName = displayName,
|
|
||||||
timestamp = obj.long("timestamp"),
|
|
||||||
title = obj.string("title"),
|
|
||||||
author = obj.string("author"),
|
|
||||||
progressPercentage = obj.float("progressPercentage"),
|
|
||||||
isRecent = obj.boolean("isRecent", true),
|
|
||||||
fileSize = obj.long("fileSize"),
|
|
||||||
sourceFolder = obj.string("sourceFolder"),
|
|
||||||
seriesName = obj.string("seriesName"),
|
|
||||||
seriesIndex = obj.double("seriesIndex"),
|
|
||||||
tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
|
|
||||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
|
||||||
return ShelfRecord(
|
|
||||||
id = obj.string("id") ?: return null,
|
|
||||||
name = obj.string("name") ?: return null,
|
|
||||||
isSmart = obj.boolean("isSmart", false),
|
|
||||||
smartRulesJson = obj.string("smartRulesJson")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
|
|
||||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
|
||||||
return BookShelfRef(
|
|
||||||
bookId = obj.string("bookId") ?: return null,
|
|
||||||
shelfId = obj.string("shelfId") ?: return null,
|
|
||||||
addedAt = obj.long("addedAt")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement.asTagOrNull(): Tag? {
|
|
||||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
|
||||||
return Tag(
|
|
||||||
id = obj.string("id") ?: return null,
|
|
||||||
name = obj.string("name") ?: return null,
|
|
||||||
color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BookItem.toJsonObject(): JsonObject {
|
|
||||||
return JsonObject(
|
|
||||||
mapOf(
|
|
||||||
"id" to JsonPrimitive(id),
|
|
||||||
"path" to path.asJson(),
|
|
||||||
"type" to JsonPrimitive(type.name),
|
|
||||||
"displayName" to JsonPrimitive(displayName),
|
|
||||||
"timestamp" to JsonPrimitive(timestamp),
|
|
||||||
"title" to title.asJson(),
|
|
||||||
"author" to author.asJson(),
|
|
||||||
"progressPercentage" to progressPercentage.asJson(),
|
|
||||||
"isRecent" to JsonPrimitive(isRecent),
|
|
||||||
"fileSize" to JsonPrimitive(fileSize),
|
|
||||||
"sourceFolder" to sourceFolder.asJson(),
|
|
||||||
"seriesName" to seriesName.asJson(),
|
|
||||||
"seriesIndex" to seriesIndex.asJson(),
|
|
||||||
"tags" to JsonArray(tags.map { it.toJsonObject() })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ShelfRecord.toJsonObject(): JsonObject {
|
|
||||||
return JsonObject(
|
|
||||||
mapOf(
|
|
||||||
"id" to JsonPrimitive(id),
|
|
||||||
"name" to JsonPrimitive(name),
|
|
||||||
"isSmart" to JsonPrimitive(isSmart),
|
|
||||||
"smartRulesJson" to smartRulesJson.asJson()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BookShelfRef.toJsonObject(): JsonObject {
|
|
||||||
return JsonObject(
|
|
||||||
mapOf(
|
|
||||||
"bookId" to JsonPrimitive(bookId),
|
|
||||||
"shelfId" to JsonPrimitive(shelfId),
|
|
||||||
"addedAt" to JsonPrimitive(addedAt)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Tag.toJsonObject(): JsonObject {
|
|
||||||
return JsonObject(
|
|
||||||
mapOf(
|
|
||||||
"id" to JsonPrimitive(id),
|
|
||||||
"name" to JsonPrimitive(name),
|
|
||||||
"color" to color.asJson()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
|
||||||
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
|
||||||
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
|
||||||
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,573 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.BookItem
|
||||||
|
import com.aryan.reader.shared.BookShelfRef
|
||||||
|
import com.aryan.reader.shared.FileType
|
||||||
|
import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
|
||||||
|
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
import com.aryan.reader.shared.LocalFolderSyncEngine
|
||||||
|
import com.aryan.reader.shared.LocalFolderSyncStats
|
||||||
|
import com.aryan.reader.shared.ReaderPlatform
|
||||||
|
import com.aryan.reader.shared.SharedFileCapabilities
|
||||||
|
import com.aryan.reader.shared.SharedFolderBookMetadata
|
||||||
|
import com.aryan.reader.shared.SharedFolderScannedFile
|
||||||
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
|
import com.aryan.reader.shared.SyncedFolder
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
||||||
|
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
|
||||||
|
import com.aryan.reader.shared.toSharedFolderBookMetadata
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
data class DesktopLocalFolderSyncResult(
|
||||||
|
val state: SharedReaderScreenState,
|
||||||
|
val shelfRefs: List<BookShelfRef>,
|
||||||
|
val stats: LocalFolderSyncStats,
|
||||||
|
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
|
||||||
|
val idMigrations: Map<String, String> = emptyMap(),
|
||||||
|
val removedBookIds: Set<String> = emptySet(),
|
||||||
|
val failedFolders: List<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
object DesktopLocalFolderSync {
|
||||||
|
private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||||
|
|
||||||
|
fun hasSupportedFiles(folder: File): Boolean {
|
||||||
|
if (!folder.isDirectory) return false
|
||||||
|
return folder.walkTopDown()
|
||||||
|
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
|
||||||
|
.any { file ->
|
||||||
|
file.isFile &&
|
||||||
|
file.shouldSyncBookFile() &&
|
||||||
|
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
shelfRefs: List<BookShelfRef>,
|
||||||
|
targetFolder: File? = null,
|
||||||
|
nowMillis: Long = System.currentTimeMillis()
|
||||||
|
): DesktopLocalFolderSyncResult {
|
||||||
|
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
|
||||||
|
var nextState = state
|
||||||
|
var nextShelfRefs = shelfRefs
|
||||||
|
var totalStats = LocalFolderSyncStats()
|
||||||
|
var totalMetadataStats = DesktopFolderMetadataExtractionStats()
|
||||||
|
val allMigrations = linkedMapOf<String, String>()
|
||||||
|
val allRemovedBookIds = linkedSetOf<String>()
|
||||||
|
val failedFolders = mutableListOf<String>()
|
||||||
|
|
||||||
|
requestedFolders.forEach { folder ->
|
||||||
|
val root = File(folder.uriString)
|
||||||
|
if (!root.isDirectory) {
|
||||||
|
failedFolders += folder.name
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString)
|
||||||
|
val remoteMetadata = readAllMetadata(root)
|
||||||
|
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||||
|
state = nextState,
|
||||||
|
folder = folder,
|
||||||
|
files = scannedFiles,
|
||||||
|
remoteMetadata = remoteMetadata,
|
||||||
|
nowMillis = nowMillis
|
||||||
|
)
|
||||||
|
nextState = syncResult.state
|
||||||
|
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
|
||||||
|
nextShelfRefs,
|
||||||
|
syncResult.idMigrations
|
||||||
|
).filterNot { it.bookId in syncResult.removedBookIds }
|
||||||
|
allMigrations += syncResult.idMigrations
|
||||||
|
allRemovedBookIds += syncResult.removedBookIds
|
||||||
|
totalStats += syncResult.stats
|
||||||
|
|
||||||
|
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||||
|
importAnnotationSidecars(root, syncedBooks)
|
||||||
|
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
|
||||||
|
books = nextState.rawLibraryBooks,
|
||||||
|
sourceFolder = folder.uriString
|
||||||
|
)
|
||||||
|
if (metadataResult.stats.updatedBooks > 0) {
|
||||||
|
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
|
||||||
|
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||||
|
}
|
||||||
|
totalMetadataStats += metadataResult.stats
|
||||||
|
syncedBooks.forEach { book ->
|
||||||
|
saveBookMetadata(book)
|
||||||
|
savePdfAnnotationSidecar(book)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DesktopLocalFolderSyncResult(
|
||||||
|
state = nextState,
|
||||||
|
shelfRefs = nextShelfRefs,
|
||||||
|
stats = totalStats,
|
||||||
|
metadataStats = totalMetadataStats,
|
||||||
|
idMigrations = allMigrations,
|
||||||
|
removedBookIds = allRemovedBookIds,
|
||||||
|
failedFolders = failedFolders
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBookSidecars(book: BookItem) {
|
||||||
|
saveBookMetadata(book)
|
||||||
|
savePdfAnnotationSidecar(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBookMetadata(book: BookItem) {
|
||||||
|
val metadata = book.toSharedFolderBookMetadata() ?: return
|
||||||
|
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
|
||||||
|
saveMetadataToFolder(root, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePdfAnnotationSidecar(book: BookItem) {
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
if (book.type != FileType.PDF) return
|
||||||
|
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
|
||||||
|
val annotationFile = desktopPdfAnnotationFile(path)
|
||||||
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||||
|
val richTextFile = desktopPdfRichTextFile(path)
|
||||||
|
val data = buildMap {
|
||||||
|
if (annotationFile.isFile) {
|
||||||
|
val annotationJson = annotationFile.readText().trim()
|
||||||
|
val annotations = SharedPdfAnnotationSerializer.decode(annotationJson)
|
||||||
|
put(
|
||||||
|
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
|
||||||
|
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (bookmarkFile.isFile) {
|
||||||
|
val bookmarksJson = bookmarkFile.readText().trim()
|
||||||
|
desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) }
|
||||||
|
}
|
||||||
|
if (richTextFile.isFile) {
|
||||||
|
val richTextJson = richTextFile.readText().trim()
|
||||||
|
val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson)
|
||||||
|
if (richTextElement == null) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.exportRichTextParseFailed book=${book.id} " +
|
||||||
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement)
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.exportRichText book=${book.id} " +
|
||||||
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
|
||||||
|
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
|
||||||
|
)
|
||||||
|
put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val timestamp = maxOf(
|
||||||
|
annotationFile.lastModifiedIfFile(),
|
||||||
|
bookmarkFile.lastModifiedIfFile(),
|
||||||
|
richTextFile.lastModifiedIfFile(),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
val dataJson = desktopFolderSyncJson.encodeToString(
|
||||||
|
JsonElement.serializer(),
|
||||||
|
JsonObject(data)
|
||||||
|
)
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
|
||||||
|
"keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
saveAnnotationSidecar(
|
||||||
|
root = root,
|
||||||
|
bookId = book.id,
|
||||||
|
jsonPayload = dataJson,
|
||||||
|
timestamp = timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun foldersToSync(
|
||||||
|
state: SharedReaderScreenState,
|
||||||
|
targetFolder: File?,
|
||||||
|
nowMillis: Long
|
||||||
|
): List<SyncedFolder> {
|
||||||
|
if (targetFolder == null) return state.syncedFolders
|
||||||
|
val root = targetFolder.canonicalOrAbsolute()
|
||||||
|
val rootPath = root.absolutePath
|
||||||
|
val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root }
|
||||||
|
return listOf(
|
||||||
|
existing ?: SyncedFolder(
|
||||||
|
uriString = rootPath,
|
||||||
|
name = root.name.takeIf { it.isNotBlank() } ?: rootPath,
|
||||||
|
lastScanTime = nowMillis,
|
||||||
|
allowedFileTypes = desktopSyncableTypes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanFolder(root: File, sourceFolder: String): List<SharedFolderScannedFile> {
|
||||||
|
val rootPath = root.toPath().toAbsolutePath().normalize()
|
||||||
|
return root.walkTopDown()
|
||||||
|
.onEnter { it == root || it.shouldEnterSyncedFolder() }
|
||||||
|
.filter { it.isFile && it.shouldSyncBookFile() }
|
||||||
|
.mapNotNull { file ->
|
||||||
|
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||||
|
.takeIf { it in desktopSyncableTypes }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val relativePath = runCatching {
|
||||||
|
rootPath.relativize(file.toPath().toAbsolutePath().normalize())
|
||||||
|
.joinToString("/")
|
||||||
|
}.getOrNull() ?: file.name
|
||||||
|
SharedFolderScannedFile(
|
||||||
|
name = file.name,
|
||||||
|
path = file.absolutePath,
|
||||||
|
sourceFolder = sourceFolder,
|
||||||
|
relativePath = relativePath,
|
||||||
|
type = type,
|
||||||
|
size = file.length(),
|
||||||
|
lastModified = file.lastModified()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||||
|
if (!syncDir.isDirectory) return emptyMap()
|
||||||
|
return syncDir.listFiles().orEmpty()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isFile }
|
||||||
|
.mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } }
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
.mapNotNull { (bookId, files) ->
|
||||||
|
val best = files
|
||||||
|
.mapNotNull { file ->
|
||||||
|
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
|
||||||
|
}
|
||||||
|
.filter { it.bookId == bookId }
|
||||||
|
.maxByOrNull { it.lastModifiedTimestamp }
|
||||||
|
best?.let { bookId to it }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||||
|
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
|
||||||
|
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) return
|
||||||
|
|
||||||
|
val target = File(syncDir, ".${metadata.bookId}.json")
|
||||||
|
val temp = File(syncDir, ".${metadata.bookId}.tmp")
|
||||||
|
runCatching {
|
||||||
|
temp.writeText(metadata.toJsonString())
|
||||||
|
moveReplacing(temp, target)
|
||||||
|
}.onFailure {
|
||||||
|
runCatching { temp.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveMetadataConflicts(
|
||||||
|
syncDir: File,
|
||||||
|
bookId: String,
|
||||||
|
cleanup: Boolean
|
||||||
|
): SharedFolderBookMetadata? {
|
||||||
|
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
||||||
|
val normalized = file.name.removePrefix(".")
|
||||||
|
file.isFile && (
|
||||||
|
normalized == "$bookId.json" ||
|
||||||
|
normalized.startsWith("$bookId.sync-conflict") ||
|
||||||
|
normalized.startsWith("$bookId.json.sync-conflict")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty()) return null
|
||||||
|
|
||||||
|
val parsed = candidates.mapNotNull { file ->
|
||||||
|
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
|
||||||
|
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
|
||||||
|
}
|
||||||
|
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
|
||||||
|
|
||||||
|
if (cleanup) {
|
||||||
|
candidates
|
||||||
|
.filterNot { it == winner.first }
|
||||||
|
.forEach { runCatching { it.delete() } }
|
||||||
|
val correctName = ".${bookId}.json"
|
||||||
|
if (winner.first.name != correctName) {
|
||||||
|
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner.second
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||||
|
if (!syncDir.isDirectory) return emptyMap()
|
||||||
|
return syncDir.listFiles().orEmpty()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isFile }
|
||||||
|
.mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } }
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
.mapNotNull { (bookId, files) ->
|
||||||
|
val best = files
|
||||||
|
.mapNotNull { it.readAnnotationSidecarOrNull() }
|
||||||
|
.maxByOrNull { it.timestamp }
|
||||||
|
best?.let { bookId to it }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
|
||||||
|
if (books.isEmpty()) return
|
||||||
|
val sidecars = preloadAnnotationSidecars(root)
|
||||||
|
if (sidecars.isEmpty()) return
|
||||||
|
|
||||||
|
books.forEach { book ->
|
||||||
|
val path = book.path?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||||
|
if (book.type != FileType.PDF) return@forEach
|
||||||
|
val sidecar = sidecars[book.id] ?: return@forEach
|
||||||
|
val annotationFile = desktopPdfAnnotationFile(path)
|
||||||
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||||
|
val richTextFile = desktopPdfRichTextFile(path)
|
||||||
|
val localTimestamp = maxOf(
|
||||||
|
annotationFile.lastModifiedIfFile(),
|
||||||
|
bookmarkFile.lastModifiedIfFile(),
|
||||||
|
richTextFile.lastModifiedIfFile()
|
||||||
|
)
|
||||||
|
if (sidecar.timestamp <= localTimestamp + 1000L) {
|
||||||
|
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
|
||||||
|
"localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " +
|
||||||
|
"richFile=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (sidecar.data.hasPdfAnnotationPayload()) {
|
||||||
|
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
|
||||||
|
annotationFile.parentFile?.mkdirs()
|
||||||
|
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||||
|
annotationFile.setLastModified(sidecar.timestamp)
|
||||||
|
}
|
||||||
|
sidecar.data["bookmarks"]?.let { bookmarks ->
|
||||||
|
bookmarkFile.parentFile?.mkdirs()
|
||||||
|
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
|
||||||
|
bookmarkFile.setLastModified(sidecar.timestamp)
|
||||||
|
}
|
||||||
|
sidecar.data["text"]?.let { richText ->
|
||||||
|
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " +
|
||||||
|
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
|
||||||
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
richTextFile.parentFile?.mkdirs()
|
||||||
|
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||||
|
richTextFile.setLastModified(sidecar.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAnnotationSidecar(
|
||||||
|
root: File,
|
||||||
|
bookId: String,
|
||||||
|
jsonPayload: String,
|
||||||
|
timestamp: Long
|
||||||
|
) {
|
||||||
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||||
|
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return
|
||||||
|
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
|
||||||
|
if (existing != null && existing.timestamp >= timestamp) {
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
|
||||||
|
"candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val wrapper = JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"version" to JsonPrimitive(1),
|
||||||
|
"timestamp" to JsonPrimitive(timestamp),
|
||||||
|
"data" to data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json")
|
||||||
|
val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp")
|
||||||
|
runCatching {
|
||||||
|
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
|
||||||
|
moveReplacing(temp, target)
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
|
||||||
|
"target=\"${target.absolutePath.richSyncPreview()}\""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
if (data.containsKey("text")) {
|
||||||
|
SharedPdfRichTextLog.d(
|
||||||
|
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
|
||||||
|
"target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
runCatching { temp.delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveAnnotationConflicts(
|
||||||
|
syncDir: File,
|
||||||
|
bookId: String,
|
||||||
|
cleanup: Boolean
|
||||||
|
): AnnotationSidecar? {
|
||||||
|
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
||||||
|
file.isFile && file.annotationBookIdOrNull() == bookId
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty()) return null
|
||||||
|
val parsed = candidates.mapNotNull { file ->
|
||||||
|
file.readAnnotationSidecarOrNull()?.let { file to it }
|
||||||
|
}
|
||||||
|
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
|
||||||
|
|
||||||
|
if (cleanup) {
|
||||||
|
candidates
|
||||||
|
.filterNot { it == winner.first }
|
||||||
|
.forEach { runCatching { it.delete() } }
|
||||||
|
val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json"
|
||||||
|
if (winner.first.name != correctName) {
|
||||||
|
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AnnotationSidecar(
|
||||||
|
val timestamp: Long,
|
||||||
|
val data: JsonObject
|
||||||
|
)
|
||||||
|
|
||||||
|
private val desktopFolderSyncJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.shouldEnterSyncedFolder(): Boolean {
|
||||||
|
if (!isDirectory) return false
|
||||||
|
if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false
|
||||||
|
if (name.startsWith(".")) return false
|
||||||
|
return runCatching { !isHidden }.getOrDefault(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.shouldSyncBookFile(): Boolean {
|
||||||
|
if (name.startsWith(".")) return false
|
||||||
|
if (extension.equals("json", ignoreCase = true)) return false
|
||||||
|
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.metadataBookIdOrNull(): String? {
|
||||||
|
val fileName = name
|
||||||
|
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
|
||||||
|
if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null
|
||||||
|
if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null
|
||||||
|
val normalized = fileName.removePrefix(".")
|
||||||
|
val base = if (normalized.contains(".sync-conflict")) {
|
||||||
|
normalized.substringBefore(".sync-conflict")
|
||||||
|
} else {
|
||||||
|
normalized.substringBeforeLast(".json")
|
||||||
|
}
|
||||||
|
return base.removeSuffix(".json").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.annotationBookIdOrNull(): String? {
|
||||||
|
var candidate = name
|
||||||
|
if (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
|
||||||
|
if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null
|
||||||
|
if (candidate.contains(".syncthing.")) return null
|
||||||
|
if (candidate.contains(".sync-conflict")) {
|
||||||
|
candidate = candidate.substringBefore(".sync-conflict")
|
||||||
|
}
|
||||||
|
candidate = candidate.substringBeforeLast(".json")
|
||||||
|
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
|
||||||
|
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
|
||||||
|
}
|
||||||
|
return candidate.removePrefix(".").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? {
|
||||||
|
return runCatching {
|
||||||
|
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
|
||||||
|
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||||
|
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
|
||||||
|
AnnotationSidecar(timestamp = timestamp, data = data)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Json.parseElementOrNull(raw: String): JsonElement? {
|
||||||
|
return runCatching { parseToJsonElement(raw) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||||
|
if (this is JsonNull) return null
|
||||||
|
return runCatching { jsonObject }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
|
||||||
|
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
|
||||||
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.canonicalOrAbsolute(): File {
|
||||||
|
return runCatching { canonicalFile }.getOrElse { absoluteFile }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.lastModifiedIfFile(): Long {
|
||||||
|
return if (isFile) lastModified() else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.richSyncPreview(maxLength: Int = 160): String {
|
||||||
|
return replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveReplacing(source: File, target: File) {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
try {
|
||||||
|
Files.move(
|
||||||
|
source.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE
|
||||||
|
)
|
||||||
|
} catch (_: AtomicMoveNotSupportedException) {
|
||||||
|
Files.move(
|
||||||
|
source.toPath(),
|
||||||
|
target.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue