From a4605f10fb3ad8b8585eda32d62874d7d5396aad Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:15:08 -0500 Subject: [PATCH 1/9] feat: shape-aware manga translation and vertical CJK layout --- .../kotatsu/reader/translate/BubbleFinder.kt | 16 ++- .../translate/TranslatedPageRenderer.kt | 107 +++++++++++++++--- .../reader/translate/TranslationLayout.kt | 15 +++ .../reader/translate/BubbleFinderTest.kt | 15 +++ .../reader/translate/TranslationLayoutTest.kt | 14 +++ docs/WORK_STATUS.md | 6 + 6 files changed, 153 insertions(+), 20 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinder.kt index 37f28b4801..a37091917a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinder.kt @@ -9,6 +9,12 @@ internal object BubbleFinder { /** Sum of per-channel differences below which two colours count as the same paper. */ const val DEFAULT_TOLERANCE = 72 + /** The actual connected bubble interior: unlike its bounds, this mask excludes panel art and bubble corners. */ + data class Region(val bounds: TranslationLayout.Box, val width: Int, val height: Int, val mask: BooleanArray) { + fun contains(x: Int, y: Int): Boolean = + x in 0 until width && y in 0 until height && mask[y * width + x] + } + private fun close(a: Int, b: Int, tolerance: Int): Boolean { val dr = ((a shr 16) and 0xFF) - ((b shr 16) and 0xFF) val dg = ((a shr 8) and 0xFF) - ((b shr 8) and 0xFF) @@ -25,7 +31,13 @@ internal object BubbleFinder { fun interiorBounds( pixels: IntArray, width: Int, height: Int, seeds: List>, paper: Int, tolerance: Int = DEFAULT_TOLERANCE, - ): TranslationLayout.Box? { + ): TranslationLayout.Box? = interior(pixels, width, height, seeds, paper, tolerance)?.bounds + + /** Preserve a pixel-accurate interior so filling never paints over the outline or artwork in a corner. */ + fun interior( + pixels: IntArray, width: Int, height: Int, seeds: List>, paper: Int, + tolerance: Int = DEFAULT_TOLERANCE, + ): Region? { if (width <= 0 || height <= 0 || pixels.size < width * height) return null val visited = BooleanArray(width * height) val queue = IntArray(width * height) @@ -59,7 +71,7 @@ internal object BubbleFinder { } val touching = listOf(minX == 0, maxX == width - 1, minY == 0, maxY == height - 1).count { it } if (touching >= 2) return null - return TranslationLayout.Box(minX, minY, maxX + 1, maxY + 1) + return Region(TranslationLayout.Box(minX, minY, maxX + 1, maxY + 1), width, height, visited) } /** Seeds around [box] (window coordinates): the middle and corners of each side, just outside its edge. */ diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt index ec1f19bdcd..cd04f24843 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt @@ -3,13 +3,17 @@ package org.koitharu.kotatsu.reader.translate import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint +import android.graphics.Path import android.graphics.RectF import android.graphics.Typeface import android.text.Layout import android.text.StaticLayout +import android.text.TextDirectionHeuristics import android.text.TextPaint import kotlin.math.max import kotlin.math.roundToInt +import java.text.BreakIterator +import java.util.Locale /** Paints over the original text of each block and draws its translation in the freed space. */ internal object TranslatedPageRenderer { @@ -20,9 +24,11 @@ internal object TranslatedPageRenderer { private const val SMOOTH_FILL_SPREAD = 28 fun render(source: Bitmap, meta: TranslationMeta): Bitmap = - render(source, meta.blocks.map { Block(it.box, it.translation) }) + render(source, meta.blocks.map { Block(it.box, it.translation, meta.targetLanguage) }) - class Block(val box: TranslationLayout.Box, val translation: String) + class Block(val box: TranslationLayout.Box, val translation: String, val targetLanguage: String? = null) + + private data class BubbleArea(val content: TranslationLayout.Box, val outline: Path) fun render(source: Bitmap, blocks: List): Bitmap { val out = source.copy(Bitmap.Config.ARGB_8888, true) @@ -49,14 +55,14 @@ internal object TranslatedPageRenderer { paint(covered) // A closed speech bubble around the text lets the translation use the whole bubble, not just the old text's footprint. val bubble = findBubble(source, covered, background, pad) - val area = if (bubble != null) TranslationLayout.union(covered, bubble) else covered - val widened = TranslationLayout.widenForHorizontalText(area, width, height) - val target = growIfNeeded(block.translation, widened, pad, height, textPaint) - if (target != covered) { - paint(target) + // Never erase the bounding rectangle of an irregular bubble: this covers its outline and panel art. + val target = if (bubble != null) bubble.content else { + val widened = TranslationLayout.widenForHorizontalText(covered, width, height) + growIfNeeded(block.translation, widened, pad, height, textPaint) } + if (bubble == null && target != covered) paint(target) textPaint.color = TranslationLayout.contrastingTextColor(background) - drawFittedText(canvas, block.translation, target, pad, textPaint) + drawFittedText(canvas, block.translation, target, pad, textPaint, bubble?.outline, block.targetLanguage) } return out } @@ -68,27 +74,80 @@ internal object TranslatedPageRenderer { return TranslationLayout.growToFit(box, needed, imageHeight) } - private fun drawFittedText(canvas: Canvas, text: String, box: TranslationLayout.Box, pad: Int, paint: TextPaint) { + private fun drawFittedText(canvas: Canvas, text: String, box: TranslationLayout.Box, pad: Int, paint: TextPaint, outline: Path? = null, language: String? = null) { + if (TranslationLayout.prefersVerticalText(language, text, box) && drawVerticalText(canvas, text, box, pad, paint, outline, language!!)) return val innerWidth = (box.width - pad * 2).coerceAtLeast(1) val innerHeight = (box.height - pad * 2).coerceAtLeast(1) val maxSize = (innerHeight * 0.45f).coerceIn(10f, 64f) val minSize = MIN_TEXT_SIZE.coerceAtMost(maxSize) val size = TranslationLayout.fitTextSize(minSize, maxSize, innerHeight) { candidate -> paint.textSize = candidate - layout(text, paint, innerWidth).height + layout(text, paint, innerWidth, language).height } paint.textSize = size - val layout = layout(text, paint, innerWidth) + val layout = layout(text, paint, innerWidth, language) canvas.save() canvas.clipRect(box.left, box.top, box.right, box.bottom) + if (outline != null) canvas.clipPath(outline) val top = box.top + pad + ((innerHeight - layout.height) / 2f).coerceAtLeast(0f) canvas.translate((box.left + pad).toFloat(), top) layout.draw(canvas) canvas.restore() } - private fun layout(text: String, paint: TextPaint, width: Int): StaticLayout = + /** Vertical CJK columns, right-to-left; grapheme boundaries keep combining marks with their glyph. */ + private fun drawVerticalText( + canvas: Canvas, text: String, box: TranslationLayout.Box, pad: Int, + paint: TextPaint, outline: Path?, language: String, + ): Boolean { + val iterator = BreakIterator.getCharacterInstance(Locale.forLanguageTag(language)) + iterator.setText(text) + val glyphs = ArrayList() + var start = iterator.first() + var end = iterator.next() + while (end != BreakIterator.DONE) { + val glyph = text.substring(start, end) + if (glyph.isNotBlank()) glyphs += glyph + start = end + end = iterator.next() + } + if (glyphs.isEmpty()) return false + val innerWidth = box.width - pad * 2 + val innerHeight = box.height - pad * 2 + if (innerWidth <= 0 || innerHeight <= 0) return false + val selected = (48 downTo MIN_TEXT_SIZE.toInt()).firstNotNullOfOrNull { candidate -> + val step = candidate * 1.2f + val rows = (innerHeight / step).toInt() + if (rows < 1) return@firstNotNullOfOrNull null + val columns = (glyphs.size + rows - 1) / rows + if (columns * step <= innerWidth) Triple(candidate.toFloat(), rows, columns) else null + } ?: return false + val (size, rows, columns) = selected + val step = size * 1.2f + val oldTypeface = paint.typeface + val oldAlign = paint.textAlign + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textAlign = Paint.Align.CENTER + paint.textSize = size + val xRight = box.left + pad + (innerWidth + columns * step) / 2f - step / 2f + val yTop = box.top + pad + (innerHeight - minOf(rows, glyphs.size) * step) / 2f + canvas.save() + canvas.clipRect(box.left, box.top, box.right, box.bottom) + if (outline != null) canvas.clipPath(outline) + glyphs.forEachIndexed { i, glyph -> + val column = i / rows + val row = i % rows + canvas.drawText(glyph, xRight - column * step, yTop + (row + 0.82f) * step, paint) + } + canvas.restore() + paint.typeface = oldTypeface + paint.textAlign = oldAlign + return true + } + + private fun layout(text: String, paint: TextPaint, width: Int, language: String? = null): StaticLayout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width) + .setTextDirection(if (TranslationLayout.isRtlLanguage(language)) TextDirectionHeuristics.FIRSTSTRONG_RTL else TextDirectionHeuristics.FIRSTSTRONG_LTR) .setAlignment(Layout.Alignment.ALIGN_CENTER) .setLineSpacing(0f, 1.0f) .setIncludePad(false) @@ -97,7 +156,7 @@ internal object TranslatedPageRenderer { .build() /** The bubble interior around [covered], shrunk a little off its outline, or null when none can be told apart. */ - private fun findBubble(source: Bitmap, covered: TranslationLayout.Box, paper: Int, pad: Int): TranslationLayout.Box? { + private fun findBubble(source: Bitmap, covered: TranslationLayout.Box, paper: Int, pad: Int): BubbleArea? { val reach = (max(covered.width, covered.height) / 2).coerceAtLeast(pad * 4) val window = TranslationLayout.inflate(covered, reach, source.width, source.height) val w = window.width @@ -106,12 +165,24 @@ internal object TranslatedPageRenderer { val pixels = IntArray(w * h) source.getPixels(pixels, 0, w, window.left, window.top, w, h) val local = TranslationLayout.Box(covered.left - window.left, covered.top - window.top, covered.right - window.left, covered.bottom - window.top) - val found = BubbleFinder.interiorBounds(pixels, w, h, BubbleFinder.seedsAround(local, 2), paper) ?: return null + val found = BubbleFinder.interior(pixels, w, h, BubbleFinder.seedsAround(local, 2), paper) ?: return null val inner = TranslationLayout.shrink( - TranslationLayout.Box(found.left + window.left, found.top + window.top, found.right + window.left, found.bottom + window.top), - pad * 2, - ) - return inner.takeIf { it.width > covered.width && it.height > 0 } + TranslationLayout.Box(found.bounds.left + window.left, found.bounds.top + window.top, + found.bounds.right + window.left, found.bounds.bottom + window.top), pad * 2) + if (inner.width <= covered.width || inner.height <= 0) return null + // Every scanline traces the connected paper region, not the enclosing rectangular OCR region. + val outline = Path() + for (y in found.bounds.top until found.bounds.bottom) { + var first = w + var last = -1 + for (x in found.bounds.left until found.bounds.right) { + if (found.contains(x, y)) { first = minOf(first, x); last = x } + } + if (last >= first) outline.addRect( + (window.left + first + 1).toFloat(), (window.top + y).toFloat(), + (window.left + last).toFloat(), (window.top + y + 1).toFloat(), Path.Direction.CW) + } + return BubbleArea(inner, outline) } /** Smooth fill from the edge colours [left, right, top, bottom] (see [sampleEdges]). */ diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayout.kt index 909eb25372..6300dc16df 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayout.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayout.kt @@ -137,6 +137,21 @@ internal object TranslationLayout { return lo } + /** Use traditional right-to-left vertical columns only for CJK-target text in a tall bubble. */ + fun prefersVerticalText(language: String?, text: String, box: Box): Boolean { + val tag = language?.substringBefore('-')?.lowercase() + if (tag != "ja" && tag != "zh") return false + if (box.width <= 0 || box.height < box.width * 1.3f) return false + val visible = text.count { !it.isWhitespace() } + if (visible == 0) return false + val cjk = text.count { it in '\u3000'..'\u9fff' || it in '\uf900'..'\ufaff' } + return cjk * 2 >= visible + } + + /** Unicode RTL-script handling without forcing Latin/numeric titles into RTL. */ + fun isRtlLanguage(language: String?): Boolean = + language?.substringBefore('-')?.lowercase() in setOf("ar", "he", "fa", "ur", "ps", "sd") + /** An OCR region with its text, used for bubble grouping. */ data class Piece(val box: Box, val text: String, val confidence: Float = -1f) diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinderTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinderTest.kt index 6386ba5771..f2b7e8cdec 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinderTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/BubbleFinderTest.kt @@ -42,6 +42,21 @@ class BubbleFinderTest { assertNull(BubbleFinder.interiorBounds(IntArray(3), 40, 40, listOf(5 to 5), white)) } + @Test + fun irregularBubbleMaskLeavesCornersAndArtworkOutside() { + val pixels = IntArray(40 * 40) { ink } + // Diamond-shaped paper area. Its bounding box includes plenty of ink outside the bubble. + for (y in 8..32) for (x in 8..32) { + if (kotlin.math.abs(x - 20) + kotlin.math.abs(y - 20) <= 12) pixels[y * 40 + x] = white + } + val region = BubbleFinder.interior(pixels, 40, 40, listOf(20 to 20), white) + assertNotNull(region) + assertEquals(Box(8, 8, 33, 33), region!!.bounds) + assertEquals(true, region.contains(20, 20)) + assertEquals(false, region.contains(8, 8)) + assertEquals(false, region.contains(30, 30)) + } + @Test fun tinyColourDriftIsStillPaper() { val p = bubbleWindow() diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayoutTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayoutTest.kt index a701e867e2..82728e9dbf 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayoutTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationLayoutTest.kt @@ -47,6 +47,20 @@ class TranslationLayoutTest { assertFalse(TranslationLayout.isMeaningful("12 ... !!")) } + @Test + fun cjkVerticalLayoutAndRtlAreScriptAware() { + val tall = Box(0, 0, 50, 200) + val wide = Box(0, 0, 200, 50) + assertTrue(TranslationLayout.prefersVerticalText("ja", "こんにちは世界", tall)) + assertTrue(TranslationLayout.prefersVerticalText("zh-CN", "你好世界", tall)) + assertFalse(TranslationLayout.prefersVerticalText("ja", "Hello there", tall)) + assertFalse(TranslationLayout.prefersVerticalText("ja", "こんにちは", wide)) + assertFalse(TranslationLayout.prefersVerticalText("en", "Hello", tall)) + assertTrue(TranslationLayout.isRtlLanguage("ar-SA")) + assertTrue(TranslationLayout.isRtlLanguage("he")) + assertFalse(TranslationLayout.isRtlLanguage("ja")) + } + @Test fun colours() { assertEquals(0xFF111111.toInt(), TranslationLayout.contrastingTextColor(0xFFFFFFFF.toInt())) diff --git a/docs/WORK_STATUS.md b/docs/WORK_STATUS.md index 1f0f5a36b1..470184e5be 100644 --- a/docs/WORK_STATUS.md +++ b/docs/WORK_STATUS.md @@ -60,3 +60,9 @@ - Scope: true search-and-details dual-pane at >=840dp; improve tablet library, book catalog, portrait/landscape details, keyboard usability and large-screen insets. - Start head: 1cc79aad8 on ccr-94e2bfd8-3w4etd. Preserve data, apps and source IDs. Do not install SDK locally. - Next: inspect layouts, add tested width policy, implement and push via Tunnel to Surface; confirm CI. + +## 2026-10-15 - Remaining 14 Android items, isolated finishing worktree +- Source: published devel `eaae55c1fcfe9972809e95974396759fd8968f0f`, 69 DONE, 14 PARTIAL. PR list empty at initial check; latest build-check still running. +- Working branch: `finish-83-20261015`; new isolated worktree `C:/Users/coolc/Monogatari-Finish83-20261015` clean at creation. Original dirty P: worktree and older audit worktrees untouched. +- Active operation: implement and test outstanding OCR bubble geometry and translated text fitting (#21/#23), then additional feasible roadmap gaps; verify GitHub-hosted CI before any completion claims. +- No SDK/JDK/emulator installation on owner devices. All pushes, PRs, merges, dispatches and release changes through Tunnel to Surface. Do not release v1.0.0 until all items validated. From 612f4f30def4feb547cfd3ba9d979bbd8e901544 Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:28:20 -0500 Subject: [PATCH 2/9] feat: richer catalog metadata and rotation-safe adaptive searches --- .../kotatsu/core/media/BookAccessMetadata.kt | 12 ++++++++++ .../kotatsu/core/media/BookLibraryActivity.kt | 23 ++++++++++++++----- .../kotatsu/core/media/PublicBookCatalog.kt | 10 +++++++- .../core/media/UnifiedLibraryActivity.kt | 6 ++++- .../kotatsu/main/ui/NavLayoutPolicy.kt | 2 +- .../search/ui/multi/SearchViewModel.kt | 16 +++++++++---- .../res/layout-w600dp/activity_search.xml | 21 +++++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ .../core/media/BookAccessMetadataTest.kt | 16 +++++++++++++ .../kotatsu/main/ui/NavLayoutPolicyTest.kt | 5 ++-- 10 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadata.kt create mode 100644 app/src/main/res/layout-w600dp/activity_search.xml create mode 100644 app/src/test/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadataTest.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadata.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadata.kt new file mode 100644 index 0000000000..c42000624d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadata.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.media + +/** Official catalog ebook-access codes are advisory; they do not guarantee an available loan. */ +internal object BookAccessMetadata { + fun openLibraryAccess(code: String?): String? = when (code?.lowercase()) { + "public" -> "Public online text" + "borrowable" -> "Borrowing may be required" + "printdisabled" -> "Restricted accessible edition" + "no_ebook" -> "No digital copy listed" + else -> null + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt index 3d76643394..e6f5a0e831 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt @@ -405,6 +405,9 @@ class BookLibraryActivity : AppCompatActivity() { group.entries.mapNotNull { it.publicationYear }.minOrNull()?.toString(), book.language?.takeIf { it.isNotBlank() }, book.format?.takeIf { it.isNotBlank() }, + book.series?.let { getString(R.string.monogatari_catalog_series, it) }, + book.editionCount?.let { getString(R.string.monogatari_catalog_editions, it) }, + book.availability, book.downloadUrl?.let { "EPUB available" }, ).joinToString(" · ") CatalogRow( @@ -467,6 +470,7 @@ class BookLibraryActivity : AppCompatActivity() { private fun openBookListing(book: PublicBookCatalog.Book) { if (book.catalog == "Standard Ebooks") { MaterialAlertDialogBuilder(this).setTitle(book.title) + .setMessage(bookMetadataSummary(book)) .setItems(arrayOf("View official listing", "Open compatible EPUB")) { _, which -> if (which == 1) openStandardEpub(book) else openOfficialLink(book.url) } @@ -474,16 +478,23 @@ class BookLibraryActivity : AppCompatActivity() { return } val epub = book.downloadUrl?.takeIf { it.startsWith("https://") } - if (epub == null) { - openOfficialLink(book.url) - return - } MaterialAlertDialogBuilder(this).setTitle(book.title) - .setItems(arrayOf("View ${book.catalog} listing", "Open available EPUB")) { _, which -> - openOfficialLink(if (which == 1) epub else book.url) + .setMessage(bookMetadataSummary(book)) + .setItems(if (epub == null) arrayOf("View ${book.catalog} listing") + else arrayOf("View ${book.catalog} listing", "Open available EPUB")) { _, which -> + openOfficialLink(if (which == 1 && epub != null) epub else book.url) }.setNegativeButton(android.R.string.cancel, null).show() } + /** A clear series/edition/access summary without implying unrestricted access or a guaranteed loan. */ + private fun bookMetadataSummary(book: PublicBookCatalog.Book): String = listOfNotNull( + book.author.takeIf { it.isNotBlank() }, book.catalog, + book.publicationYear?.toString(), book.language, + book.series?.let { getString(R.string.monogatari_catalog_series, it) }, + book.editionCount?.let { getString(R.string.monogatari_catalog_editions, it) }, + book.format, book.availability, + ).joinToString("\n") + private fun openOfficialLink(url: String) { if (!url.startsWith("https://")) { catalogStatus.text = "Only secure official catalog links can be opened." diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt index df7bff8fdc..9a35f83a92 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt @@ -29,6 +29,11 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: val publicationYear: Int? = null, val language: String? = null, val format: String? = null, + /** Catalog-listed series, not a guessed volume from the title. */ + val series: String? = null, + val editionCount: Int? = null, + /** Metadata describes access conditions, not a guarantee that lending is available now. */ + val availability: String? = null, ) suspend fun searchGutenberg(query: String, page: Int = 1): List = withContext(Dispatchers.IO) { @@ -62,7 +67,10 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: "Open Library", "https://openlibrary.org$workPath", coverUrl = obj.optInt("cover_i").takeIf { it > 0 }?.let { "https://covers.openlibrary.org/b/id/$it-L.jpg" }, publicationYear = obj.optInt("first_publish_year").takeIf { it in 1000..2100 }, - language = obj.optJSONArray("language")?.optString(0), format = "Online catalog") + language = obj.optJSONArray("language")?.optString(0), format = "Online catalog", + series = obj.optJSONArray("series")?.optString(0)?.takeIf { it.isNotBlank() }, + editionCount = obj.optInt("edition_count").takeIf { it > 0 }, + availability = BookAccessMetadata.openLibraryAccess(obj.optString("ebook_access"))) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt index 6b3532a927..19aec6e637 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt @@ -134,9 +134,10 @@ class UnifiedLibraryActivity : AppCompatActivity() { addView(collectionRow) }, LinearLayout.LayoutParams(-1, -2)) collections = LibraryCollections.decode(collectionPrefs.getString("data", null)) + selectedCollection = savedInstanceState?.getString("selected_collection")?.takeIf { it in collections } search = EditText(this).apply { hint = "Filter this library"; setSingleLine(true) } search.doAfterTextChanged { render() } - search.setText(intent.getStringExtra(EXTRA_FILTER).orEmpty()) + search.setText(savedInstanceState?.getString("library_query") ?: intent.getStringExtra(EXTRA_FILTER).orEmpty()) root.addView(search, LinearLayout.LayoutParams(-1, -2)) count = TextView(this) root.addView(count, LinearLayout.LayoutParams(-1, -2)) @@ -301,6 +302,8 @@ class UnifiedLibraryActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { outState.putString("selected_kind", selected?.name ?: "ALL") + outState.putString("selected_collection", selectedCollection) + if (::search.isInitialized) outState.putString("library_query", search.text?.toString().orEmpty()) paneRow?.let { outState.putString("pane_row_key", keyOf(it)) } super.onSaveInstanceState(outState) } @@ -402,6 +405,7 @@ class UnifiedLibraryActivity : AppCompatActivity() { private inner class Rows : RecyclerView.Adapter() { private var rows = emptyList() + init { stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY } fun setRows(value: List) { rows = value notifyDataSetChanged() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt index 2747098cad..88d18c73ac 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt @@ -7,7 +7,7 @@ internal object NavLayoutPolicy { const val DESKTOP_WIDTH_DP = 1000 /** The two-pane layouts need at least this width. */ - const val TWO_PANE_WIDTH_DP = 840 + const val TWO_PANE_WIDTH_DP = 600 fun expandRail(widthDp: Int, labelsVisible: Boolean): Boolean = labelsVisible && widthDp >= DESKTOP_WIDTH_DP diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index c0777da7c2..2c3dd4bcd7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -107,13 +107,13 @@ class SearchViewModel @Inject constructor( private val effectiveMediaType: MediaType? get() = if (searchMediaScope == "ALL") null else mediaTypeOverride ?: settings.mediaTypeFilter - private var includeDisabledSources = MutableStateFlow(false) - private var pinnedOnly = MutableStateFlow(false) + private var includeDisabledSources = MutableStateFlow(savedStateHandle.get("include_disabled_sources") ?: false) + private var pinnedOnly = MutableStateFlow(savedStateHandle.get("pinned_only") ?: false) // The combined search is most useful as a single clean answer, so dead/empty sources stay hidden // unless the user explicitly asks to see them. - private var hideEmpty = MutableStateFlow(true) - private var yearFrom = MutableStateFlow(null) - private var yearTo = MutableStateFlow(null) + private var hideEmpty = MutableStateFlow(savedStateHandle.get("hide_empty") ?: true) + private var yearFrom = MutableStateFlow(savedStateHandle.get("year_from")) + private var yearTo = MutableStateFlow(savedStateHandle.get("year_to")) /** When off, titles whose year can't be estimated are hidden while a year filter or order is in use. */ private val languageFilter = MutableStateFlow(savedStateHandle.get("language_filter")) private val includeUnknownYears = MutableStateFlow(savedStateHandle.get("include_unknown_years") ?: true) @@ -286,18 +286,21 @@ class SearchViewModel @Inject constructor( catalogState.value = CatalogState(loading = effectiveMediaType == MediaType.BOOK) includeDisabledSources.value = false + savedStateHandle["include_disabled_sources"] = false doSearch() } fun setPinnedOnly(value: Boolean) { if (pinnedOnly.value != value) { pinnedOnly.value = value + savedStateHandle["pinned_only"] = value retry() } } fun setHideEmpty(value: Boolean) { hideEmpty.value = value + savedStateHandle["hide_empty"] = value } val isHideEmptyEnabled: Boolean @@ -337,6 +340,8 @@ class SearchViewModel @Inject constructor( if (yearFrom.value == normalizedFrom && yearTo.value == normalizedTo) return yearFrom.value = normalizedFrom yearTo.value = normalizedTo + savedStateHandle["year_from"] = normalizedFrom + savedStateHandle["year_to"] = normalizedTo retry() } @@ -347,6 +352,7 @@ class SearchViewModel @Inject constructor( val prevJob = searchJob searchJob = launchLoadingJob(Dispatchers.Default) { includeDisabledSources.value = true + savedStateHandle["include_disabled_sources"] = true prevJob?.join() val sources = if (pinnedOnly.value) { emptyList() diff --git a/app/src/main/res/layout-w600dp/activity_search.xml b/app/src/main/res/layout-w600dp/activity_search.xml new file mode 100644 index 0000000000..8a51286b3f --- /dev/null +++ b/app/src/main/res/layout-w600dp/activity_search.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4ed67e7f9..c3173ced4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1420,4 +1420,6 @@ Optional — skip it and you appear as anon. Start reading Open catalog listing No description is available for this title. + Series: %1$s + %1$d catalog editions diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadataTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadataTest.kt new file mode 100644 index 0000000000..53095c6f2e --- /dev/null +++ b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/BookAccessMetadataTest.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.core.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BookAccessMetadataTest { + @Test fun neverPromisesThatABorrowableBookIsAvailable() { + assertEquals("Borrowing may be required", BookAccessMetadata.openLibraryAccess("borrowable")) + assertEquals("Public online text", BookAccessMetadata.openLibraryAccess("public")) + assertEquals("Restricted accessible edition", BookAccessMetadata.openLibraryAccess("printdisabled")) + assertEquals("No digital copy listed", BookAccessMetadata.openLibraryAccess("no_ebook")) + assertNull(BookAccessMetadata.openLibraryAccess("unexpected")) + assertNull(BookAccessMetadata.openLibraryAccess(null)) + } +} diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt index 6052f5b77e..f97911d4bf 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt @@ -15,8 +15,9 @@ class NavLayoutPolicyTest { } @Test - fun twoPaneStartsAt840() { - assertFalse(NavLayoutPolicy.useTwoPane(839)) + fun twoPaneStartsAt600() { + assertFalse(NavLayoutPolicy.useTwoPane(599)) + assertTrue(NavLayoutPolicy.useTwoPane(600)) assertTrue(NavLayoutPolicy.useTwoPane(840)) } } From 63705873e97dccf59f301ff1dbcde99afa219c23 Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:39:55 -0500 Subject: [PATCH 3/9] feat: conservative cross-source manga grouping with per-title preferred source --- .../ui/multi/MangaCrossSourceGrouper.kt | 76 +++++++++++++++++++ .../search/ui/multi/MangaDuplicatePolicy.kt | 51 +++++++++++++ .../search/ui/multi/PublicationYearSorter.kt | 5 +- .../kotatsu/search/ui/multi/SearchActivity.kt | 34 ++++++++- .../search/ui/multi/SearchViewModel.kt | 30 +++++++- app/src/main/res/values/strings.xml | 2 + .../ui/multi/MangaDuplicatePolicyTest.kt | 33 ++++++++ .../ui/multi/PublicationYearSorterTest.kt | 8 ++ 8 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaCrossSourceGrouper.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicy.kt create mode 100644 app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaCrossSourceGrouper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaCrossSourceGrouper.kt new file mode 100644 index 0000000000..56355f68e0 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaCrossSourceGrouper.kt @@ -0,0 +1,76 @@ +package org.koitharu.kotatsu.search.ui.multi + +import android.content.Context +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.media.mediaType +import org.koitharu.kotatsu.core.model.UnknownMangaSource +import org.koitharu.kotatsu.list.ui.model.MangaListModel +import org.koitharu.kotatsu.parsers.model.Manga + +/** Presentation-only deduplication; never merges chapter lists, history, or library records. */ +internal class MangaCrossSourceGrouper(context: Context) { + private val preferences = context.getSharedPreferences("preferred_manga_source", Context.MODE_PRIVATE) + + private fun candidates(rows: List) = rows.map { row -> + val manga = row.manga + MangaDuplicatePolicy.Candidate( + manga.id, manga.source.name, manga.title, manga.altTitle, + manga.authors, manga.source.mediaType().name, + ) + } + + private fun groups(rows: List) = + MangaDuplicatePolicy.groups(candidates(rows)) + + private fun identity(manga: Manga) = manga.id to manga.source.name + + private fun preferred(group: MangaDuplicatePolicy.Group) = + MangaDuplicatePolicy.preferred(group, preferences.getString(group.key, null)) + /** Mark a preferred copy per matched work without erasing either source. */ + fun choose(sections: List, manga: Manga) { + val group = findGroup(sections, manga) ?: return + if (group.copies.none { it.id to it.source == identity(manga) }) return + preferences.edit().putString(group.key, manga.source.name).apply() + } + + fun preferredName(sections: List, manga: Manga): String? = + findGroup(sections, manga)?.let { preferences.getString(it.key, null) } + + fun alternatives(sections: List, manga: Manga): List { + val rows = sections.flatMap { it.list } + val group = findGroup(sections, manga) ?: return emptyList() + val byIdentity = rows.associateBy { it.manga.id to it.manga.source.name } + return group.copies.mapNotNull { byIdentity[it.id to it.source]?.manga } + } + + private fun findGroup(sections: List, manga: Manga) = + groups(sections.flatMap { it.list }).firstOrNull { group -> + group.copies.any { it.id to it.source == identity(manga) } + } + /** Put a single preferred representative into a combined section; preserve unsupported/failed sources. */ + fun group(sections: List): List { + val rows = sections.flatMap { it.list } + val matches = groups(rows) + if (matches.isEmpty()) return sections + val byIdentity = rows.associateBy { it.manga.id to it.manga.source.name } + val hidden = matches.flatMap { it.copies }.mapTo(HashSet()) { it.id to it.source } + val representatives = matches.mapNotNull { group -> + val candidate = preferred(group) + byIdentity[candidate.id to candidate.source] + } + if (representatives.isEmpty()) return sections + val groupedSection = SearchResultsListModel( + titleResId = R.string.search_cross_source_matches, + source = UnknownMangaSource, + listFilter = null, + sortOrder = null, + list = representatives, + error = null, + ) + return listOf(groupedSection) + sections.map { section -> + section.copy(list = section.list.filterNot { + it.manga.id to it.manga.source.name in hidden + }) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicy.kt new file mode 100644 index 0000000000..5ab78a783b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicy.kt @@ -0,0 +1,51 @@ +package org.koitharu.kotatsu.search.ui.multi + +import java.text.Normalizer +import java.util.Locale + +/** + * Conservative cross-source manga identity. We never merge on a title alone: + * an author match or a distinctive identical alternate title is required. + * Provider failures and differently credited editions remain separate. + */ +internal object MangaDuplicatePolicy { + data class Candidate( + val id: Long, + val source: String, + val title: String, + val altTitle: String?, + val authors: Set, + val mediaKind: String, + ) + data class Group(val key: String, val copies: List) + + private fun norm(raw: String): String = + Normalizer.normalize(raw, Normalizer.Form.NFKC) + .lowercase(Locale.ROOT) + .replace(Regex("[^\\p{L}\\p{N}]+"), " ") + .trim() + .replace(Regex("\\s+"), " ") + fun identity(item: Candidate): String? { + val title = norm(item.title) + if (title.length < 2) return null + val authors = item.authors.map(::norm).filter { it.isNotBlank() }.distinct().sorted() + val alt = item.altTitle?.let(::norm).orEmpty() + val proof = when { + authors.isNotEmpty() -> "author:" + authors.joinToString("|") + alt.length >= 2 && alt != title -> "alternate:$alt" + else -> return null + } + return item.mediaKind + "|" + title + "|" + proof + } + + fun groups(candidates: List): List = + candidates.mapNotNull { item -> identity(item)?.let { it to item } } + .groupBy({ it.first }, { it.second }) + .mapNotNull { (key, copies) -> + val distinctSources = copies.distinctBy { it.source } + if (distinctSources.size < 2) null else Group(key, distinctSources) + } + + fun preferred(group: Group, source: String?): Candidate = + group.copies.firstOrNull { it.source == source } ?: group.copies.first() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorter.kt index 70f2221536..01a2d71ac5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorter.kt @@ -74,9 +74,10 @@ internal object PublicationYearSorter { * One list for all sources, ordered by [order] (years unknown last). The same title found by several sources is * kept once, from the earliest list: callers pass lists in preference order (best source first). */ - fun sortedGlobal(lists: List>, order: PublicationYearOrder, titleOf: (T) -> String, yearOf: (T) -> Int?): List { + fun sortedGlobal(lists: List>, order: PublicationYearOrder, titleOf: (T) -> String, yearOf: (T) -> Int?, deduplicate: Boolean = true): List { val seen = HashSet() - val unique = lists.flatten().filter { item -> + // Callers with author-aware duplicate grouping must retain unlike works sharing a short title. + val unique = if (!deduplicate) lists.flatten() else lists.flatten().filter { item -> val key = titleKey(titleOf(item)) key.isEmpty() || seen.add(key) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index ebc72222a8..0dd72d47b4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -8,6 +8,8 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koitharu.kotatsu.core.model.getTitle import androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding @@ -64,8 +66,8 @@ class SearchActivity : setContentView(ActivitySearchBinding.inflate(layoutInflater)) tabletPreview = SearchTabletPreview.attach( root = viewBinding.root, - onDetails = { router.openDetails(it) }, - onRead = { router.openReader(it) }, + onDetails = { openMangaWithSourceChoice(it, false) }, + onRead = { openMangaWithSourceChoice(it, true) }, onUrl = { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }, ) title = when (viewModel.kind) { @@ -143,7 +145,7 @@ class SearchActivity : override fun onItemClick(item: MangaListModel, view: View) { if (!selectionController.onItemClick(item.id)) { - if (tabletPreview != null) tabletPreview?.show(item) else router.openDetails(item.toMangaWithOverride()) + if (tabletPreview != null) tabletPreview?.show(item) else openMangaWithSourceChoice(item.toMangaWithOverride(), false) } } @@ -157,7 +159,7 @@ class SearchActivity : override fun onReadClick(manga: Manga, view: View) { if (!selectionController.onItemClick(manga.id)) { - router.openReader(manga) + openMangaWithSourceChoice(manga, true) } } @@ -167,6 +169,30 @@ class SearchActivity : } } + /** Cross-source choices are explicit. Picking one also persists the preferred provider for this work. */ + private fun openMangaWithSourceChoice(manga: Manga, read: Boolean) { + val alternatives = viewModel.alternativeSources(manga) + if (alternatives.size < 2) { + if (read) router.openReader(manga) else router.openDetails(manga) + return + } + val preferred = viewModel.preferredSourceName(manga) + val ordered = alternatives.sortedWith(compareByDescending { it.source.name == preferred } + .thenBy { it.source.name }) + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.search_choose_source, manga.title)) + .setItems(ordered.map { + (if (it.source.name == preferred) "★ " else "") + + it.source.getTitle(this).toString() + }.toTypedArray()) { _, index -> + val selection = ordered[index] + viewModel.rememberPreferredSource(selection) + if (read) router.openReader(selection) else router.openDetails(selection) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + override fun onRetryClick(error: Throwable) { viewModel.retry() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 2c3dd4bcd7..d49c1348eb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -34,6 +34,7 @@ import org.koitharu.kotatsu.core.media.OpdsCatalog import org.koitharu.kotatsu.core.media.MediaType import org.koitharu.kotatsu.builtin.BuiltinSource import org.koitharu.kotatsu.core.media.filterByMediaType +import org.koitharu.kotatsu.core.media.mediaType import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.getLocale @@ -133,13 +134,17 @@ class SearchViewModel @Inject constructor( } private val results = MutableStateFlow>(emptyList()) + private val preferredMangaSources by lazy { + applicationContext.getSharedPreferences("preferred_manga_source", Context.MODE_PRIVATE) + } + private val preferredSourceVersion = MutableStateFlow(0) private val publicationSort = MutableStateFlow( savedStateHandle.get("publication_sort") ?.let { code -> PublicationYearOrder.entries.firstOrNull { it.name == code } } ?: PublicationYearOrder.RELEVANCE, ) private val displayResults = combine( - combine(results, languageFilter) { all, language -> + combine(results, languageFilter, preferredSourceVersion) { all, language, _ -> if (language == null) all else all.filter { LanguageFilter.matches(it.source.getLocale()?.language, language) } }, publicationSort, yearFrom, yearTo, includeUnknownYears, @@ -160,8 +165,9 @@ class SearchViewModel @Inject constructor( section.copy(list = PublicationYearSorter.sorted(section.list, order)) } // While years matter (an order or a range is chosen) show each year in the title, "~" when it is only a guess. - if (order == PublicationYearOrder.RELEVANCE && from == null && to == null) ordered - else ordered.map { section -> section.copy(list = section.list.map { it.withYearBadge() }) } + val grouped = groupConfidentDuplicates(ordered) + if (order == PublicationYearOrder.RELEVANCE && from == null && to == null) grouped + else grouped.map { section -> section.copy(list = section.list.map { it.withYearBadge() }) } } private fun MangaListModel.withYearBadge(): MangaListModel { val estimate = PublicationYearSorter.estimateDetailed(manga.title, manga.altTitle, manga.description) ?: return this @@ -173,6 +179,23 @@ class SearchViewModel @Inject constructor( } } + private val crossSourceGrouper = MangaCrossSourceGrouper(applicationContext) + + /** Title/creator evidence is mandatory; similarly named but unrelated works remain separate. */ + private fun groupConfidentDuplicates(sections: List) = + crossSourceGrouper.group(sections) + + fun alternativeSources(manga: Manga): List = + crossSourceGrouper.alternatives(results.value, manga) + + fun preferredSourceName(manga: Manga): String? = + crossSourceGrouper.preferredName(results.value, manga) + + fun rememberPreferredSource(manga: Manga) { + crossSourceGrouper.choose(results.value, manga) + preferredSourceVersion.value++ + } + /** Languages of the sources that answered so far, for the language filter. */ fun languagesInResults(): List = LanguageFilter.available(results.value.map { it.source.getLocale()?.language }) @@ -197,6 +220,7 @@ class SearchViewModel @Inject constructor( order = order, titleOf = { it.manga.title }, yearOf = { PublicationYearSorter.estimateYear(it.manga.title, it.manga.altTitle, it.manga.description) }, + deduplicate = false, ) val combined = SearchResultsListModel( titleResId = R.string.search_all_sources_by_year, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3173ced4d..e97f0486ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1422,4 +1422,6 @@ Optional — skip it and you appear as anon. No description is available for this title. Series: %1$s %1$d catalog editions + Same titles from multiple sources + Choose a source for %1$s diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt new file mode 100644 index 0000000000..21585a93d6 --- /dev/null +++ b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.search.ui.multi + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MangaDuplicatePolicyTest { + private fun copy(id: Long, source: String, title: String = "Our Story", + alt: String? = null, authors: Set = setOf("A. Writer"), kind: String = "MANGA") = + MangaDuplicatePolicy.Candidate(id, source, title, alt, authors, kind) + + @Test fun identicalCreditedTitlesAcrossSourcesGroupOnce() { + val a = copy(11, "Manga A") + val b = copy(22, "Manga B", "OUR STORY") + val groups = MangaDuplicatePolicy.groups(listOf(a, b)) + assertEquals(1, groups.size) + assertEquals(2, groups.single().copies.size) + assertEquals(b, MangaDuplicatePolicy.preferred(groups.single(), "Manga B")) + } + + @Test fun identicalNamesFromDifferentCreatorsAndMediaRemainSeparate() { + val a = copy(11, "A") + val differentAuthor = copy(12, "B", authors = setOf("Other Creator")) + val differentMedia = copy(13, "C", kind = "BOOK") + assertTrue(MangaDuplicatePolicy.groups(listOf(a, differentAuthor, differentMedia)).isEmpty()) + } + + @Test fun bareNamesAreTooAmbiguousAndCopiesFromSameSourceNeverGroup() { + val a = copy(1, "A", authors = emptySet()) + val b = copy(2, "B", authors = emptySet()) + assertTrue(MangaDuplicatePolicy.groups(listOf(a, b)).isEmpty()) + assertTrue(MangaDuplicatePolicy.groups(listOf(copy(1, "A"), copy(2, "A"))).isEmpty()) + } diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorterTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorterTest.kt index b013447299..fc7d84c687 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorterTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/PublicationYearSorterTest.kt @@ -49,6 +49,14 @@ class PublicationYearSorterTest { assertEquals(listOf("Alpha (2010)", "Delta (2005)", "Gamma (1999)", "Beta"), newest) } + @Test fun globalSortCanRetainUnlikeCreatorsSharingATitle() { + val results = PublicationYearSorter.sortedGlobal( + listOf(listOf("One Story", "One Story")), PublicationYearOrder.NEWEST_FIRST_GLOBAL, + { it }, { 2007 }, deduplicate = false, + ) + assertEquals(2, results.size) + } + @Test fun orderFlags() { assertEquals(true, PublicationYearOrder.OLDEST_FIRST_GLOBAL.isGlobal) assertEquals(false, PublicationYearOrder.NEWEST_FIRST.isGlobal) From d58e2e5093ebb92d4815aa1ca227d648b2069114 Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:51:30 -0500 Subject: [PATCH 4/9] feat: live StoryWeaver catalog and licensed illustrated reader; fix duplicate test --- app/src/main/AndroidManifest.xml | 1 + .../kotatsu/core/media/BookLibraryActivity.kt | 29 ++++- .../kotatsu/core/media/PublicBookCatalog.kt | 9 ++ .../kotatsu/core/media/StoryWeaverCatalog.kt | 67 +++++++++++ .../core/media/StoryWeaverReaderActivity.kt | 106 ++++++++++++++++++ .../search/ui/multi/BookCatalogListModel.kt | 4 + .../kotatsu/search/ui/multi/SearchActivity.kt | 17 ++- .../search/ui/multi/SearchTabletPreview.kt | 8 +- .../search/ui/multi/SearchViewModel.kt | 4 +- .../search/ui/multi/adapter/BookCatalogAD.kt | 5 + .../res/layout/item_monogatari_catalog.xml | 2 +- app/src/main/res/values/strings.xml | 5 + .../core/media/StoryWeaverCatalogTest.kt | 49 ++++++++ .../ui/multi/MangaDuplicatePolicyTest.kt | 2 + docs/WORK_STATUS.md | 6 + 15 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverReaderActivity.kt create mode 100644 app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c931c97085..bb04eb07c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -114,6 +114,7 @@ + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt index e6f5a0e831..fcced02579 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt @@ -238,7 +238,7 @@ class BookLibraryActivity : AppCompatActivity() { "Gutenberg Australia · regional classics", "Global Grey · classics and historical books", "Baen Free Library · science fiction and fantasy", - "StoryWeaver · illustrated stories (official website)", + "StoryWeaver · illustrated CC BY stories (live catalog)", "FictionPress · original fiction (official website)", "FanFiction.net · fanfiction (official website)", "Bilibili · public videos (official website)", @@ -252,7 +252,7 @@ class BookLibraryActivity : AppCompatActivity() { 1 -> search("Gutenberg Australia") 2 -> search("Global Grey") 3 -> search("Baen Free Library") - 4 -> openOfficialLink(OfficialMediaSites.searchUrl(OfficialMediaSites.Site.STORYWEAVER, term)) + 4 -> search("StoryWeaver") 5 -> openOfficialLink(OfficialMediaSites.searchUrl(OfficialMediaSites.Site.FICTIONPRESS, term)) 6 -> openOfficialLink(OfficialMediaSites.searchUrl(OfficialMediaSites.Site.FANFICTION, term)) 7 -> openOfficialLink(OfficialMediaSites.searchUrl(OfficialMediaSites.Site.BILIBILI, term)) @@ -355,7 +355,7 @@ class BookLibraryActivity : AppCompatActivity() { catalogJob = lifecycleScope.launch { val providers = if (source == "All") listOf("Gutenberg", "Open Library", "Archive", "Wikisource", "Google Books", "Standard Ebooks", - "Faded Page", "Gutenberg Australia", "Global Grey", "Baen Free Library") + "Faded Page", "Gutenberg Australia", "Global Grey", "Baen Free Library", "StoryWeaver") else listOf(source) try { // A slow, restricted or unreachable catalog cannot hide other providers' results. @@ -417,11 +417,13 @@ class BookLibraryActivity : AppCompatActivity() { onLongPress = { openCatalogGroup(group) }, actionLabel = when { book.catalog == "Standard Ebooks" -> "Get EPUB" + book.catalog == "StoryWeaver" -> "Read story" book.downloadUrl?.startsWith("https://") == true -> "Open EPUB" else -> "View listing" }, onAction = { if (book.catalog == "Standard Ebooks") openStandardEpub(book) + else if (book.catalog == "StoryWeaver") openStoryWeaverReader(book) else { val epub = book.downloadUrl?.takeIf { it.startsWith("https://") } openOfficialLink(epub ?: book.url) @@ -468,6 +470,14 @@ class BookLibraryActivity : AppCompatActivity() { } private fun openBookListing(book: PublicBookCatalog.Book) { + if (book.catalog == "StoryWeaver") { + MaterialAlertDialogBuilder(this).setTitle(book.title) + .setMessage(bookMetadataSummary(book)) + .setPositiveButton(R.string.storyweaver_read_story) { _, _ -> openStoryWeaverReader(book) } + .setNeutralButton(R.string.storyweaver_open_browser) { _, _ -> openOfficialLink(book.url) } + .setNegativeButton(android.R.string.cancel, null).show() + return + } if (book.catalog == "Standard Ebooks") { MaterialAlertDialogBuilder(this).setTitle(book.title) .setMessage(bookMetadataSummary(book)) @@ -492,9 +502,19 @@ class BookLibraryActivity : AppCompatActivity() { book.publicationYear?.toString(), book.language, book.series?.let { getString(R.string.monogatari_catalog_series, it) }, book.editionCount?.let { getString(R.string.monogatari_catalog_editions, it) }, - book.format, book.availability, + book.format, book.availability, book.attribution, ).joinToString("\n") + private fun openStoryWeaverReader(book: PublicBookCatalog.Book) { + if (!StoryWeaverCatalog.officialStoryUrl(book.url)) { + catalogStatus.text = getString(R.string.storyweaver_invalid_link) + return + } + startActivity(Intent(this, StoryWeaverReaderActivity::class.java) + .putExtra(StoryWeaverReaderActivity.EXTRA_URL, book.url) + .putExtra(StoryWeaverReaderActivity.EXTRA_CREDIT, book.attribution)) + } + private fun openOfficialLink(url: String) { if (!url.startsWith("https://")) { catalogStatus.text = "Only secure official catalog links can be opened." @@ -516,6 +536,7 @@ class BookLibraryActivity : AppCompatActivity() { "Gutenberg Australia" -> catalog.searchGutenbergAustralia(term) "Global Grey" -> catalog.searchGlobalGrey(term) "Baen Free Library" -> catalog.searchBaenFreeLibrary(term) + "StoryWeaver" -> catalog.searchStoryWeaver(term) else -> emptyList() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt index 9a35f83a92..3344665359 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt @@ -34,6 +34,7 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: val editionCount: Int? = null, /** Metadata describes access conditions, not a guarantee that lending is available now. */ val availability: String? = null, + val attribution: String? = null, ) suspend fun searchGutenberg(query: String, page: Int = 1): List = withContext(Dispatchers.IO) { @@ -194,6 +195,14 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: StandardEbooksCatalog.parseCompatibleEpub(fetchText(bookUrl), bookUrl) } + /** Live public StoryWeaver API: authorized search metadata and official illustrated-story links. */ + suspend fun searchStoryWeaver(query: String, page: Int = 1): List = withContext(Dispatchers.IO) { + if (query.isBlank()) return@withContext emptyList() + val url = "https://storyweaver.org.in/api/v1/books-search?query=${encode(query.take(120))}" + + "&page=${page.coerceIn(1, 100)}&per_page=24" + StoryWeaverCatalog.parse(fetch(url)) + } + /** Rights-respecting discovery of public audiobook metadata (not a direct download parser). */ suspend fun searchInternetArchiveAudio(query: String, page: Int = 1): List = withContext(Dispatchers.IO) { val safe = query.replace(Regex("[^\\p{L}\\p{N} ]"), " ").trim() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt new file mode 100644 index 0000000000..73cb710fbd --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt @@ -0,0 +1,67 @@ +package org.koitharu.kotatsu.core.media + +import org.json.JSONObject +import java.net.URI + +/** Parses the provider's documented public catalog response; no login, captcha or scraping. */ +internal object StoryWeaverCatalog { + private val slugPattern = Regex("[0-9]{1,12}-[a-z0-9-]{2,140}") + + fun officialStoryUrl(url: String): Boolean = runCatching { + val uri = URI(url) + uri.scheme == "https" && uri.host == "storyweaver.org.in" && + uri.port == -1 && uri.userInfo == null && + uri.path.removePrefix("/en/stories/").let { slugPattern.matches(it) } && + uri.path.startsWith("/en/stories/") + }.getOrDefault(false) + + fun officialHost(url: String): Boolean = runCatching { + val uri = URI(url) + uri.scheme == "https" && uri.host == "storyweaver.org.in" && + uri.userInfo == null && uri.port == -1 + }.getOrDefault(false) + + fun parse(response: JSONObject): List { + if (!response.optBoolean("ok")) return emptyList() + val items = response.optJSONArray("data") ?: return emptyList() + return (0 until minOf(items.length(), 40)).mapNotNull { i -> + val book = items.optJSONObject(i) ?: return@mapNotNull null + val slug = book.optString("slug").takeIf { slugPattern.matches(it) } + ?: return@mapNotNull null + val title = book.optString("title").trim().takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val authors = book.optJSONArray("authors") + val writers = (0 until (authors?.length() ?: 0)).mapNotNull { + authors?.optJSONObject(it)?.optString("name")?.takeIf(String::isNotBlank) + } + val illustrators = book.optJSONArray("illustrators") + val artists = (0 until (illustrators?.length() ?: 0)).mapNotNull { + illustrators?.optJSONObject(it)?.optString("name")?.takeIf(String::isNotBlank) + } + val publisher = book.optJSONObject("publisher")?.optString("name").orEmpty() + val covers = book.optJSONObject("coverImage")?.optJSONArray("sizes") + val cover = (0 until (covers?.length() ?: 0)).mapNotNull { idx -> + covers?.optJSONObject(idx)?.optString("url")?.takeIf { + it.startsWith("https://storage.googleapis.com/static.storyweaver.org.in/") + } + }.lastOrNull() + val credit = buildList { + if (writers.isNotEmpty()) add("Written by " + writers.joinToString(", ")) + if (artists.isNotEmpty()) add("Illustrated by " + artists.joinToString(", ")) + if (publisher.isNotBlank()) add("Published by $publisher") + add("StoryWeaver · CC BY 4.0") + }.joinToString(" · ") + PublicBookCatalog.Book( + title = title, + author = writers.joinToString(", "), + catalog = "StoryWeaver", + url = "https://storyweaver.org.in/en/stories/$slug", + coverUrl = cover, + language = book.optString("language").takeIf(String::isNotBlank), + format = "Illustrated online story", + availability = "Free licensed reading; online connection required", + attribution = credit, + ) + }.distinctBy { it.url } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverReaderActivity.kt new file mode 100644 index 0000000000..864cdc5bc6 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverReaderActivity.kt @@ -0,0 +1,106 @@ +package org.koitharu.kotatsu.core.media + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.google.android.material.progressindicator.LinearProgressIndicator +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.MonogatariChrome +import org.koitharu.kotatsu.core.ui.setMonogatariContent + +/** In-app view of the provider's own illustrated reader. No page scraping, paywall bypass or ad injection. */ +class StoryWeaverReaderActivity : AppCompatActivity() { + companion object { + const val EXTRA_URL = "storyweaver_story_url" + const val EXTRA_CREDIT = "storyweaver_story_attribution" + } + private lateinit var browser: WebView + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val url = intent.getStringExtra(EXTRA_URL).orEmpty() + if (!StoryWeaverCatalog.officialStoryUrl(url)) { finish(); return } + title = getString(R.string.storyweaver_reader_title) + val content = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + val attribution = intent.getStringExtra(EXTRA_CREDIT).orEmpty() + if (attribution.isNotBlank()) content.addView(TextView(this).apply { + text = attribution + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall) + setPadding(16, 8, 16, 8) + isFocusable = true + }) + val loading = LinearProgressIndicator(this).apply { isIndeterminate = false; max = 100 } + content.addView(loading, LinearLayout.LayoutParams(-1, -2)) + val status = TextView(this).apply { + setText(R.string.storyweaver_online_notice) + setPadding(16, 8, 16, 8) + } + content.addView(status, LinearLayout.LayoutParams(-1, -2)) + val fallback = MaterialButton(this).apply { + setText(R.string.storyweaver_open_browser) + setOnClickListener { startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } + } + content.addView(fallback, LinearLayout.LayoutParams(-1, -2)) + browser = WebView(this).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + settings.javaScriptCanOpenWindowsAutomatically = false + settings.setSupportMultipleWindows(false) + settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + loading.progress = newProgress + loading.isVisible = newProgress < 100 + } + } + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean { + if (StoryWeaverCatalog.officialHost(request.url.toString())) return false + if (request.isForMainFrame) runCatching { + startActivity(Intent(Intent.ACTION_VIEW, request.url)) + } + return true + } + @Deprecated("Required on Android 6") + override fun shouldOverrideUrlLoading(view: WebView?, nextUrl: String): Boolean { + if (StoryWeaverCatalog.officialHost(nextUrl)) return false + runCatching { startActivity(Intent(Intent.ACTION_VIEW, nextUrl.toUri())) } + return true + } + } + } + content.addView(browser, LinearLayout.LayoutParams(-1, 0, 1f)) + setMonogatariContent(content, MonogatariChrome.LIST_MAX_WIDTH_DP) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { if (browser.canGoBack()) browser.goBack() else finish() } + }) + if (savedInstanceState == null || browser.restoreState(savedInstanceState) == null) browser.loadUrl(url) + } + override fun onSaveInstanceState(outState: Bundle) { + if (::browser.isInitialized) browser.saveState(outState) + super.onSaveInstanceState(outState) + } + + override fun onDestroy() { + if (::browser.isInitialized) { + browser.stopLoading() + (browser.parent as? android.view.ViewGroup)?.removeView(browser) + browser.destroy() + } + super.onDestroy() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt index 6d8e49496b..daad6dd8cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt @@ -10,6 +10,10 @@ data class BookCatalogListModel( val url: String, /** Structured publication year supplied by a catalog, never inferred from its title. */ val publicationYear: Int? = null, + val coverUrl: String? = null, + val format: String? = null, + val language: String? = null, + val attribution: String? = null, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is BookCatalogListModel && other.url == url diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index 0dd72d47b4..9fbed77910 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -16,6 +16,8 @@ import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.media.MediaType +import org.koitharu.kotatsu.core.media.StoryWeaverCatalog +import org.koitharu.kotatsu.core.media.StoryWeaverReaderActivity import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings @@ -68,7 +70,7 @@ class SearchActivity : root = viewBinding.root, onDetails = { openMangaWithSourceChoice(it, false) }, onRead = { openMangaWithSourceChoice(it, true) }, - onUrl = { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }, + onUrl = { openCatalogWebsite(it) }, ) title = when (viewModel.kind) { SearchKind.SIMPLE, @@ -109,7 +111,7 @@ class SearchActivity : selectionDecoration = selectionDecoration, onBookOpen = { book -> if (tabletPreview != null) tabletPreview?.show(book) - else startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(book.url))) + else openCatalogWebsite(book.url, book.attribution) }, ) viewBinding.recyclerView.adapter = adapter @@ -169,6 +171,17 @@ class SearchActivity : } } + /** StoryWeaver opens its authorized illustrated reader in-app, rather than an unrelated browser tab. */ + private fun openCatalogWebsite(url: String, attribution: String? = null) { + if (StoryWeaverCatalog.officialStoryUrl(url)) { + startActivity(Intent(this, StoryWeaverReaderActivity::class.java) + .putExtra(StoryWeaverReaderActivity.EXTRA_URL, url) + .putExtra(StoryWeaverReaderActivity.EXTRA_CREDIT, attribution)) + } else { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } + /** Cross-source choices are explicit. Picking one also persists the preferred provider for this work. */ private fun openMangaWithSourceChoice(manga: Manga, read: Boolean) { val alternatives = viewModel.alternativeSources(manga) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt index 3b6e7a910e..12953ab180 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt @@ -54,11 +54,11 @@ internal class SearchTabletPreview private constructor( fun show(item: BookCatalogListModel) { selectedManga = null selectedUrl = item.url - cover.setImageDrawable(null) - cover.isVisible = false + cover.isVisible = !item.coverUrl.isNullOrBlank() + cover.setImageAsync(item.coverUrl) showCommon(item.title, item.catalog, - item.author.takeIf { it.isNotBlank() } - ?: title.context.getString(R.string.tablet_search_no_description)) + listOfNotNull(item.author.takeIf { it.isNotBlank() }, item.format, item.language, item.attribution) + .joinToString(" · ").ifBlank { title.context.getString(R.string.tablet_search_no_description) }) open.setText(R.string.tablet_search_website) read.isVisible = false } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index d49c1348eb..3bd8db23c0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -404,6 +404,7 @@ class SearchViewModel @Inject constructor( listOf( async { safeCatalogSearch { publicBookCatalog.searchInternetArchive(query) } }, async { safeCatalogSearch { publicBookCatalog.searchStandardEbooks(query) } }, + async { safeCatalogSearch { publicBookCatalog.searchStoryWeaver(query) } }, async { searchSavedOpds(query) }, ).awaitAll().flatten() } @@ -460,7 +461,8 @@ class SearchViewModel @Inject constructor( ): List = try { withTimeout(15_000) { block().map { - BookCatalogListModel(it.title, it.author, it.catalog, it.url, it.publicationYear) + BookCatalogListModel(it.title, it.author, it.catalog, it.url, it.publicationYear, + coverUrl = it.coverUrl, format = it.format, language = it.language, attribution = it.attribution) } } } catch (_: TimeoutCancellationException) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt index 55bfaca106..73bdd0f664 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.search.ui.multi.adapter +import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.databinding.ItemMonogatariCatalogBinding import org.koitharu.kotatsu.list.ui.model.ListModel @@ -12,10 +13,14 @@ fun bookCatalogAD(onOpen: (BookCatalogListModel) -> Unit) = ) { bind { binding.monogatariCatalogItemTitle.text = item.title + binding.monogatariCatalogItemCover.isVisible = !item.coverUrl.isNullOrBlank() + binding.monogatariCatalogItemCover.setImageAsync(item.coverUrl) binding.monogatariCatalogItemSummary.text = listOf( item.author.takeIf { it.isNotBlank() }, item.catalog, item.publicationYear?.let { "Published $it" }, + item.format, + item.language, ).filterNotNull().joinToString(" · ") binding.root.setOnClickListener { onOpen(item) } binding.root.contentDescription = "${item.title}, ${binding.monogatariCatalogItemSummary.text}" diff --git a/app/src/main/res/layout/item_monogatari_catalog.xml b/app/src/main/res/layout/item_monogatari_catalog.xml index bb84137002..fcb198e072 100644 --- a/app/src/main/res/layout/item_monogatari_catalog.xml +++ b/app/src/main/res/layout/item_monogatari_catalog.xml @@ -17,7 +17,7 @@ android:minHeight="70dp" android:orientation="horizontal" android:padding="14dp"> - %1$d catalog editions Same titles from multiple sources Choose a source for %1$s + StoryWeaver illustrated reader + StoryWeaver stories open securely on the official website. Internet access is required. + Open in browser + Read illustrated story + This StoryWeaver story address is not recognized. diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt new file mode 100644 index 0000000000..ed8d4b6609 --- /dev/null +++ b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt @@ -0,0 +1,49 @@ +package org.koitharu.kotatsu.core.media + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StoryWeaverCatalogTest { + private val fixture = JSONObject( + """{ + "ok":true, + "metadata":{"hits":1}, + "data":[{ + "id":21618,"title":"Wild Cat! Wild Cat!","slug":"21618-wild-cat-wild-cat", + "language":"English","authors":[{"name":"Sejal Mehta"}], + "illustrators":[{"name":"Rohan Chakravarty"}], + "publisher":{"name":"Pratham Books"}, + "coverImage":{"sizes":[{"url":"https://storage.googleapis.com/static.storyweaver.org.in/art/small.jpg"}]} + }] + }""".trimIndent() + ) + @Test fun readsAuthorizedMetadataAndKeepsRealCopyrightCredits() { + val book = StoryWeaverCatalog.parse(fixture).single() + assertEquals("Wild Cat! Wild Cat!", book.title) + assertEquals("Sejal Mehta", book.author) + assertEquals("StoryWeaver", book.catalog) + assertEquals("English", book.language) + assertTrue(book.url.endsWith("/21618-wild-cat-wild-cat")) + assertTrue(book.attribution!!.contains("Rohan Chakravarty")) + assertTrue(book.attribution!!.contains("CC BY 4.0")) + assertEquals(null, book.downloadUrl) + } + + @Test fun refusesBogusOrUntrustedStoryAddresses() { + assertTrue(StoryWeaverCatalog.officialStoryUrl( + "https://storyweaver.org.in/en/stories/21618-wild-cat-wild-cat")) + assertFalse(StoryWeaverCatalog.officialStoryUrl( + "https://storyweaver.org.in.attacker.example/en/stories/21618-wild-cat-wild-cat")) + assertFalse(StoryWeaverCatalog.officialStoryUrl("javascript:alert(1)")) + assertFalse(StoryWeaverCatalog.officialStoryUrl("http://storyweaver.org.in/en/stories/21618-wild-cat-wild-cat")) + assertFalse(StoryWeaverCatalog.officialStoryUrl("https://storyweaver.org.in/en/profile")) + } + @Test fun invalidSlugsDoNotBecomeClickableSearchResults() { + val unsafe = JSONObject("""{"ok":true,"data":[{"title":"Invalid","slug":"../bad"}]}""") + assertTrue(StoryWeaverCatalog.parse(unsafe).isEmpty()) + assertTrue(StoryWeaverCatalog.parse(JSONObject("""{"ok":false,"data":[]}""")).isEmpty()) + } +} diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt index 21585a93d6..00c7cbccac 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/MangaDuplicatePolicyTest.kt @@ -31,3 +31,5 @@ class MangaDuplicatePolicyTest { assertTrue(MangaDuplicatePolicy.groups(listOf(a, b)).isEmpty()) assertTrue(MangaDuplicatePolicy.groups(listOf(copy(1, "A"), copy(2, "A"))).isEmpty()) } + +} diff --git a/docs/WORK_STATUS.md b/docs/WORK_STATUS.md index 470184e5be..daebf41731 100644 --- a/docs/WORK_STATUS.md +++ b/docs/WORK_STATUS.md @@ -66,3 +66,9 @@ - Working branch: `finish-83-20261015`; new isolated worktree `C:/Users/coolc/Monogatari-Finish83-20261015` clean at creation. Original dirty P: worktree and older audit worktrees untouched. - Active operation: implement and test outstanding OCR bubble geometry and translated text fitting (#21/#23), then additional feasible roadmap gaps; verify GitHub-hosted CI before any completion claims. - No SDK/JDK/emulator installation on owner devices. All pushes, PRs, merges, dispatches and release changes through Tunnel to Surface. Do not release v1.0.0 until all items validated. + +## 2026-10-15 - OCR, catalog, responsive and cross-source finishing branch +- Pushed `a4605f1` (#21/#23), `612f4f3` (#14/#17/#63/#66) and `6370587` (#37) to `finish-83-20261015` through Tunnel. None has yet been merged into `devel`. +- CI `38841599631` on `6370587` failed compiling the new `MangaDuplicatePolicyTest.kt` because its final class brace was omitted. Fixed in working tree. This is not a production regression in application code; fresh CI still required. +- Verified StoryWeaver public `https://storyweaver.org.in/api/v1/books-search?query=cat&per_page=2&page=1` returns HTTP 200 with 1931 real matching public story records (title/author/illustrator/cover/publisher). Implemented parser, official-source reader WebView and official-site fallback, and tests. No invented PDF/EPUB link, site scraping or anti-bot bypass. +- Still open: cross-media filters (#32), global chrome/dark/accessibility (#11/#18/#69), foldable/window/device acceptance (#66), provider restrictions (#48), private Plex credentials/device codec (#58). Await hosted unit/R8/cold-start and real owner-device QA; not cleared for 1.0.0. From a3081b8ea7dbd1129d1ab2e2020f273f63c9a819 Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:58:09 -0500 Subject: [PATCH 5/9] feat: metadata-aware global filters for tags formats language and progress --- .../search/ui/multi/SearchMenuProvider.kt | 5 + .../ui/multi/SearchMetadataFilterDialog.kt | 99 +++++++++++++++++++ .../search/ui/multi/SearchMetadataFilters.kt | 31 ++++++ .../search/ui/multi/SearchViewModel.kt | 72 +++++++++++++- app/src/main/res/menu/opt_search_kind.xml | 4 + app/src/main/res/values/strings.xml | 10 ++ .../ui/multi/SearchMetadataFiltersTest.kt | 32 ++++++ docs/WORK_STATUS.md | 5 + 8 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt create mode 100644 app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMenuProvider.kt index 624d7f8ac2..480323ec7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMenuProvider.kt @@ -80,6 +80,11 @@ class SearchMenuProvider( showPublicationYearsDialog() return true } + + R.id.action_filter_metadata -> { + SearchMetadataFilterDialog.show(activity, viewModel) + return true + } R.id.action_filter_media -> { showMediaScopeDialog() return true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt new file mode 100644 index 0000000000..6adbcce65f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt @@ -0,0 +1,99 @@ +package org.koitharu.kotatsu.search.ui.multi + +import android.view.View +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Spinner +import android.widget.TextView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koitharu.kotatsu.R +import java.util.Locale + +/** Hide unsupported filter choices rather than claiming every catalog reports the same metadata. */ +internal object SearchMetadataFilterDialog { + fun show(activity: SearchActivity, viewModel: SearchViewModel) { + val dp = activity.resources.displayMetrics.density + val gap = (12 * dp).toInt() + val content = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(gap * 2, gap, gap * 2, gap) + } + fun label(res: Int) { + content.addView(TextView(activity).apply { + setText(res) + setPadding(0, gap, 0, gap / 2) + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleSmall) + }) + } + fun spinner(res: Int, labels: List, selectedIndex: Int): Spinner { + label(res) + return Spinner(activity).apply { + adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, labels) + .also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + minimumHeight = (48 * dp).toInt() + setSelection(selectedIndex.coerceIn(0, labels.lastIndex)) + contentDescription = activity.getString(res) + }.also { content.addView(it) } + } + val availableTags = viewModel.availableGenres() + val genre = if (availableTags.isNotEmpty() || viewModel.selectedGenre != null) { + label(R.string.search_genre_tag_label) + EditText(activity).apply { + setSingleLine(true) + setText(viewModel.selectedGenre.orEmpty()) + setHint(R.string.search_genre_tag_hint) + contentDescription = activity.getString(R.string.search_genre_tag_label) + content.addView(this) + } + } else null + val languages = (viewModel.languagesInResults() + listOfNotNull(viewModel.selectedLanguage)).distinct() + val languageOptions = listOf(null) + languages + val languagePicker = if (languages.isNotEmpty()) spinner( + R.string.search_language_label, + languageOptions.map { it?.let { code -> Locale.forLanguageTag(code).displayLanguage } ?: activity.getString(R.string.search_language_any) }, + languageOptions.indexOf(viewModel.selectedLanguage), + ) else null + val formats = (viewModel.availableBookFormats() + listOfNotNull(viewModel.selectedBookFormat)).distinct() + val formatOptions = listOf(null) + formats + val formatPicker = if (formats.isNotEmpty()) spinner( + R.string.search_format_label, + formatOptions.map { it ?: activity.getString(R.string.search_language_any) }, + formatOptions.indexOf(viewModel.selectedBookFormat), + ) else null + val historyModes = SearchMetadataFilters.History.entries + val historyPicker = if (viewModel.hasHistoryCapableResults() || + viewModel.selectedHistoryFilter != SearchMetadataFilters.History.ANY + ) spinner( + R.string.search_local_progress_label, + listOf( + activity.getString(R.string.search_history_all), + activity.getString(R.string.search_history_viewed), + activity.getString(R.string.search_history_unviewed), + ), + historyModes.indexOf(viewModel.selectedHistoryFilter), + ) else null + if (genre == null && languagePicker == null && formatPicker == null && historyPicker == null) { + content.addView(TextView(activity).apply { setText(R.string.search_metadata_unavailable) }) + } + val scroll = ScrollView(activity).apply { addView(content); isFillViewport = false } + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.search_metadata_filters) + .setView(scroll) + .setNeutralButton(R.string.search_clear_metadata_filters) { _, _ -> + viewModel.setGenreFilter(null) + viewModel.setBookFormatFilter(null) + viewModel.setLanguageFilter(null) + viewModel.setHistoryFilter(SearchMetadataFilters.History.ANY) + } + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + genre?.let { viewModel.setGenreFilter(it.text?.toString()) } + languagePicker?.let { viewModel.setLanguageFilter(languageOptions[it.selectedItemPosition]) } + formatPicker?.let { viewModel.setBookFormatFilter(formatOptions[it.selectedItemPosition]) } + historyPicker?.let { viewModel.setHistoryFilter(historyModes[it.selectedItemPosition]) } + } + .show() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt new file mode 100644 index 0000000000..748c54022e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.search.ui.multi + +/** Only real provider tags, catalog formats, and local reading history are filterable. */ +internal object SearchMetadataFilters { + enum class History { ANY, VIEWED, UNVIEWED } + + fun tagMatches(tags: Collection, selected: String?): Boolean = + selected.isNullOrBlank() || tags.any { it.contains(selected.trim(), ignoreCase = true) } + + fun historyMatches(id: Long, selected: History, viewedIds: Set?): Boolean = when (selected) { + History.ANY -> true + History.VIEWED -> viewedIds != null && id in viewedIds + History.UNVIEWED -> viewedIds != null && id !in viewedIds + } + + fun languageMatches(language: String?, selected: String?): Boolean = + selected.isNullOrBlank() || language.isNullOrBlank() || + language.equals(selected, ignoreCase = true) || + language.equals(java.util.Locale.forLanguageTag(selected).getDisplayLanguage(java.util.Locale.ENGLISH), ignoreCase = true) + + fun formatMatches(format: String?, selected: String?): Boolean = + selected.isNullOrBlank() || (format?.contains(selected, ignoreCase = true) == true) + + fun availableTags(tags: Iterable): List = tags + .map(String::trim).filter(String::isNotEmpty) + .distinctBy(String::lowercase).sorted().take(50) + + fun availableFormats(formats: Iterable): List = formats + .filterNotNull().map(String::trim).filter(String::isNotEmpty) + .distinctBy(String::lowercase).sorted().take(25) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 3bd8db23c0..17e3f6ed39 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -117,6 +117,18 @@ class SearchViewModel @Inject constructor( private var yearTo = MutableStateFlow(savedStateHandle.get("year_to")) /** When off, titles whose year can't be estimated are hidden while a year filter or order is in use. */ private val languageFilter = MutableStateFlow(savedStateHandle.get("language_filter")) + private val genreFilter = MutableStateFlow(savedStateHandle.get("genre_filter")) + private val bookFormatFilter = MutableStateFlow(savedStateHandle.get("book_format_filter")) + private val progressFilter = MutableStateFlow( + savedStateHandle.get("history_filter") + ?.let { SearchMetadataFilters.History.entries.firstOrNull { mode -> mode.name == it } } + ?: SearchMetadataFilters.History.ANY, + ) + private val viewedIds = MutableStateFlow?>(null) + private var viewedJob: Job? = null + private val metadataSelection = combine(genreFilter, progressFilter, viewedIds) { genre, progress, ids -> + Triple(genre, progress, ids) + } private val includeUnknownYears = MutableStateFlow(savedStateHandle.get("include_unknown_years") ?: true) val isVideoModeSearch: Boolean get() = effectiveMediaType == MediaType.VIDEO @@ -144,8 +156,15 @@ class SearchViewModel @Inject constructor( ?: PublicationYearOrder.RELEVANCE, ) private val displayResults = combine( - combine(results, languageFilter, preferredSourceVersion) { all, language, _ -> - if (language == null) all else all.filter { LanguageFilter.matches(it.source.getLocale()?.language, language) } + combine(results, languageFilter, preferredSourceVersion, metadataSelection) { all, language, _, selection -> + all.map { section -> + val eligible = language == null || + LanguageFilter.matches(section.source.getLocale()?.language, language) + section.copy(list = if (!eligible) emptyList() else section.list.filter { row -> + SearchMetadataFilters.tagMatches(row.manga.tags.map { it.title }, selection.first) && + SearchMetadataFilters.historyMatches(row.manga.id, selection.second, selection.third) + }) + } }, publicationSort, yearFrom, yearTo, includeUnknownYears, ) { raw, order, from, to, unknown -> @@ -208,6 +227,46 @@ class SearchViewModel @Inject constructor( savedStateHandle["language_filter"] = language } + val selectedGenre: String? get() = genreFilter.value + val selectedBookFormat: String? get() = bookFormatFilter.value + val selectedHistoryFilter: SearchMetadataFilters.History get() = progressFilter.value + + fun availableGenres(): List = + SearchMetadataFilters.availableTags(results.value.flatMap { section -> + section.list.flatMap { row -> row.manga.tags.map { it.title } } + }) + + fun availableBookFormats(): List = + SearchMetadataFilters.availableFormats(catalogState.value.items.map { it.format }) + + fun hasHistoryCapableResults(): Boolean = results.value.any { it.list.isNotEmpty() } + + fun setGenreFilter(value: String?) { + genreFilter.value = value?.trim()?.take(50)?.takeIf(String::isNotBlank) + savedStateHandle["genre_filter"] = genreFilter.value + } + + fun setBookFormatFilter(value: String?) { + bookFormatFilter.value = value + savedStateHandle["book_format_filter"] = value + } + + fun setHistoryFilter(value: SearchMetadataFilters.History) { + progressFilter.value = value + savedStateHandle["history_filter"] = value.name + if (value != SearchMetadataFilters.History.ANY) loadViewedIds() + } + + /** Uses the existing local history database; unknown history does NOT mean unread. */ + private fun loadViewedIds() { + if (viewedIds.value != null || viewedJob?.isActive == true) return + viewedJob = viewModelScope.launch(Dispatchers.IO) { + viewedIds.value = runCatchingCancellable { + historyRepository.search(query, kind, Int.MAX_VALUE).map { it.id }.toSet() + }.getOrNull() + } + } + /** One combined, globally ordered section replaces the per-source ones (sections with errors stay visible). */ private fun mergeGlobally( sections: List, @@ -239,8 +298,14 @@ class SearchViewModel @Inject constructor( val loading: Boolean = false, ) private val catalogState = MutableStateFlow(CatalogState(loading = effectiveMediaType == MediaType.BOOK)) + private val filteredCatalog = combine(catalogState, bookFormatFilter, progressFilter, genreFilter, languageFilter) { + catalog, format, progress, genre, language -> + catalog.copy(items = if (progress != SearchMetadataFilters.History.ANY || !genre.isNullOrBlank()) emptyList() + else catalog.items.filter { SearchMetadataFilters.formatMatches(it.format, format) && + SearchMetadataFilters.languageMatches(it.language, language) }) + } private val displayCatalog = combine( - catalogState, publicationSort, yearFrom, yearTo, includeUnknownYears, + filteredCatalog, publicationSort, yearFrom, yearTo, includeUnknownYears, ) { catalog, sort, from, to, unknown -> catalog.copy(items = PublicBookYearSorter.display(catalog.items, sort, from, to, unknown)) } @@ -288,6 +353,7 @@ class SearchViewModel @Inject constructor( }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState())) init { + if (progressFilter.value != SearchMetadataFilters.History.ANY) loadViewedIds() doSearch() } diff --git a/app/src/main/res/menu/opt_search_kind.xml b/app/src/main/res/menu/opt_search_kind.xml index 8ed6f84bd9..f01a23414d 100644 --- a/app/src/main/res/menu/opt_search_kind.xml +++ b/app/src/main/res/menu/opt_search_kind.xml @@ -62,6 +62,10 @@ android:id="@+id/action_filter_years" android:title="@string/publication_years" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b81a6d2d4..ec13df8510 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1429,4 +1429,14 @@ Optional — skip it and you appear as anon. Open in browser Read illustrated story This StoryWeaver story address is not recognized. + Genre, language, format and history + Genre or demographic tag + e.g. Romance, Boys’ Love, Mystery + Listed book format + Local reading/watch history + All titles + Already opened on this device + Not in this device’s history + No compatible metadata yet. Search a source first; controls appear only when available. + Clear filters diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt new file mode 100644 index 0000000000..1cf99a3fea --- /dev/null +++ b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.search.ui.multi + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchMetadataFiltersTest { + @Test fun filtersOnlyDeclaredGenresNotUnrelatedTitles() { + assertTrue(SearchMetadataFilters.tagMatches(listOf("Boys' Love", "Romance"), "romance")) + assertFalse(SearchMetadataFilters.tagMatches(listOf("Mystery", "Horror"), "romance")) + assertTrue(SearchMetadataFilters.tagMatches(emptyList(), null)) + } + @Test fun historyRequiresActualLocalData() { + val ids = setOf(10L) + assertTrue(SearchMetadataFilters.historyMatches(10, SearchMetadataFilters.History.VIEWED, ids)) + assertFalse(SearchMetadataFilters.historyMatches(11, SearchMetadataFilters.History.VIEWED, ids)) + assertTrue(SearchMetadataFilters.historyMatches(11, SearchMetadataFilters.History.UNVIEWED, ids)) + assertFalse(SearchMetadataFilters.historyMatches(11, SearchMetadataFilters.History.UNVIEWED, null)) + } + @Test fun sourceLanguageCodesMatchProviderLanguageNames() { + assertTrue(SearchMetadataFilters.languageMatches("English", "en")) + assertTrue(SearchMetadataFilters.languageMatches("en", "en")) + assertFalse(SearchMetadataFilters.languageMatches("Japanese", "en")) + } + @Test fun filtersCatalogFormatsWithoutPretendingUnknownIsEpub() { + assertTrue(SearchMetadataFilters.formatMatches("EPUB", "epub")) + assertFalse(SearchMetadataFilters.formatMatches("Online listing", "epub")) + assertFalse(SearchMetadataFilters.formatMatches(null, "epub")) + assertEquals(listOf("EPUB", "PDF"), SearchMetadataFilters.availableFormats(listOf("PDF", "EPUB", null, "epub"))) + } +} diff --git a/docs/WORK_STATUS.md b/docs/WORK_STATUS.md index daebf41731..dfa141f306 100644 --- a/docs/WORK_STATUS.md +++ b/docs/WORK_STATUS.md @@ -72,3 +72,8 @@ - CI `38841599631` on `6370587` failed compiling the new `MangaDuplicatePolicyTest.kt` because its final class brace was omitted. Fixed in working tree. This is not a production regression in application code; fresh CI still required. - Verified StoryWeaver public `https://storyweaver.org.in/api/v1/books-search?query=cat&per_page=2&page=1` returns HTTP 200 with 1931 real matching public story records (title/author/illustrator/cover/publisher). Implemented parser, official-source reader WebView and official-site fallback, and tests. No invented PDF/EPUB link, site scraping or anti-bot bypass. - Still open: cross-media filters (#32), global chrome/dark/accessibility (#11/#18/#69), foldable/window/device acceptance (#66), provider restrictions (#48), private Plex credentials/device codec (#58). Await hosted unit/R8/cold-start and real owner-device QA; not cleared for 1.0.0. + +## 2026-10-15 - Metadata-aware filters finishing work (#32) +- Implemented explicit Search > filter menu for available result tags/demographics, normalized source language, public catalog formats, and actual local history (opened/not opened), preserving choices via SavedStateHandle. +- Unsupported reading-direction metadata is deliberately not fabricated; controls appear only when at least one result exposes relevant metadata. Public external catalogs are excluded from local-history selection and genre selection when no genre metadata is supplied. +- Added `SearchMetadataFiltersTest`; still awaiting exact-commit GitHub-hosted unit tests, R8 APK and cold start. Do not count #32 fully DONE until those checks and unsupported direction behavior are reviewed. From fc25ff01639a187741aff59af9fd9338476af94e Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:59:20 -0500 Subject: [PATCH 6/9] feat: finish fourteen roadmap code paths and preserve final QA stage --- app/src/main/AndroidManifest.xml | 1 + .../kotatsu/builtin/plex/PlexRepository.kt | 37 ++++- .../kotatsu/core/db/dao/PreferencesDao.kt | 4 + .../kotatsu/core/media/BookLibraryActivity.kt | 12 +- .../kotatsu/core/media/MediaFeedActivity.kt | 24 +++- .../media/OfficialFictionBrowserActivity.kt | 135 ++++++++++++++++++ .../kotatsu/core/media/OfficialMediaSites.kt | 12 ++ .../kotatsu/core/media/StoryWeaverCatalog.kt | 3 +- .../core/media/UnifiedLibraryActivity.kt | 3 +- .../core/parser/MangaDataRepository.kt | 6 + .../core/qa/MonogatariTestCenterActivity.kt | 8 +- .../kotatsu/core/qa/NewsFeedActivity.kt | 67 +++++++-- .../koitharu/kotatsu/main/ui/MoreActivity.kt | 2 +- .../kotatsu/main/ui/NavLayoutPolicy.kt | 3 + .../translate/TranslatedPageActivity.kt | 25 +++- .../translate/TranslatedPageRenderer.kt | 22 ++- .../reader/translate/TranslationSidecar.kt | 2 + .../kotatsu/reader/ui/MediaReaderActivity.kt | 36 ++++- .../search/ui/multi/BookCatalogListModel.kt | 2 + .../kotatsu/search/ui/multi/SearchActivity.kt | 5 + .../ui/multi/SearchMetadataFilterDialog.kt | 19 ++- .../search/ui/multi/SearchMetadataFilters.kt | 6 +- .../search/ui/multi/SearchViewModel.kt | 64 +++++++-- .../search/ui/multi/adapter/BookCatalogAD.kt | 1 + .../activity_monogatari_test_center.xml | 9 +- .../res/layout/activity_translated_page.xml | 8 ++ .../res/layout/item_monogatari_catalog.xml | 4 - app/src/main/res/values/strings.xml | 23 +++ .../builtin/plex/PlexRepositoryTest.kt | 11 ++ .../core/media/OfficialMediaSitesTest.kt | 26 ++++ .../core/media/StoryWeaverCatalogTest.kt | 2 +- .../kotatsu/main/ui/NavLayoutPolicyTest.kt | 8 ++ .../translate/TranslationSidecarTest.kt | 8 ++ docs/CODE_FINISHING_14.md | 28 ++++ docs/MONOGATARI_NEXT_CHAT_HANDOFF.md | 6 + docs/ROADMAP_83_TRACKER.md | 7 + scripts/validate-ui-a11y.py | 27 +++- 37 files changed, 609 insertions(+), 57 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt create mode 100644 app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSitesTest.kt create mode 100644 docs/CODE_FINISHING_14.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bb04eb07c6..2e0145b316 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -115,6 +115,7 @@ + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepository.kt index 70f074ac17..ef84c1e0bd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepository.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN import org.koitharu.kotatsu.parsers.model.SortOrder import java.io.IOException +import java.util.UUID /** * The user's own Plex Media Server through its HTTP API with their Plex token (More > API keys & accounts). Movies and @@ -56,7 +57,7 @@ class PlexRepository( parseEpisodes(fetch("$server/library/metadata/$id/allLeaves", token)) } else { val part = partKey(fetch("$server/library/metadata/$id", token)) ?: throw IOException("This Plex item has no playable file") - listOf(Episode(manga.title, part, 1f)) + listOf(Episode(manga.title, part, 1f, id)) } return manga.copy(chapters = episodes.map { ep -> MangaChapter( @@ -64,7 +65,9 @@ class PlexRepository( title = ep.title, number = ep.number, volume = 0, - url = ep.partKey, + // The metadata id is carried in the chapter URL fragment for optional server-side transcoding. + // Fragments are never sent to the part endpoint or persisted in a token-bearing URL. + url = if (ep.metadataId != null) "${ep.partKey}#item=${ep.metadataId}" else ep.partKey, scanlator = null, uploadDate = 0L, branch = null, @@ -75,8 +78,16 @@ class PlexRepository( override suspend fun getStreams(chapter: MangaChapter): List { val (server, token) = credentials() - require(chapter.url.startsWith("/library/parts/")) { "Invalid Plex file" } - return listOf(MediaStream("$server${chapter.url}", "Direct play", headers = mapOf("X-Plex-Token" to token))) + val path = chapter.url.substringBefore('#') + require(path.startsWith("/library/parts/") && !path.contains("..") && '?' !in path) { "Invalid Plex file" } + val headers = mapOf("X-Plex-Token" to token, "X-Plex-Product" to "Monogatari", + "X-Plex-Client-Identifier" to "monogatari-android") + val direct = MediaStream("$server$path", "Direct play", headers = headers) + val id = chapter.url.substringAfter("#item=", "").takeIf { ID.matches(it) } ?: return listOf(direct) + // Server-side HLS is only offered for an explicitly identified item on the user's own Plex server. + val transcoded = MediaStream(transcodeUrl(server, id, UUID.randomUUID().toString()), + "Server transcode (720p)", headers = headers, mimeType = "application/x-mpegURL") + return listOf(direct, transcoded) } override suspend fun getChapterHtml(title: String, chapter: MangaChapter) = "" @@ -124,7 +135,7 @@ class PlexRepository( data class Item(val id: String, val type: String, val title: String, val year: Int?, val summary: String?) - data class Episode(val title: String, val partKey: String, val number: Float) + data class Episode(val title: String, val partKey: String, val number: Float, val metadataId: String? = null) companion object { @@ -138,6 +149,17 @@ class PlexRepository( ).joinToString(" - ").ifBlank { null } private val TYPES = setOf("movie", "show") + /** Plex universal transcoder: session-specific, header-authenticated, never embeds user tokens in URLs. */ + fun transcodeUrl(server: String, metadataId: String, session: String): String { + require(ID.matches(metadataId)) { "Invalid Plex metadata id" } + require(UUID_PATTERN.matches(session)) { "Invalid Plex session" } + return "$server/video/:/transcode/universal/start.m3u8?path=${ArchiveMapping.encode("/library/metadata/$metadataId")}" + + "&mediaIndex=0&partIndex=0&protocol=hls&directPlay=0&directStream=1" + + "&videoQuality=60&videoResolution=1280x720&maxVideoBitrate=4000&session=$session" + } + + private val UUID_PATTERN = Regex("[a-fA-F0-9-]{36}") + fun recentUrl(server: String) = "$server/library/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=40" fun searchUrl(server: String, term: String) = "$server/hubs/search?limit=30&query=${ArchiveMapping.encode(term)}" @@ -184,12 +206,13 @@ class PlexRepository( val parsed = (0 until items.length()).mapNotNull { i -> val item = items.optJSONObject(i) ?: return@mapNotNull null val part = firstPart(item) ?: return@mapNotNull null - Triple(item.optInt("parentIndex", 0), item.optInt("index", 0), item.optString("title") to part) + val ratingKey = item.optString("ratingKey").takeIf { ID.matches(it) } + Triple(item.optInt("parentIndex", 0), item.optInt("index", 0), Triple(item.optString("title"), part, ratingKey)) }.sortedWith(compareBy({ it.first }, { it.second })) return parsed.mapIndexed { i, (season, number, titlePart) -> val label = if (season > 0 && number > 0) "S$season E$number" else "Episode ${i + 1}" val name = titlePart.first.takeIf { it.isNotBlank() } - Episode(if (name != null) "$label · $name" else label, titlePart.second, (i + 1).toFloat()) + Episode(if (name != null) "$label · $name" else label, titlePart.second, (i + 1).toFloat(), titlePart.third) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt index 4b62c77ed6..2e4f297189 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt @@ -15,6 +15,10 @@ abstract class PreferencesDao { @Query("SELECT * FROM preferences WHERE manga_id = :mangaId") abstract fun observe(mangaId: Long): Flow + /** Reader direction is known only for titles where the user saved an explicit reading mode. */ + @Query("SELECT * FROM preferences WHERE mode IN (1, 2, 3, 4)") + abstract suspend fun getExplicitReaderModes(): List + @Query("SELECT * FROM preferences WHERE title_override IS NOT NULL OR cover_override IS NOT NULL OR content_rating_override IS NOT NULL") abstract suspend fun getOverrides(): List diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt index fcced02579..bcf96249ce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt @@ -253,8 +253,8 @@ class BookLibraryActivity : AppCompatActivity() { 2 -> search("Global Grey") 3 -> search("Baen Free Library") 4 -> search("StoryWeaver") - 5 -> openOfficialLink(OfficialMediaSites.searchUrl(OfficialMediaSites.Site.FICTIONPRESS, term)) - 6 -> openOfficialLink(OfficialMediaSites.searchUrl(OfficialMediaSites.Site.FANFICTION, term)) + 5 -> openFictionBrowser(OfficialMediaSites.Site.FICTIONPRESS, term) + 6 -> openFictionBrowser(OfficialMediaSites.Site.FANFICTION, term) 7 -> openOfficialLink(OfficialMediaSites.searchUrl(OfficialMediaSites.Site.BILIBILI, term)) } } @@ -515,6 +515,14 @@ class BookLibraryActivity : AppCompatActivity() { .putExtra(StoryWeaverReaderActivity.EXTRA_CREDIT, book.attribution)) } + /** These sites do not expose a permitted public chapter API: always use their own search website. */ + private fun openFictionBrowser(site: OfficialMediaSites.Site, term: String) { + val url = OfficialMediaSites.searchUrl(site, term) + if (OfficialMediaSites.fictionSite(url) != site) return + startActivity(Intent(this, OfficialFictionBrowserActivity::class.java) + .putExtra(OfficialFictionBrowserActivity.EXTRA_URL, url)) + } + private fun openOfficialLink(url: String) { if (!url.startsWith("https://")) { catalogStatus.text = "Only secure official catalog links can be opened." diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt index 3bbe8c08c9..3d8913455c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt @@ -9,6 +9,8 @@ import android.widget.EditText import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.isVisible +import com.google.android.material.progressindicator.LinearProgressIndicator import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager @@ -30,6 +32,8 @@ class MediaFeedActivity : AppCompatActivity() { private val results = CatalogResultsAdapter() private val saved by lazy { getSharedPreferences("monogatari_entertainment_feeds", MODE_PRIVATE) } private lateinit var status: TextView + private lateinit var loading: LinearProgressIndicator + private lateinit var retryButton: MaterialButton private lateinit var kindButton: MaterialButton private var currentUrl: String? = null private var entries = emptyList() @@ -54,7 +58,7 @@ class MediaFeedActivity : AppCompatActivity() { } root.addView(TextView(this).apply { text = "Video & podcast feeds" - textSize = 24f + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_HeadlineSmall) }) root.addView(TextView(this).apply { text = "Subscribe to RSS/Atom feeds for shows, films, fiction podcasts and audio drama." @@ -100,6 +104,18 @@ class MediaFeedActivity : AppCompatActivity() { setPadding(0, pad / 2, 0, pad / 2) } root.addView(status) + loading = LinearProgressIndicator(this).apply { + isIndeterminate = true + isVisible = false + contentDescription = getString(R.string.media_feed_loading) + } + root.addView(loading, LinearLayout.LayoutParams(-1, -2)) + retryButton = MaterialButton(this).apply { + setText(R.string.media_feed_retry) + isVisible = false + setOnClickListener { currentUrl?.let(::load) ?: chooseFeed() } + } + root.addView(retryButton) root.addView(RecyclerView(this).apply { layoutManager = GridLayoutManager(this@MediaFeedActivity, MonogatariChrome.columns(context)) @@ -185,6 +201,8 @@ class MediaFeedActivity : AppCompatActivity() { private fun load(url: String, subscribe: Boolean = false) { playlistOnly = false loadJob?.cancel() + retryButton.isVisible = false + loading.isVisible = true status.text = "Loading feed..." loadJob = lifecycleScope.launch { try { @@ -196,7 +214,11 @@ class MediaFeedActivity : AppCompatActivity() { } catch (cancel: CancellationException) { throw cancel } catch (error: Exception) { + currentUrl = url + retryButton.isVisible = true status.text = "Feed unavailable: ${error.message.orEmpty().take(140)}" + } finally { + loading.isVisible = false } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt new file mode 100644 index 0000000000..7dee91e547 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt @@ -0,0 +1,135 @@ +package org.koitharu.kotatsu.core.media + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.google.android.material.progressindicator.LinearProgressIndicator +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.MonogatariChrome +import org.koitharu.kotatsu.core.ui.setMonogatariContent + +/** + * The provider's own website in an ordinary WebView. + * This does NOT masquerade as a parser, evade bot challenges, scrape results or bypass sign-in. + * Manual sign-in/challenges stay under the provider's control and external browser is always available. + */ +class OfficialFictionBrowserActivity : AppCompatActivity() { + companion object { + const val EXTRA_URL = "official_fiction_browser_url" + } + private lateinit var browser: WebView + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val initial = intent.getStringExtra(EXTRA_URL).orEmpty() + val provider = OfficialMediaSites.fictionSite(initial) ?: run { finish(); return } + title = when (provider) { + OfficialMediaSites.Site.FICTIONPRESS -> "FictionPress" + OfficialMediaSites.Site.FANFICTION -> "FanFiction.net" + else -> "Official fiction website" + } + val unit = resources.displayMetrics.density + val padding = (12 * unit).toInt() + val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + val progress = LinearProgressIndicator(this).apply { max = 100; isIndeterminate = false } + root.addView(progress, LinearLayout.LayoutParams(-1, -2)) + val notice = TextView(this).apply { + setText(R.string.official_fiction_disclosure) + setPadding(padding, padding / 2, padding, padding / 2) + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall) + } + root.addView(notice) + val buttons = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL } + buttons.addView(MaterialButton(this).apply { + setText(R.string.official_fiction_reload) + setOnClickListener { if (::browser.isInitialized) browser.reload() } + }, LinearLayout.LayoutParams(0, -2, 1f)) + buttons.addView(MaterialButton(this).apply { + setText(R.string.official_fiction_external) + setOnClickListener { + val current = browser.url?.takeIf { OfficialMediaSites.fictionSite(it) != null } ?: initial + runCatching { startActivity(Intent(Intent.ACTION_VIEW, current.toUri())) } + .onFailure { notice.setText(R.string.official_fiction_no_browser) } + } + }, LinearLayout.LayoutParams(0, -2, 1f)) + root.addView(buttons) + browser = WebView(this).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + settings.javaScriptCanOpenWindowsAutomatically = false + settings.setSupportMultipleWindows(false) + settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) settings.safeBrowsingEnabled = true + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progress.progress = newProgress + progress.isVisible = newProgress < 100 + } + } + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean = + handleNavigation(request.url, request.isForMainFrame) + @Deprecated("Android 6 compatibility") + override fun shouldOverrideUrlLoading(view: WebView?, url: String): Boolean = + handleNavigation(Uri.parse(url), true) + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + if (request?.isForMainFrame == true) { + notice.setText(R.string.official_fiction_connection_failed) + } + } + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + notice.setText(R.string.official_fiction_disclosure) + } + } + } + root.addView(browser, LinearLayout.LayoutParams(-1, 0, 1f)) + setMonogatariContent(root, MonogatariChrome.LIST_MAX_WIDTH_DP) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (browser.canGoBack()) browser.goBack() else finish() + } + }) + if (savedInstanceState == null || browser.restoreState(savedInstanceState) == null) browser.loadUrl(initial) + } + + private fun handleNavigation(url: Uri, mainFrame: Boolean): Boolean { + val location = url.toString() + if (OfficialMediaSites.fictionSite(location) != null) return false + if (mainFrame && url.scheme == "https") { + runCatching { startActivity(Intent(Intent.ACTION_VIEW, url)) } + } + // No deep-link or untrusted URL can be opened silently from provider-controlled page script. + return true + } + + override fun onSaveInstanceState(outState: Bundle) { + if (::browser.isInitialized) browser.saveState(outState) + super.onSaveInstanceState(outState) + } + + override fun onDestroy() { + if (::browser.isInitialized) { + browser.stopLoading() + (browser.parent as? android.view.ViewGroup)?.removeView(browser) + browser.destroy() + } + super.onDestroy() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSites.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSites.kt index 51880fe5f0..449367da0b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSites.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSites.kt @@ -1,11 +1,23 @@ package org.koitharu.kotatsu.core.media import java.net.URLEncoder +import java.net.URI /** Visible official-site discovery for providers without a stable, permitted content API. */ internal object OfficialMediaSites { enum class Site { STORYWEAVER, FICTIONPRESS, FANFICTION, BILIBILI } + /** Only provider-owned hosts may be shown in our in-app fiction browser. Never follow host lookalikes. */ + fun fictionSite(url: String): Site? = runCatching { + val address = URI(url) + if (address.scheme != "https" || address.port != -1 || address.userInfo != null) return@runCatching null + when (address.host?.lowercase()) { + "www.fictionpress.com", "fictionpress.com", "m.fictionpress.com" -> Site.FICTIONPRESS + "www.fanfiction.net", "fanfiction.net", "m.fanfiction.net" -> Site.FANFICTION + else -> null + } + }.getOrNull() + fun searchUrl(site: Site, query: String): String { val term = query.trim().take(120) val encoded = URLEncoder.encode(term, "UTF-8") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt index 73cb710fbd..8c8390437b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalog.kt @@ -49,7 +49,8 @@ internal object StoryWeaverCatalog { if (writers.isNotEmpty()) add("Written by " + writers.joinToString(", ")) if (artists.isNotEmpty()) add("Illustrated by " + artists.joinToString(", ")) if (publisher.isNotBlank()) add("Published by $publisher") - add("StoryWeaver · CC BY 4.0") + // License may differ per translated edition; never invent blanket rights for a catalog entry. + add("Check the story’s license and attribution on StoryWeaver") }.joinToString(" · ") PublicBookCatalog.Book( title = title, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt index 19aec6e637..33ff32c0ed 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt @@ -157,7 +157,8 @@ class UnifiedLibraryActivity : AppCompatActivity() { }) val list = RecyclerView(this).apply { layoutManager = GridLayoutManager(this@UnifiedLibraryActivity, - if (twoPane) 1 else MonogatariChrome.columns(context)) + if (twoPane) NavLayoutPolicy.libraryListColumns(resources.configuration.screenWidthDp) + else MonogatariChrome.columns(context)) adapter = this@UnifiedLibraryActivity.adapter } root.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt index bb3bddc51d..d17320dca6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt @@ -72,6 +72,12 @@ class MangaDataRepository @Inject constructor( return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) } } + /** Single indexed query; never perform a DB query for each global-search result. */ + suspend fun getExplicitReaderModes(): Map = + db.getPreferencesDao().getExplicitReaderModes().mapNotNull { pref -> + ReaderMode.valueOf(pref.mode)?.let { pref.mangaId to it } + }.toMap() + suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? { return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/MonogatariTestCenterActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/MonogatariTestCenterActivity.kt index eb4d77879d..390a31058c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/MonogatariTestCenterActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/MonogatariTestCenterActivity.kt @@ -60,9 +60,11 @@ class MonogatariTestCenterActivity : AppCompatActivity() { ui = ActivityMonogatariTestCenterBinding.inflate(layoutInflater) setContentView(ui.root) MonogatariChrome.applyInsets(ui.root) - ui.qaToolbar.setNavigationIcon(android.R.drawable.ic_media_previous) - ui.qaToolbar.setNavigationContentDescription(android.R.string.cancel) - ui.qaToolbar.setNavigationOnClickListener { finish() } + title = getString(R.string.monogatari_qa_title) + setSupportActionBar(ui.qaToolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + ui.qaToolbar.setNavigationContentDescription(R.string.monogatari_navigation_back) + ui.qaToolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } index = (savedInstanceState?.getInt("step") ?: prefs.getInt("step", 0)).coerceIn(MonogatariTestPlan.cases.indices) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/NewsFeedActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/NewsFeedActivity.kt index 8ed8eb33ae..24fc152b79 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/NewsFeedActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/qa/NewsFeedActivity.kt @@ -9,6 +9,8 @@ import android.view.ViewGroup import android.widget.EditText import android.widget.LinearLayout import android.widget.ScrollView +import com.google.android.material.progressindicator.LinearProgressIndicator +import androidx.core.view.isVisible import android.widget.HorizontalScrollView import androidx.core.widget.doAfterTextChanged import com.google.android.material.card.MaterialCardView @@ -36,6 +38,9 @@ class NewsFeedActivity : AppCompatActivity() { private lateinit var searchInput: EditText private lateinit var message: TextView private lateinit var entries: LinearLayout + private lateinit var loading: LinearProgressIndicator + private lateinit var listScroll: ScrollView + private var restoredScrollY = 0 private var items = emptyList() private var onlyFollowing = false private var savedOnly = false @@ -52,6 +57,12 @@ class NewsFeedActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) title = "News & followed tags" + onlyFollowing = savedInstanceState?.getBoolean("news_following") ?: false + savedOnly = savedInstanceState?.getBoolean("news_saved") ?: false + activeTag = savedInstanceState?.getString("news_tag") + sourceFilter = savedInstanceState?.getString("news_source") + oldestFirst = savedInstanceState?.getBoolean("news_oldest") ?: false + restoredScrollY = savedInstanceState?.getInt("news_scroll") ?: 0 val density = resources.displayMetrics.density val padding = (16 * density).toInt() val outer = (((resources.configuration.screenWidthDp - 920).coerceAtLeast(0) / 2) * density).toInt() @@ -76,7 +87,8 @@ class NewsFeedActivity : AppCompatActivity() { feedInput = EditText(this).apply { hint = "HTTPS RSS/Atom feed URL" setSingleLine(true) - setText(prefs.getString("selected", "https://www.animenewsnetwork.com/news/rss.xml")) + setText(savedInstanceState?.getString("news_feed_url") + ?: prefs.getString("selected", "https://www.animenewsnetwork.com/news/rss.xml")) } root.addView(feedInput, LinearLayout.LayoutParams(-1, -2)) action("Save feed and refresh") { saveAndLoad() } @@ -86,7 +98,8 @@ class NewsFeedActivity : AppCompatActivity() { id = R.id.monogatari_news_filter hint = "Tags to follow, separated by commas" setSingleLine(true) - setText(followedTags().sorted().joinToString(", ")) + setText(savedInstanceState?.getString("news_tags_input") + ?: followedTags().sorted().joinToString(", ")) } root.addView(tagInput, LinearLayout.LayoutParams(-1, -2)) action("Save followed tags") { @@ -115,6 +128,7 @@ class NewsFeedActivity : AppCompatActivity() { searchInput = EditText(this).apply { hint = "Filter articles by title or topic" setSingleLine(true) + setText(savedInstanceState?.getString("news_search").orEmpty()) doAfterTextChanged { if (::entries.isInitialized) render() } } root.addView(searchInput, LinearLayout.LayoutParams(-1, -2)) @@ -125,11 +139,31 @@ class NewsFeedActivity : AppCompatActivity() { }, LinearLayout.LayoutParams(-1, -2)) message = TextView(this).apply { text = "Choose a feed and refresh · long-press a story to save it" } root.addView(message, LinearLayout.LayoutParams(-1, -2)) - val listScroll = ScrollView(this) + loading = LinearProgressIndicator(this).apply { + isIndeterminate = true + isVisible = false + contentDescription = getString(R.string.news_feed_loading) + } + root.addView(loading, LinearLayout.LayoutParams(-1, -2)) + listScroll = ScrollView(this) entries = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } listScroll.addView(entries) root.addView(listScroll, LinearLayout.LayoutParams(-1, 0, 1f)) setMonogatariContent(root) + if (savedInstanceState != null) load(feedInput.text.toString().trim()) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean("news_following", onlyFollowing) + outState.putBoolean("news_saved", savedOnly) + outState.putString("news_tag", activeTag) + outState.putString("news_source", sourceFilter) + outState.putBoolean("news_oldest", oldestFirst) + outState.putString("news_feed_url", feedInput.text?.toString().orEmpty()) + outState.putString("news_tags_input", tagInput.text?.toString().orEmpty()) + outState.putString("news_search", searchInput.text?.toString().orEmpty()) + outState.putInt("news_scroll", listScroll.scrollY) + super.onSaveInstanceState(outState) } private fun followedTags(): Set = prefs.getStringSet("tags", emptySet()).orEmpty().toSet() @@ -197,15 +231,24 @@ class NewsFeedActivity : AppCompatActivity() { items = emptyList() entries.removeAllViews() message.text = "Loading feed…" + loading.isVisible = true feedJob = lifecycleScope.launch { - val result = withContext(Dispatchers.IO) { runCatching { fetchEntries(url) } } - if (url != feedInput.text.toString().trim()) return@launch - result.onSuccess { loaded -> - items = loaded - message.text = "Loaded ${loaded.size} entries — nothing is fetched in the background" - render() - }.onFailure { error -> - message.text = "Feed unavailable: ${error.message}" + try { + val result = withContext(Dispatchers.IO) { runCatching { fetchEntries(url) } } + if (url != feedInput.text.toString().trim()) return@launch + result.onSuccess { loaded -> + items = loaded + render() + if (restoredScrollY > 0) { + val position = restoredScrollY + restoredScrollY = 0 + listScroll.post { listScroll.scrollTo(0, position) } + } + }.onFailure { error -> + message.text = "Feed unavailable: ${error.message.orEmpty().take(140)}" + } + } finally { + loading.isVisible = false } } } @@ -286,7 +329,7 @@ class NewsFeedActivity : AppCompatActivity() { text = (if (savedNow) "★ " else "") + entry.title + (if (entry.date.isNotBlank()) "\n${entry.date}" else "") + (if (entry.tags.isEmpty()) "" else "\n${entry.tags.joinToString(" · ")}") - textSize = 16f + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge) setOnClickListener { if (entry.link.startsWith("https://") || entry.link.startsWith("http://")) { runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(entry.link))) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MoreActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MoreActivity.kt index 7b73da92ff..a17aed3295 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MoreActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MoreActivity.kt @@ -35,7 +35,7 @@ class MoreActivity : AppCompatActivity() { } val info = TextView(this).apply { text = "Library · Find something · Backup · App and tools" - textSize = 15f + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium) setPadding(0, 0, 0, margin) } val header = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt index 88d18c73ac..ea7805f58d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicy.kt @@ -12,4 +12,7 @@ internal object NavLayoutPolicy { fun expandRail(widthDp: Int, labelsVisible: Boolean): Boolean = labelsVisible && widthDp >= DESKTOP_WIDTH_DP fun useTwoPane(widthDp: Int): Boolean = widthDp >= TWO_PANE_WIDTH_DP + + /** Two library card columns plus the detail preview only when every column remains comfortably wide. */ + fun libraryListColumns(widthDp: Int): Int = if (widthDp >= 1200) 2 else 1 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageActivity.kt index 0762d4dff4..ecf5f373b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageActivity.kt @@ -55,6 +55,7 @@ class TranslatedPageActivity : BaseActivity(), Vi meta = translator.readMeta(result) viewBinding.buttonBack.setOnClickListener(this) viewBinding.buttonToggle.setOnClickListener(this) + viewBinding.buttonFont.setOnClickListener { chooseFont() } val detector = GestureDetector( this, object : GestureDetector.SimpleOnGestureListener() { @@ -98,6 +99,9 @@ class TranslatedPageActivity : BaseActivity(), Vi bottomMargin = barsInsets.bottom marginEnd = barsInsets.end(v) } + viewBinding.buttonFont.updateLayoutParams { + marginEnd = barsInsets.end(v) + } return insets.consumeAll(typeMask) } @@ -152,14 +156,31 @@ class TranslatedPageActivity : BaseActivity(), Vi .show() } + private fun chooseFont() { + val current = meta ?: return + val styles = arrayOf("sans", "serif", "comic") + val labels = arrayOf( + getString(R.string.translated_page_font_sans), + getString(R.string.translated_page_font_serif), + getString(R.string.translated_page_font_comic), + ) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.translated_page_font_title) + .setSingleChoiceItems(labels, styles.indexOf(current.fontStyle).coerceAtLeast(0)) { dialog, selected -> + dialog.dismiss() + if (styles[selected] != current.fontStyle) applyCorrection(current.copy(fontStyle = styles[selected])) + } + .setNegativeButton(android.R.string.cancel, null).show() + } + private fun applyCorrection(updated: TranslationMeta) { lifecycleScope.launch { try { translator.rewrite(result, updated) meta = updated showImage() - } catch (e: PageTranslator.TranslationException) { - Toast.makeText(this@TranslatedPageActivity, e.message, Toast.LENGTH_LONG).show() + } catch (e: Exception) { + Toast.makeText(this@TranslatedPageActivity, getString(R.string.translated_page_font_failed), Toast.LENGTH_LONG).show() } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt index cd04f24843..78b1fb80f2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslatedPageRenderer.kt @@ -24,9 +24,9 @@ internal object TranslatedPageRenderer { private const val SMOOTH_FILL_SPREAD = 28 fun render(source: Bitmap, meta: TranslationMeta): Bitmap = - render(source, meta.blocks.map { Block(it.box, it.translation, meta.targetLanguage) }) + render(source, meta.blocks.map { Block(it.box, it.translation, meta.targetLanguage, meta.fontStyle) }) - class Block(val box: TranslationLayout.Box, val translation: String, val targetLanguage: String? = null) + class Block(val box: TranslationLayout.Box, val translation: String, val targetLanguage: String? = null, val fontStyle: String = "sans") private data class BubbleArea(val content: TranslationLayout.Box, val outline: Path) @@ -42,6 +42,11 @@ internal object TranslatedPageRenderer { } for (block in blocks) { if (block.translation.isBlank()) continue + textPaint.typeface = when (block.fontStyle) { + "serif" -> Typeface.create(Typeface.SERIF, Typeface.NORMAL) + "comic" -> Typeface.create("casual", Typeface.BOLD) + else -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } val covered = TranslationLayout.inflate(block.box, pad, width, height) val edges = sampleEdges(source, covered) val background = TranslationLayout.medianColor(edges) @@ -52,9 +57,14 @@ internal object TranslatedPageRenderer { fun paint(region: TranslationLayout.Box) { if (smooth) fillSmooth(canvas, region, edges) else canvas.drawRoundRect(region.toRectF(), corner, corner, fill) } - paint(covered) - // A closed speech bubble around the text lets the translation use the whole bubble, not just the old text's footprint. + // Detect the connected paper boundary before painting; do not erase artwork outside irregular bubbles. val bubble = findBubble(source, covered, background, pad) + if (bubble != null) { + canvas.save() + canvas.clipPath(bubble.outline) + paint(covered) + canvas.restore() + } else paint(covered) // Never erase the bounding rectangle of an irregular bubble: this covers its outline and panel art. val target = if (bubble != null) bubble.content else { val widened = TranslationLayout.widenForHorizontalText(covered, width, height) @@ -126,7 +136,7 @@ internal object TranslatedPageRenderer { val step = size * 1.2f val oldTypeface = paint.typeface val oldAlign = paint.textAlign - paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + // Preserve the user's font choice; Android falls back to an installed CJK glyph face where needed. paint.textAlign = Paint.Align.CENTER paint.textSize = size val xRight = box.left + pad + (innerWidth + columns * step) / 2f - step / 2f @@ -170,6 +180,8 @@ internal object TranslatedPageRenderer { TranslationLayout.Box(found.bounds.left + window.left, found.bounds.top + window.top, found.bounds.right + window.left, found.bounds.bottom + window.top), pad * 2) if (inner.width <= covered.width || inner.height <= 0) return null + // A flood region can occasionally be a large panel. Reject an implausible near-full-window region. + if (found.bounds.width.toLong() * found.bounds.height > w.toLong() * h * 85L / 100L) return null // Every scanline traces the connected paper region, not the enclosing rectangular OCR region. val outline = Path() for (y in found.bounds.top until found.bounds.bottom) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecar.kt index 64642a1182..dec615d388 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecar.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecar.kt @@ -26,6 +26,8 @@ internal data class TranslationMeta( val sourceLanguage: String, val targetLanguage: String, val blocks: List, + /** User-chosen rendering font; old cache sidecars default to the previous sans-serif style. */ + val fontStyle: String = "sans", ) /** Pure helpers for the per-page translation record (unit-tested). */ diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/MediaReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/MediaReaderActivity.kt index abd925dd12..9f538d9167 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/MediaReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/MediaReaderActivity.kt @@ -30,6 +30,7 @@ import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player +import androidx.media3.common.PlaybackException import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import androidx.core.content.ContextCompat @@ -62,6 +63,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import java.io.ByteArrayOutputStream import org.koitharu.kotatsu.core.media.MediaType +import org.koitharu.kotatsu.builtin.BuiltinSource import org.koitharu.kotatsu.core.media.asJsSource import org.koitharu.kotatsu.core.media.mediaType import org.koitharu.kotatsu.core.nav.AppRouter @@ -398,7 +400,10 @@ class MediaReaderActivity : BaseActivity() { ) { provider.getStreams(state.chapter) } val selected = chooseVideo(videos) ?: throw IllegalStateException(getString(R.string.media_reader_no_streams)) - startPlayer(selected) + val fallback = if (manga.source.name == BuiltinSource.PLEX.name) { + videos.firstOrNull { it.quality.startsWith("Server transcode") } + } else null + startPlayer(selected, fallback) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -410,12 +415,14 @@ class MediaReaderActivity : BaseActivity() { } private fun chooseVideo(videos: List): MediaStream? = - videos.maxByOrNull { video -> + // Prefer the owner's original Plex file; use transcoding only when direct playback fails. + videos.firstOrNull { it.quality.startsWith("Direct play") && "X-Plex-Token" in it.headers } + ?: videos.maxByOrNull { video -> QUALITY_NUMBER.find(video.quality)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 0 } ?: videos.firstOrNull() @OptIn(UnstableApi::class) - private fun startPlayer(video: MediaStream) { + private fun startPlayer(video: MediaStream, fallback: MediaStream? = null) { if (currentMediaType == MediaType.AUDIO && video.headers.isEmpty()) { startBackgroundAudio(video) return @@ -437,6 +444,29 @@ class MediaReaderActivity : BaseActivity() { .build() player = exoPlayer viewBinding.playerView.player = exoPlayer + var fallbackScheduled = false + exoPlayer.addListener(object : Player.Listener { + override fun onPlayerError(error: PlaybackException) { + if (player !== exoPlayer || fallbackScheduled) return + if (fallback != null && fallback.url != video.url) { + fallbackScheduled = true + val chapterAtFailure = currentChapterId + restorePosition = exoPlayer.currentPosition.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + Snackbar.make(viewBinding.root, R.string.plex_direct_play_fallback, Snackbar.LENGTH_LONG).show() + viewBinding.playerView.post { + if (!isDestroyed && currentChapterId == chapterAtFailure) startPlayer(fallback) + } + } else { + val explain = when (error.errorCode) { + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED -> getString(R.string.plex_codec_unavailable) + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> getString(R.string.plex_media_denied) + else -> error.message.orEmpty().take(160).ifBlank { getString(R.string.media_reader_content_error) } + } + showError(explain, reload = false) + } + } + }) val position = restorePosition.toLong().coerceAtLeast(0L) if (video.audioUrl != null) { val videoSource = mediaSourceFactory.createMediaSource(item) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt index daad6dd8cb..cb159e06c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt @@ -14,6 +14,8 @@ data class BookCatalogListModel( val format: String? = null, val language: String? = null, val attribution: String? = null, + /** This row opens provider search; it is not a claim that a matching title was found. */ + val isOfficialSiteSearch: Boolean = false, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is BookCatalogListModel && other.url == url diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index 9fbed77910..235566bdc5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -18,6 +18,8 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.media.MediaType import org.koitharu.kotatsu.core.media.StoryWeaverCatalog import org.koitharu.kotatsu.core.media.StoryWeaverReaderActivity +import org.koitharu.kotatsu.core.media.OfficialMediaSites +import org.koitharu.kotatsu.core.media.OfficialFictionBrowserActivity import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings @@ -177,6 +179,9 @@ class SearchActivity : startActivity(Intent(this, StoryWeaverReaderActivity::class.java) .putExtra(StoryWeaverReaderActivity.EXTRA_URL, url) .putExtra(StoryWeaverReaderActivity.EXTRA_CREDIT, attribution)) + } else if (OfficialMediaSites.fictionSite(url) != null) { + startActivity(Intent(this, OfficialFictionBrowserActivity::class.java) + .putExtra(OfficialFictionBrowserActivity.EXTRA_URL, url)) } else { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt index 6adbcce65f..e9c4cb4791 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt @@ -10,6 +10,7 @@ import android.widget.TextView import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import java.util.Locale +import org.koitharu.kotatsu.core.prefs.ReaderMode /** Hide unsupported filter choices rather than claiming every catalog reports the same metadata. */ internal object SearchMetadataFilterDialog { @@ -62,6 +63,20 @@ internal object SearchMetadataFilterDialog { formatOptions.map { it ?: activity.getString(R.string.search_language_any) }, formatOptions.indexOf(viewModel.selectedBookFormat), ) else null + val directions = (viewModel.availableReaderModes().map { it.name } + + listOfNotNull(viewModel.selectedReadingMode)).distinct() + val directionOptions = listOf(null) + directions + val directionPicker = if (directions.isNotEmpty()) spinner( + R.string.search_reading_direction, + directionOptions.map { option -> when (option) { + "STANDARD" -> activity.getString(R.string.search_reading_ltr) + "REVERSED" -> activity.getString(R.string.search_reading_rtl) + "VERTICAL" -> activity.getString(R.string.search_reading_vertical) + "WEBTOON" -> activity.getString(R.string.search_reading_webtoon) + else -> activity.getString(R.string.search_language_any) + } }, + directionOptions.indexOf(viewModel.selectedReadingMode), + ) else null val historyModes = SearchMetadataFilters.History.entries val historyPicker = if (viewModel.hasHistoryCapableResults() || viewModel.selectedHistoryFilter != SearchMetadataFilters.History.ANY @@ -74,7 +89,7 @@ internal object SearchMetadataFilterDialog { ), historyModes.indexOf(viewModel.selectedHistoryFilter), ) else null - if (genre == null && languagePicker == null && formatPicker == null && historyPicker == null) { + if (genre == null && languagePicker == null && formatPicker == null && historyPicker == null && directionPicker == null) { content.addView(TextView(activity).apply { setText(R.string.search_metadata_unavailable) }) } val scroll = ScrollView(activity).apply { addView(content); isFillViewport = false } @@ -86,6 +101,7 @@ internal object SearchMetadataFilterDialog { viewModel.setBookFormatFilter(null) viewModel.setLanguageFilter(null) viewModel.setHistoryFilter(SearchMetadataFilters.History.ANY) + viewModel.setReadingModeFilter(null) } .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> @@ -93,6 +109,7 @@ internal object SearchMetadataFilterDialog { languagePicker?.let { viewModel.setLanguageFilter(languageOptions[it.selectedItemPosition]) } formatPicker?.let { viewModel.setBookFormatFilter(formatOptions[it.selectedItemPosition]) } historyPicker?.let { viewModel.setHistoryFilter(historyModes[it.selectedItemPosition]) } + directionPicker?.let { viewModel.setReadingModeFilter(directionOptions[it.selectedItemPosition]) } } .show() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt index 748c54022e..ab238fdbc5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt @@ -1,9 +1,13 @@ package org.koitharu.kotatsu.search.ui.multi /** Only real provider tags, catalog formats, and local reading history are filterable. */ -internal object SearchMetadataFilters { +object SearchMetadataFilters { enum class History { ANY, VIEWED, UNVIEWED } + /** Unrecorded reader direction is unknown, never guessed from language or provider. */ + fun directionMatches(savedMode: org.koitharu.kotatsu.core.prefs.ReaderMode?, chosenMode: String?): Boolean = + chosenMode.isNullOrBlank() || savedMode?.name == chosenMode + fun tagMatches(tags: Collection, selected: String?): Boolean = selected.isNullOrBlank() || tags.any { it.contains(selected.trim(), ignoreCase = true) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 17e3f6ed39..a21e75061e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.media.PublicBookCatalog +import org.koitharu.kotatsu.core.media.OfficialMediaSites import org.koitharu.kotatsu.core.media.OpdsCatalog import org.koitharu.kotatsu.core.media.MediaType import org.koitharu.kotatsu.builtin.BuiltinSource @@ -44,6 +45,8 @@ import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.ListMode @@ -81,6 +84,7 @@ private const val MAX_LOCALE_PRIORITY = 2.0 @HiltViewModel class SearchViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val mangaDataRepository: MangaDataRepository, @ApplicationContext private val applicationContext: Context, private val publicBookCatalog: PublicBookCatalog, private val opdsCatalog: OpdsCatalog, @@ -126,9 +130,15 @@ class SearchViewModel @Inject constructor( ) private val viewedIds = MutableStateFlow?>(null) private var viewedJob: Job? = null - private val metadataSelection = combine(genreFilter, progressFilter, viewedIds) { genre, progress, ids -> - Triple(genre, progress, ids) - } + private val readingModeFilter = MutableStateFlow(savedStateHandle.get("reading_mode_filter")) + private val savedReadingModes = MutableStateFlow>(emptyMap()) + private var directionJob: Job? = null + private data class MetadataSelection(val genre: String?, val progress: SearchMetadataFilters.History, + val viewed: Set?, val readingMode: String?, val knownModes: Map) + private val metadataSelection = combine( + combine(genreFilter, progressFilter, viewedIds) { genre, progress, ids -> Triple(genre, progress, ids) }, + readingModeFilter, savedReadingModes, + ) { simple, direction, modes -> MetadataSelection(simple.first, simple.second, simple.third, direction, modes) } private val includeUnknownYears = MutableStateFlow(savedStateHandle.get("include_unknown_years") ?: true) val isVideoModeSearch: Boolean get() = effectiveMediaType == MediaType.VIDEO @@ -161,8 +171,9 @@ class SearchViewModel @Inject constructor( val eligible = language == null || LanguageFilter.matches(section.source.getLocale()?.language, language) section.copy(list = if (!eligible) emptyList() else section.list.filter { row -> - SearchMetadataFilters.tagMatches(row.manga.tags.map { it.title }, selection.first) && - SearchMetadataFilters.historyMatches(row.manga.id, selection.second, selection.third) + SearchMetadataFilters.tagMatches(row.manga.tags.map { it.title }, selection.genre) && + SearchMetadataFilters.historyMatches(row.manga.id, selection.progress, selection.viewed) && + SearchMetadataFilters.directionMatches(selection.knownModes[row.manga.id], selection.readingMode) }) } }, @@ -230,6 +241,14 @@ class SearchViewModel @Inject constructor( val selectedGenre: String? get() = genreFilter.value val selectedBookFormat: String? get() = bookFormatFilter.value val selectedHistoryFilter: SearchMetadataFilters.History get() = progressFilter.value + val selectedReadingMode: String? get() = readingModeFilter.value + + fun availableReaderModes(): List = savedReadingModes.value.values.distinct().sortedBy { it.id } + + fun setReadingModeFilter(mode: String?) { + readingModeFilter.value = mode?.takeIf { code -> ReaderMode.entries.any { it.name == code } } + savedStateHandle["reading_mode_filter"] = readingModeFilter.value + } fun availableGenres(): List = SearchMetadataFilters.availableTags(results.value.flatMap { section -> @@ -267,6 +286,14 @@ class SearchViewModel @Inject constructor( } } + private fun loadSavedReadingModes() { + if (directionJob?.isActive == true) return + directionJob = viewModelScope.launch(Dispatchers.IO) { + savedReadingModes.value = runCatchingCancellable { mangaDataRepository.getExplicitReaderModes() } + .getOrDefault(emptyMap()) + } + } + /** One combined, globally ordered section replaces the per-source ones (sections with errors stay visible). */ private fun mergeGlobally( sections: List, @@ -297,15 +324,18 @@ class SearchViewModel @Inject constructor( val items: List = emptyList(), val loading: Boolean = false, ) - private val catalogState = MutableStateFlow(CatalogState(loading = effectiveMediaType == MediaType.BOOK)) + private val catalogState = MutableStateFlow(CatalogState(loading = effectiveMediaType == null || effectiveMediaType == MediaType.BOOK)) private val filteredCatalog = combine(catalogState, bookFormatFilter, progressFilter, genreFilter, languageFilter) { catalog, format, progress, genre, language -> catalog.copy(items = if (progress != SearchMetadataFilters.History.ANY || !genre.isNullOrBlank()) emptyList() else catalog.items.filter { SearchMetadataFilters.formatMatches(it.format, format) && SearchMetadataFilters.languageMatches(it.language, language) }) } + private val directionFilteredCatalog = combine(filteredCatalog, readingModeFilter) { catalog, direction -> + if (direction == null) catalog else catalog.copy(items = emptyList()) + } private val displayCatalog = combine( - filteredCatalog, publicationSort, yearFrom, yearTo, includeUnknownYears, + directionFilteredCatalog, publicationSort, yearFrom, yearTo, includeUnknownYears, ) { catalog, sort, from, to, unknown -> catalog.copy(items = PublicBookYearSorter.display(catalog.items, sort, from, to, unknown)) } @@ -354,6 +384,7 @@ class SearchViewModel @Inject constructor( init { if (progressFilter.value != SearchMetadataFilters.History.ANY) loadViewedIds() + loadSavedReadingModes() doSearch() } @@ -373,7 +404,7 @@ class SearchViewModel @Inject constructor( fun retry() { searchJob?.cancel() catalogJob?.cancel() - catalogState.value = CatalogState(loading = effectiveMediaType == MediaType.BOOK) + catalogState.value = CatalogState(loading = effectiveMediaType == null || effectiveMediaType == MediaType.BOOK) includeDisabledSources.value = false savedStateHandle["include_disabled_sources"] = false @@ -464,7 +495,8 @@ class SearchViewModel @Inject constructor( } private fun doSearch() { - if (effectiveMediaType == MediaType.BOOK) { + // Unified ALL search must really include books, not just parser-backed manga/video. + if (effectiveMediaType == null || effectiveMediaType == MediaType.BOOK) { catalogJob = viewModelScope.launch(Dispatchers.IO) { val publicResults = supervisorScope { listOf( @@ -474,7 +506,19 @@ class SearchViewModel @Inject constructor( async { searchSavedOpds(query) }, ).awaitAll().flatten() } - catalogState.value = CatalogState(publicResults.distinctBy { it.url }) + val officialFictionLinks = if (query.isBlank()) emptyList() else listOf( + OfficialMediaSites.Site.FICTIONPRESS to "FictionPress", + OfficialMediaSites.Site.FANFICTION to "FanFiction.net", + ).map { (site, provider) -> + BookCatalogListModel( + title = "Search $provider for: $query", author = "", + catalog = provider, url = OfficialMediaSites.searchUrl(site, query), + format = "Official-site search (results not indexed)", + attribution = "Browser verification or sign-in may be required", + isOfficialSiteSearch = true, + ) + } + catalogState.value = CatalogState(publicResults.distinctBy { it.url } + officialFictionLinks) } } else { catalogState.value = CatalogState() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt index 73bdd0f664..075090250d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt @@ -18,6 +18,7 @@ fun bookCatalogAD(onOpen: (BookCatalogListModel) -> Unit) = binding.monogatariCatalogItemSummary.text = listOf( item.author.takeIf { it.isNotBlank() }, item.catalog, + if (item.isOfficialSiteSearch) "Opens provider website" else null, item.publicationYear?.let { "Published $it" }, item.format, item.language, diff --git a/app/src/main/res/layout/activity_monogatari_test_center.xml b/app/src/main/res/layout/activity_monogatari_test_center.xml index 492691d630..753322b5d2 100644 --- a/app/src/main/res/layout/activity_monogatari_test_center.xml +++ b/app/src/main/res/layout/activity_monogatari_test_center.xml @@ -9,7 +9,8 @@ @@ -110,6 +111,8 @@ + + Not in this device’s history No compatible metadata yet. Search a source first; controls appear only when available. Clear filters + Navigate back + Font + Translation font + Clear sans-serif + Book serif + Handwritten + Could not redraw this page; the previous font was kept. + Reading direction (saved titles) + Left to right + Right to left + Vertical pages + Continuous webtoon + Direct playback failed; trying a compatible Plex server stream. + The video or audio codec is not supported by this device. In Plex, optimize this title or choose a different file. + Plex denied this media request. Check your server connection, permissions and Plex token. + This is the provider’s own website. Search results, sign-in and browser verification are controlled by the provider. Monogatari does not download restricted chapters. + Reload page + Open in browser + No external browser is available on this device. + Could not load the official website. Check your connection or open it in a browser. + Loading entertainment news + Loading podcast and video episodes + Retry feed diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepositoryTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepositoryTest.kt index e7148d76d9..3c855a90b6 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepositoryTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/builtin/plex/PlexRepositoryTest.kt @@ -30,6 +30,7 @@ class PlexRepositoryTest { val episodes = PlexRepository.parseEpisodes(body) assertEquals(listOf("S1 E1 · One", "S1 E2 · Two"), episodes.map { it.title }) assertEquals("/library/parts/1/1/a.mkv", episodes[0].partKey) + assertNull(episodes[0].metadataId) } @Test @@ -42,6 +43,16 @@ class PlexRepositoryTest { assertNull(PlexRepository.descriptionWithYear(0, null)) } + @Test + fun serverTranscodeRequiresValidMetadataAndKeepsTokensInHeaders() { + val url = PlexRepository.transcodeUrl("https://plex.example", "1234", "12345678-1234-4234-8234-123456789012") + assertTrue(url.startsWith("https://plex.example/video/:/transcode/universal/start.m3u8")) + assertTrue(url.contains("protocol=hls")) + assertTrue(url.contains("1280x720")) + assertTrue(url.contains("%2Flibrary%2Fmetadata%2F1234")) + assertTrue(!url.contains("X-Plex-Token")) + } + @Test fun movieFilePartAndUrlHelpers() { val movie = JSONObject("""{"MediaContainer":{"Metadata":[{"Media":[{"Part":[{"key":"/library/parts/9/9/m.mp4"}]}]}]}}""") diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSitesTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSitesTest.kt new file mode 100644 index 0000000000..61202ed4b6 --- /dev/null +++ b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialMediaSitesTest.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.core.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class OfficialMediaSitesTest { + @Test fun identifiesOnlyActualProviderOwnedSecureHosts() { + assertEquals(OfficialMediaSites.Site.FANFICTION, + OfficialMediaSites.fictionSite("https://www.fanfiction.net/search/?keywords=cat")) + assertEquals(OfficialMediaSites.Site.FICTIONPRESS, + OfficialMediaSites.fictionSite("https://m.fictionpress.com/s/1234/1/Example")) + assertNull(OfficialMediaSites.fictionSite("https://www.fanfiction.net.attacker.example/s/1")) + assertNull(OfficialMediaSites.fictionSite("http://www.fictionpress.com/")) + assertNull(OfficialMediaSites.fictionSite("https://user@www.fictionpress.com/")) + assertNull(OfficialMediaSites.fictionSite("file:///android_asset/secret")) + } + + @Test fun officialSearchLinksDoNotContainUnencodedUserQueries() { + val url = OfficialMediaSites.searchUrl(OfficialMediaSites.Site.FICTIONPRESS, "a & b") + assertTrue(url.startsWith("https://www.fictionpress.com/search/")) + assertTrue(url.contains("a+%26+b")) + assertEquals(OfficialMediaSites.Site.FICTIONPRESS, OfficialMediaSites.fictionSite(url)) + } +} diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt index ed8d4b6609..96592f4815 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/StoryWeaverCatalogTest.kt @@ -28,7 +28,7 @@ class StoryWeaverCatalogTest { assertEquals("English", book.language) assertTrue(book.url.endsWith("/21618-wild-cat-wild-cat")) assertTrue(book.attribution!!.contains("Rohan Chakravarty")) - assertTrue(book.attribution!!.contains("CC BY 4.0")) + assertTrue(book.attribution!!.contains("license and attribution")) assertEquals(null, book.downloadUrl) } diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt index f97911d4bf..f502743f86 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/main/ui/NavLayoutPolicyTest.kt @@ -20,4 +20,12 @@ class NavLayoutPolicyTest { assertTrue(NavLayoutPolicy.useTwoPane(600)) assertTrue(NavLayoutPolicy.useTwoPane(840)) } + + @Test + fun threeColumnLibraryOnlyAtDesktopWidth() { + assertTrue(NavLayoutPolicy.libraryListColumns(840) == 1) + assertTrue(NavLayoutPolicy.libraryListColumns(1199) == 1) + assertTrue(NavLayoutPolicy.libraryListColumns(1200) == 2) + assertTrue(NavLayoutPolicy.libraryListColumns(1440) == 2) + } } diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecarTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecarTest.kt index 89dd13a9eb..c84c35eeb0 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecarTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/reader/translate/TranslationSidecarTest.kt @@ -23,6 +23,14 @@ class TranslationSidecarTest { assertEquals(meta, TranslationSidecar.decode(TranslationSidecar.encode(meta))) } + @Test + fun olderCachedPagesLoadWithDefaultFontAndChoiceSurvivesRewrite() { + val older = """{"sourceLanguage":"ja","targetLanguage":"en","blocks":[]}""" + assertEquals("sans", TranslationSidecar.decode(older)?.fontStyle) + val styled = meta.copy(fontStyle = "serif") + assertEquals("serif", TranslationSidecar.decode(TranslationSidecar.encode(styled))?.fontStyle) + } + @Test fun garbageDecodesToNull() { assertNull(TranslationSidecar.decode("not json")) diff --git a/docs/CODE_FINISHING_14.md b/docs/CODE_FINISHING_14.md new file mode 100644 index 0000000000..a576d66fbf --- /dev/null +++ b/docs/CODE_FINISHING_14.md @@ -0,0 +1,28 @@ +# Monogatari: final fourteen Android roadmap items + +**Work branch:** `finish-83-20261015` (based on `devel` at `eaae55c`). +**Policy:** Complete and push app changes first. GitHub-hosted compilation checks may flag programming errors. Device/provider feature verification and the first official v1.0.0 release are **last**, not prerequisites for writing code. This sheet is **not** release sign-off. + +| ID | Code implementation on this finishing branch | Deferred final acceptance | +| --- | --- | --- | +| 11 | Unified Material3 chrome/Up affordance on QA, More and custom screens; consistent catalog/reader navigation | Inspect every specialized Activity's back/up and theme | +| 14 | Books metadata, editions, series, availability disclosures, official source options, covers | Sample providers for metadata/lending truth | +| 17 | Persistent search filters, query and library selection; Books skeleton, News rotation/scroll restoration and feed loading/retry | Rotate/search/interrupt each screen | +| 18 | Theme typography on More/news/media screens, responsive controls, translated font options | 200% font and all light/dark screen inspection | +| 21 | Connected paper flood mask, path-clipped OCR removal and panel-boundary guard | Review real manga speech bubbles on devices | +| 23 | Dynamic text fit, RTL/CJK layout and saved sans/serif/comic translation font | Real pages at different scales and scripts | +| 32 | Genre/demographic tags, source language, formats, actual local history and saved reader-direction filters | Compare mixed manga/book/video result sets | +| 37 | Conservative title+author/alternate-title grouping, source choice persisted per title | Search different creators and multiple sources | +| 45 | StoryWeaver live authorized public catalog metadata, covers, publisher/writer/illustrator credits and in-app official reader | Site display and rights/attribution spot checks | +| 48 | FictionPress/FanFiction.net clearly labeled official-site searches, contained in-app browser, reload and external browser fallback; no unsupported HTML parsing or CAPTCHA bypass | Log-in/CAPTCHA behavior belongs to the provider | +| 58 | Plex header-authenticated direct-play and optional token-safe server HLS fallback, codec/access errors | Test owner Plex server and media codecs | +| 63 | Search preview panes at 600dp/840dp; multi-column wide library and details | Narrow/wide tablet split screen | +| 66 | Saved search/genre/history/direction, library pane/collection/query, News filter/position and translated WebView state | Rotate and resize Razr foldable, tablet freeform | +| 69 | Catalog unlimited text lines, minimum touch targets, QA header/navigation labels, accessibility-focused static checks | TalkBack/contrast/font-scale review | + +Important constraints: +- The 14 tracker rows remain **PARTIAL** until the final acceptance stage. Code presence is not evidence that a provider or screen works on the owner's device. +- FictionPress/FanFiction.net do not have an available documented native discovery/chapter API in this integration. This is an explicit official-web experience, not a fictitious extension. +- Plex credentials must never appear in public URLs, logs, issue descriptions, or tests. Only use mocked IDs in source-controlled tests. +- Do not merge or publish a signed v1.0.0 based solely on an unverified build. Build/test evidence and owner acceptance belong to the last stage. +- Keep the original dirty P: checkout untouched; all development is in the dedicated clean-origin worktree. diff --git a/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md b/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md index 33632fcee6..687a9e0928 100644 --- a/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md +++ b/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md @@ -71,3 +71,9 @@ After PR #11 merged, published `devel` has **69 DONE, 14 PARTIAL, 0 PENDING** ou ## How to start the next chat “Please read `docs/MONOGATARI_NEXT_CHAT_HANDOFF.md` and `docs/ROADMAP_83_TRACKER.md` from `coolchan03/Monogatari`. Begin by requesting Tunnel to Surface permission. Verify live `devel`, open PRs, checkout status and latest CI. Continue completing the remaining PARTIAL Android roadmap items in 20–30 minute tested batches; push all GitHub writes via the tunnel. No Android SDK or emulator installation on my devices. Publish the first official signed v1.0.0 only when all in-scope functionality is implemented in code, then I will test it on two devices.” + +## 2026-10-16 supplemental code handoff + +Latest finishing branch: `finish-83-20261015` in `coolchan03/Monogatari`, isolated worktree `C:/Users/coolc/Monogatari-Finish83-20261015`. Do not reset the original dirty P: checkout. See `docs/CODE_FINISHING_14.md` for the fourteen target item implementations. Early pushed commits: `a4605f1`, `612f4f3`, `6370587`, `d58e2e5`, `a3081b8`. + +User specifically instructed: **feature verification must be the very last stage**. Write Android code and push via Tunnel to Surface first; treat GitHub compilation checks as programming feedback only and do not claim feature validation from them. Do not merge into `devel`, publish a signed version, or mark all 14 DONE before final full test/owner acceptance. There is no Windows project. diff --git a/docs/ROADMAP_83_TRACKER.md b/docs/ROADMAP_83_TRACKER.md index 7514e55367..8d589460f8 100644 --- a/docs/ROADMAP_83_TRACKER.md +++ b/docs/ROADMAP_83_TRACKER.md @@ -95,3 +95,10 @@ Legend: DONE = implemented in code; PARTIAL = incomplete or awaiting real-provid | 81 | DONE | Tablet emulation screenshots and layout checks; manual device QA only after CI. *(CI cold-starts on phone, portrait-tablet and landscape-tablet windows and keeps screenshots.)* | | 82 | DONE | Clearly distinguish debug preview, release signing and verified device-tested version. | | 83 | DONE | Preserve user data, backups, settings and reading progress across updates. *(Automatic pre-update copy of database + settings, last two kept; Settings > Backup > Undo an app update.)* | + +## Finishing-branch code updates (2026-10-16) + +**The original 69 DONE / 14 PARTIAL counts are preserved until final feature verification.** +The finishing branch `finish-83-20261015` contains source-code changes across **all fourteen** partial item areas; see [CODE_FINISHING_14.md](CODE_FINISHING_14.md) for each specific implementation and its remaining acceptance criterion. + +Development policy: first implement and push the Android code; GitHub Actions may flag compilation problems without being treated as user-facing feature verification; after the code is assembled, conduct automated checks, two-device acceptance, then publish v1.0.0. The rows above describe the pre-finishing baseline and should not be interpreted as the current code delta. diff --git a/scripts/validate-ui-a11y.py b/scripts/validate-ui-a11y.py index 7caf613563..bfbb490868 100644 --- a/scripts/validate-ui-a11y.py +++ b/scripts/validate-ui-a11y.py @@ -45,4 +45,29 @@ def element_by_id(path: Path, name: str): for relative in ("values/strings.xml", "layout/activity_monogatari_book_catalog.xml"): ET.parse(res / relative) -print("UI static checks passed: More layout, dynamic text, 48dp actions, skeletons, state restoration") +for item_id in ("monogatari_catalog_item_title", "monogatari_catalog_item_summary"): + text = element_by_id(catalog, item_id) + assert text.get(android + "maxLines") is None, f"{item_id}: truncated at 200% font size" + assert text.get(android + "ellipsize") is None, f"{item_id}: ellipsized at 200% font size" + assert text.get(android + "textAppearance"), f"{item_id}: missing scalable typography" +for name in ("layout-w600dp", "layout-w840dp"): + preview = res / name / "activity_search.xml" + element_by_id(preview, "search_preview_scroll") + element_by_id(preview, "search_preview_pane") + for action_id in ("search_preview_open", "search_preview_read"): + button = element_by_id(preview, action_id) + assert button.get(android + "layout_height") in ("wrap_content", "48dp"), "fixed-size preview action" +assert "SearchMetadataFilterDialog" in (src / "search" / "ui" / "multi" / "SearchMenuProvider.kt").read_text(encoding="utf-8") +assert "contentDescription" in (src / "search" / "ui" / "multi" / "SearchMetadataFilterDialog.kt").read_text(encoding="utf-8") +qa = res / "layout" / "activity_monogatari_test_center.xml" +qa_toolbar = element_by_id(qa, "qa_toolbar") +assert qa_toolbar.get(android + "layout_height") == "wrap_content", "QA toolbar clips text on large font settings" +assert qa_toolbar.get(android + "minHeight") == "?attr/actionBarSize" +for action_id in ("qa_pass", "qa_fail", "qa_untested"): + button = element_by_id(qa, action_id) + assert button.get(android + "minHeight") == "48dp", f"{action_id}: insufficient touch target" + assert button.get(android + "maxLines") == "2", f"{action_id}: cannot wrap with larger font" +qa_activity = (src / "core" / "qa" / "MonogatariTestCenterActivity.kt").read_text(encoding="utf-8") +assert "setSupportActionBar(ui.qaToolbar)" in qa_activity +assert "setNavigationContentDescription(R.string.monogatari_navigation_back)" in qa_activity +print("UI static checks passed: scalable text, accessible 48dp actions, adaptive search panes and consistent QA chrome") From f429d9e55a2dd5063174732e88e950feec682e09 Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:26:02 -0500 Subject: [PATCH 7/9] feat: offline EPUB reading, accurate search filters, tablet state and compile-only CI --- .github/workflows/code-compile.yml | 35 ++++++ README.md | 2 + .../kotatsu/core/media/BookLibraryActivity.kt | 58 +++++++-- .../koitharu/kotatsu/core/media/BookShelf.kt | 4 +- .../core/media/OfficialEpubDownloader.kt | 112 ++++++++++++++++++ .../media/OfficialFictionBrowserActivity.kt | 12 +- .../core/media/UnifiedLibraryActivity.kt | 15 ++- .../kotatsu/search/ui/multi/SearchActivity.kt | 39 ++++++ .../ui/multi/SearchMetadataFilterDialog.kt | 12 +- .../search/ui/multi/SearchMetadataFilters.kt | 5 +- .../search/ui/multi/SearchTabletPreview.kt | 6 +- .../search/ui/multi/SearchViewModel.kt | 13 +- app/src/main/res/values/ids.xml | 1 + app/src/main/res/values/strings.xml | 8 ++ .../core/media/OfficialEpubDownloaderTest.kt | 28 +++++ .../ui/multi/SearchMetadataFiltersTest.kt | 4 + docs/CODE_FINISHING_14.md | 8 ++ docs/MONOGATARI_NEXT_CHAT_HANDOFF.md | 3 + 18 files changed, 340 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/code-compile.yml create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloader.kt create mode 100644 app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloaderTest.kt diff --git a/.github/workflows/code-compile.yml b/.github/workflows/code-compile.yml new file mode 100644 index 0000000000..9814825cfb --- /dev/null +++ b/.github/workflows/code-compile.yml @@ -0,0 +1,35 @@ +# Development-only feedback while features are still being implemented. +# This workflow never runs tests, emulators, release signing or APK publishing. +name: Android code compilation + +on: + pull_request: + branches: + - devel + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: compile-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + compile: + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + - name: Configure extension repositories + env: + MONOGATARI_REPOS: ${{ secrets.MONOGATARI_REPOS }} + shell: bash + run: printf 'monogatari.repos=%s\n' "$MONOGATARI_REPOS" >> local.properties + - uses: gradle/actions/setup-gradle@v4 + - name: Compile app, resources, and JVM test sources (do not execute tests) + run: ./gradlew :app:compileDebugKotlin :app:compileDebugUnitTestKotlin --stacktrace --no-daemon diff --git a/README.md b/README.md index b506db85ee..20aa2c08c6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ This is a **signed beta prerelease**, not the first official v1.0.0. The develop Active development; some third-party sources and video extractors are unsupported. +The Android finishing work is in [draft PR #13](https://github.com/coolchan03/Monogatari/pull/13) (branch `finish-83-20261015`). The [14-item code ledger](docs/CODE_FINISHING_14.md) distinguishes implemented code from final provider and device verification. A dedicated **code-compilation-only** GitHub Action checks draft PRs without running feature tests or publishing APKs. Official v1.0.0 remains unreleased. + ## Source repositories [Curated extensions](https://github.com/coolchan03/Best-BL-Mangayomi-Extensions) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt index bcf96249ce..c3107c3d55 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.core.media +import okhttp3.OkHttpClient +import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.ui.MonogatariChrome import org.koitharu.kotatsu.core.ui.setMonogatariContent import org.koitharu.kotatsu.core.ui.Mascot @@ -50,6 +52,7 @@ class BookLibraryActivity : AppCompatActivity() { @Inject lateinit var catalog: PublicBookCatalog @Inject lateinit var imageLoader: ImageLoader @Inject lateinit var opds: OpdsCatalog + @Inject @BaseHttpClient lateinit var epubClient: OkHttpClient private val resultsAdapter = CatalogResultsAdapter() private lateinit var query: EditText private lateinit var catalogStatus: TextView @@ -416,9 +419,10 @@ class BookLibraryActivity : AppCompatActivity() { onOpen = { openCatalogGroup(group) }, onLongPress = { openCatalogGroup(group) }, actionLabel = when { - book.catalog == "Standard Ebooks" -> "Get EPUB" + book.catalog == "Standard Ebooks" -> "Save & read EPUB" book.catalog == "StoryWeaver" -> "Read story" - book.downloadUrl?.startsWith("https://") == true -> "Open EPUB" + book.downloadUrl?.let { OfficialEpubDownloader.approved(book.catalog, it) } == true -> "Save & read EPUB" + book.downloadUrl?.startsWith("https://") == true -> "Open EPUB externally" else -> "View listing" }, onAction = { @@ -426,7 +430,9 @@ class BookLibraryActivity : AppCompatActivity() { else if (book.catalog == "StoryWeaver") openStoryWeaverReader(book) else { val epub = book.downloadUrl?.takeIf { it.startsWith("https://") } - openOfficialLink(epub ?: book.url) + if (epub != null && OfficialEpubDownloader.approved(book.catalog, epub)) { + downloadAndRead(book, epub) + } else openOfficialLink(epub ?: book.url) } }, ) @@ -463,9 +469,10 @@ class BookLibraryActivity : AppCompatActivity() { val link = withContext(Dispatchers.IO) { runCatching { catalog.resolveStandardEbooksEpub(book.url) }.getOrNull() } - if (link == null) catalogStatus.text = - "Compatible EPUB unavailable. Opening the official listing instead." - openOfficialLink(link ?: book.url) + if (link == null) { + catalogStatus.text = getString(R.string.monogatari_epub_edition_missing) + openOfficialLink(book.url) + } else downloadAndRead(book, link) } } @@ -481,18 +488,28 @@ class BookLibraryActivity : AppCompatActivity() { if (book.catalog == "Standard Ebooks") { MaterialAlertDialogBuilder(this).setTitle(book.title) .setMessage(bookMetadataSummary(book)) - .setItems(arrayOf("View official listing", "Open compatible EPUB")) { _, which -> + .setItems(arrayOf("View official listing", "Save EPUB and read offline")) { _, which -> if (which == 1) openStandardEpub(book) else openOfficialLink(book.url) } .setNegativeButton(android.R.string.cancel, null).show() return } val epub = book.downloadUrl?.takeIf { it.startsWith("https://") } + val approvedEpub = epub?.takeIf { OfficialEpubDownloader.approved(book.catalog, it) } + val actions = when { + approvedEpub != null -> arrayOf("View ${book.catalog} listing", + "Save EPUB and read offline", "Open EPUB in browser") + epub != null -> arrayOf("View ${book.catalog} listing", "Open EPUB in browser") + else -> arrayOf("View ${book.catalog} listing") + } MaterialAlertDialogBuilder(this).setTitle(book.title) .setMessage(bookMetadataSummary(book)) - .setItems(if (epub == null) arrayOf("View ${book.catalog} listing") - else arrayOf("View ${book.catalog} listing", "Open available EPUB")) { _, which -> - openOfficialLink(if (which == 1 && epub != null) epub else book.url) + .setItems(actions) { _, which -> + when (actions[which]) { + "Save EPUB and read offline" -> approvedEpub?.let { downloadAndRead(book, it) } + "Open EPUB in browser" -> openOfficialLink(epub ?: book.url) + else -> openOfficialLink(book.url) + } }.setNegativeButton(android.R.string.cancel, null).show() } @@ -505,6 +522,27 @@ class BookLibraryActivity : AppCompatActivity() { book.format, book.availability, book.attribution, ).joinToString("\n") + private fun downloadAndRead(book: PublicBookCatalog.Book, link: String) { + if (!OfficialEpubDownloader.approved(book.catalog, link)) { + catalogStatus.text = getString(R.string.monogatari_epub_unapproved) + return + } + catalogStatus.text = getString(R.string.monogatari_epub_downloading, book.title) + lifecycleScope.launch { + try { + val library = File(filesDir, "imported-books") + val file = OfficialEpubDownloader.save(epubClient, library, book.title, book.catalog, link) + catalogStatus.text = getString(R.string.monogatari_epub_saved, book.title) + startActivity(BookShelf.readerIntent(this@BookLibraryActivity, file)) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Exception) { + catalogStatus.text = getString(R.string.monogatari_epub_download_error, + error.message.orEmpty().take(130)) + } + } + } + private fun openStoryWeaverReader(book: PublicBookCatalog.Book) { if (!StoryWeaverCatalog.officialStoryUrl(book.url)) { catalogStatus.text = getString(R.string.storyweaver_invalid_link) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookShelf.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookShelf.kt index 36e8793697..99ec7d1977 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookShelf.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookShelf.kt @@ -8,7 +8,9 @@ import java.io.File internal object BookShelf { fun list(context: Context, filter: String = ""): List = File(context.filesDir, "imported-books").listFiles().orEmpty() - .filter { it.isFile && it.name.contains(filter.trim(), ignoreCase = true) } + .filter { it.isFile && it.extension.lowercase() in + setOf("epub", "pdf", "txt", "text", "md", "markdown", "html", "htm", "xhtml") && + it.name.contains(filter.trim(), ignoreCase = true) } .sortedBy { it.name.lowercase() } /** Display the original filename, not the temporary import prefix or file extension. */ diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloader.kt new file mode 100644 index 0000000000..6e4f9e058b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloader.kt @@ -0,0 +1,112 @@ +package org.koitharu.kotatsu.core.media + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.IOException +import java.net.URI +import java.security.MessageDigest +import java.util.zip.ZipFile + +/** + * A user-clicked, permission-respecting offline import of a publisher-supplied free EPUB. + * Never attempts protected borrowing, DRM removal, login scraping or arbitrary URL download. + */ +internal object OfficialEpubDownloader { + private const val MAX_DOWNLOAD = 32L * 1024 * 1024 + private const val MAX_UNCOMPRESSED = 384L * 1024 * 1024 + private val GUTENBERG_EPUB = Regex("\\.epub(?:3)?(?:\\.(?:images|noimages))?$", RegexOption.IGNORE_CASE) + + fun approved(catalog: String, url: String): Boolean { + val uri = runCatching { URI(url) }.getOrNull() ?: return false + if (uri.scheme != "https" || uri.userInfo != null || uri.fragment != null) return false + if (uri.port != -1 && uri.port != 443) return false + val path = uri.path.orEmpty() + if (path.contains("..") || path.contains('\\')) return false + return when (catalog) { + "Standard Ebooks" -> StandardEbooksCatalog.officialPage(url) && + "/downloads/" in path && path.endsWith(".epub", ignoreCase = true) + "Project Gutenberg" -> uri.host?.lowercase() in + setOf("gutenberg.org", "www.gutenberg.org") && + (path.startsWith("/ebooks/") || path.startsWith("/cache/epub/")) && + GUTENBERG_EPUB.containsMatchIn(path) + else -> false + } + } + + suspend fun save(client: OkHttpClient, libraryDir: File, title: String, catalog: String, url: String): File = + withContext(Dispatchers.IO) { + if (!approved(catalog, url)) throw IOException("This catalog did not provide an approved free EPUB URL") + if (!libraryDir.isDirectory && !libraryDir.mkdirs()) throw IOException("Cannot create the offline book library") + val digest = MessageDigest.getInstance("SHA-256").digest(url.toByteArray(Charsets.UTF_8)) + .take(8).joinToString("") { "%02x".format(it) } + val filename = title.trim().replace(Regex("[^\\p{L}\\p{N}._ -]"), "_") + .trim(' ', '.').take(80).ifBlank { "ebook" } + val destination = File(libraryDir, "book-$digest-$filename.epub") + if (destination.isFile && destination.length() > 0) return@withContext destination + val temporary = File.createTempFile("book-$digest-", ".partial", libraryDir) + try { + val request = Request.Builder().url(url).get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("The publisher returned HTTP ${response.code}") + if (!approved(catalog, response.request.url.toString())) { + throw IOException("The publisher redirected to an unapproved download location") + } + val body = response.body ?: throw IOException("The publisher returned an empty download") + if (body.contentLength() > MAX_DOWNLOAD) throw IOException("The EPUB exceeds the size limit") + var total = 0L + body.byteStream().use { input -> + temporary.outputStream().use { output -> + val buffer = ByteArray(16_384) + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(buffer) + if (count < 0) break + total += count + if (total > MAX_DOWNLOAD) throw IOException("The EPUB exceeds the size limit") + output.write(buffer, 0, count) + } + } + } + if (total == 0L) throw IOException("The publisher returned an empty download") + } + validateEpub(temporary) + if (!temporary.renameTo(destination)) { + if (!destination.isFile) throw IOException("Unable to save the downloaded EPUB") + } + return@withContext destination + } finally { + temporary.delete() + } + } + + private fun validateEpub(file: File) { + ZipFile(file).use { zip -> + val mimetype = zip.getEntry("mimetype") ?: throw IOException("Not a valid EPUB: mimetype is missing") + if (mimetype.size !in 1L..64L) throw IOException("Invalid EPUB mimetype") + val value = zip.getInputStream(mimetype).use { input -> + val bytes = ByteArray(64) + val size = input.read(bytes) + if (size <= 0) "" else String(bytes, 0, size, Charsets.US_ASCII) + } + if (value != "application/epub+zip") throw IOException("The file is not an EPUB") + var expanded = 0L + var entries = 0 + val contents = zip.entries() + while (contents.hasMoreElements()) { + val entry = contents.nextElement() + entries++ + if (entries > 12_000 || entry.name.startsWith("/") || + entry.name.contains('\\') || entry.name.split('/').any { it == ".." } + ) throw IOException("The EPUB archive contains unsafe entries") + val length = entry.size.coerceAtLeast(0) + if (length > MAX_UNCOMPRESSED - expanded) throw IOException("The EPUB archive is too large") + expanded += length + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt index 7dee91e547..492b6574a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/OfficialFictionBrowserActivity.kt @@ -32,12 +32,14 @@ class OfficialFictionBrowserActivity : AppCompatActivity() { const val EXTRA_URL = "official_fiction_browser_url" } private lateinit var browser: WebView + private lateinit var allowedSite: OfficialMediaSites.Site @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val initial = intent.getStringExtra(EXTRA_URL).orEmpty() val provider = OfficialMediaSites.fictionSite(initial) ?: run { finish(); return } + allowedSite = provider title = when (provider) { OfficialMediaSites.Site.FICTIONPRESS -> "FictionPress" OfficialMediaSites.Site.FANFICTION -> "FanFiction.net" @@ -62,7 +64,7 @@ class OfficialFictionBrowserActivity : AppCompatActivity() { buttons.addView(MaterialButton(this).apply { setText(R.string.official_fiction_external) setOnClickListener { - val current = browser.url?.takeIf { OfficialMediaSites.fictionSite(it) != null } ?: initial + val current = browser.url?.takeIf { OfficialMediaSites.fictionSite(it) == allowedSite } ?: initial runCatching { startActivity(Intent(Intent.ACTION_VIEW, current.toUri())) } .onFailure { notice.setText(R.string.official_fiction_no_browser) } } @@ -94,6 +96,12 @@ class OfficialFictionBrowserActivity : AppCompatActivity() { notice.setText(R.string.official_fiction_connection_failed) } } + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, response: android.webkit.WebResourceResponse?) { + if (request?.isForMainFrame == true && response != null && + (response.statusCode == 403 || response.statusCode == 429 || response.statusCode == 503)) { + notice.setText(R.string.official_fiction_access_limited) + } + } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { notice.setText(R.string.official_fiction_disclosure) } @@ -111,7 +119,7 @@ class OfficialFictionBrowserActivity : AppCompatActivity() { private fun handleNavigation(url: Uri, mainFrame: Boolean): Boolean { val location = url.toString() - if (OfficialMediaSites.fictionSite(location) != null) return false + if (OfficialMediaSites.fictionSite(location) == allowedSite) return false if (mainFrame && url.scheme == "https") { runCatching { startActivity(Intent(Intent.ACTION_VIEW, url)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt index 33ff32c0ed..f447f7aaea 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/UnifiedLibraryActivity.kt @@ -26,6 +26,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.Job @@ -156,6 +157,8 @@ class UnifiedLibraryActivity : AppCompatActivity() { gravity = android.view.Gravity.CENTER_HORIZONTAL }) val list = RecyclerView(this).apply { + // Stable resource ID lets RecyclerView defer state restoration until async rows load. + id = R.id.monogatari_library_results_list layoutManager = GridLayoutManager(this@UnifiedLibraryActivity, if (twoPane) NavLayoutPolicy.libraryListColumns(resources.configuration.screenWidthDp) else MonogatariChrome.columns(context)) @@ -170,7 +173,7 @@ class UnifiedLibraryActivity : AppCompatActivity() { } else { setMonogatariContent(root, MonogatariChrome.LIST_MAX_WIDTH_DP) } - refresh() + // onResume refreshes when the activity first opens and when a reader changes progress. } private fun buildPane(px: Int): View { @@ -317,6 +320,7 @@ class UnifiedLibraryActivity : AppCompatActivity() { mascot.isVisible = false refreshJob?.cancel() refreshJob = lifecycleScope.launch { + try { all = withContext(Dispatchers.IO) { val groups = MediaType.entries.associateWith { it.persistedSourceNames() } val remote = favorites.getAllManga().distinctBy { it.id }.map { manga -> @@ -345,8 +349,15 @@ class UnifiedLibraryActivity : AppCompatActivity() { } (remote + local).sortedBy { it.title.lowercase() } } - loading.visibility = View.GONE render() + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Exception) { + // Keep the previous library visible and explain an I/O or database problem. + count.text = getString(R.string.library_load_failed, error.message.orEmpty().take(160)) + } finally { + loading.visibility = View.GONE + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index 235566bdc5..7c73a3afcb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -56,6 +56,8 @@ class SearchActivity : companion object { /** Intent scope for a one-off book search; never mutates the global media filter. */ const val EXTRA_MEDIA_TYPE = "monogatari.search.media_type" + private const val STATE_PREVIEW_MANGA_ID = "search.preview.manga_id" + private const val STATE_PREVIEW_BOOK_URL = "search.preview.book_url" } @Inject @@ -64,10 +66,15 @@ class SearchActivity : private val viewModel by viewModels() private lateinit var selectionController: ListSelectionController private var tabletPreview: SearchTabletPreview? = null + private var pendingPreviewMangaId: Long? = null + private var pendingPreviewBookUrl: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySearchBinding.inflate(layoutInflater)) + // View recreation builds a new pane, but its previously selected title should remain selected. + pendingPreviewMangaId = savedInstanceState?.getLong(STATE_PREVIEW_MANGA_ID)?.takeIf { it != 0L } + pendingPreviewBookUrl = savedInstanceState?.getString(STATE_PREVIEW_BOOK_URL) tabletPreview = SearchTabletPreview.attach( root = viewBinding.root, onDetails = { openMangaWithSourceChoice(it, false) }, @@ -126,9 +133,41 @@ class SearchActivity : addMenuProvider(SearchMenuProvider(this, viewModel)) viewModel.list.observe(this, adapter) + viewModel.list.observe(this) { items -> restoreTabletPreview(items) } viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } + private fun restoreTabletPreview(items: List) { + val pane = tabletPreview ?: return + val mangaId = pendingPreviewMangaId + if (mangaId != null) { + val row = items.filterIsInstance() + .asSequence().flatMap { it.list.asSequence() } + .firstOrNull { it.manga.id == mangaId } + if (row != null) { + pane.show(row) + pendingPreviewMangaId = null + return + } + } + val bookUrl = pendingPreviewBookUrl + if (bookUrl != null) { + val row = items.filterIsInstance().firstOrNull { it.url == bookUrl } + if (row != null) { + pane.show(row) + pendingPreviewBookUrl = null + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + tabletPreview?.selectedId?.let { outState.putLong(STATE_PREVIEW_MANGA_ID, it) } + val mangaId = tabletPreview?.selectedId + if (mangaId == null) pendingPreviewMangaId?.let { outState.putLong(STATE_PREVIEW_MANGA_ID, it) } + outState.putString(STATE_PREVIEW_BOOK_URL, tabletPreview?.selectedBookUrl ?: pendingPreviewBookUrl) + super.onSaveInstanceState(outState) + } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.toolbar.updatePadding( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt index e9c4cb4791..ff296beb33 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilterDialog.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.search.ui.multi import android.view.View import android.widget.ArrayAdapter -import android.widget.EditText +import android.widget.AutoCompleteTextView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.Spinner @@ -41,8 +41,10 @@ internal object SearchMetadataFilterDialog { val availableTags = viewModel.availableGenres() val genre = if (availableTags.isNotEmpty() || viewModel.selectedGenre != null) { label(R.string.search_genre_tag_label) - EditText(activity).apply { + AutoCompleteTextView(activity).apply { setSingleLine(true) + setAdapter(ArrayAdapter(activity, android.R.layout.simple_dropdown_item_1line, availableTags)) + threshold = 1 setText(viewModel.selectedGenre.orEmpty()) setHint(R.string.search_genre_tag_hint) contentDescription = activity.getString(R.string.search_genre_tag_label) @@ -53,7 +55,11 @@ internal object SearchMetadataFilterDialog { val languageOptions = listOf(null) + languages val languagePicker = if (languages.isNotEmpty()) spinner( R.string.search_language_label, - languageOptions.map { it?.let { code -> Locale.forLanguageTag(code).displayLanguage } ?: activity.getString(R.string.search_language_any) }, + languageOptions.map { code -> when { + code == null -> activity.getString(R.string.search_language_any) + code.length in 2..3 -> Locale.forLanguageTag(code).displayLanguage + else -> code + } }, languageOptions.indexOf(viewModel.selectedLanguage), ) else null val formats = (viewModel.availableBookFormats() + listOfNotNull(viewModel.selectedBookFormat)).distinct() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt index ab238fdbc5..65f5ad4e39 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFilters.kt @@ -18,9 +18,10 @@ object SearchMetadataFilters { } fun languageMatches(language: String?, selected: String?): Boolean = - selected.isNullOrBlank() || language.isNullOrBlank() || + selected.isNullOrBlank() || (!language.isNullOrBlank() && ( language.equals(selected, ignoreCase = true) || - language.equals(java.util.Locale.forLanguageTag(selected).getDisplayLanguage(java.util.Locale.ENGLISH), ignoreCase = true) + language.equals(java.util.Locale.forLanguageTag(selected).getDisplayLanguage(java.util.Locale.ENGLISH), ignoreCase = true) || + selected.equals(java.util.Locale.forLanguageTag(language).getDisplayLanguage(java.util.Locale.ENGLISH), ignoreCase = true))) fun formatMatches(format: String?, selected: String?): Boolean = selected.isNullOrBlank() || (format?.contains(selected, ignoreCase = true) == true) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt index 12953ab180..617a33ec31 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt @@ -28,6 +28,8 @@ internal class SearchTabletPreview private constructor( private var selectedManga: Manga? = null private var selectedUrl: String? = null val selectedId: Long? get() = selectedManga?.id + /** Stable identity for restoring a public-book preview after window resizing or rotation. */ + val selectedBookUrl: String? get() = selectedUrl init { open.setOnClickListener { @@ -57,7 +59,9 @@ internal class SearchTabletPreview private constructor( cover.isVisible = !item.coverUrl.isNullOrBlank() cover.setImageAsync(item.coverUrl) showCommon(item.title, item.catalog, - listOfNotNull(item.author.takeIf { it.isNotBlank() }, item.format, item.language, item.attribution) + listOfNotNull(item.author.takeIf { it.isNotBlank() }, + if (item.isOfficialSiteSearch) title.context.getString(R.string.search_official_site_only) else null, + item.format, item.language, item.attribution) .joinToString(" · ").ifBlank { title.context.getString(R.string.tablet_search_no_description) }) open.setText(R.string.tablet_search_website) read.isVisible = false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index a21e75061e..1ec9ed1e14 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -166,10 +166,10 @@ class SearchViewModel @Inject constructor( ?: PublicationYearOrder.RELEVANCE, ) private val displayResults = combine( - combine(results, languageFilter, preferredSourceVersion, metadataSelection) { all, language, _, selection -> + combine(results, languageFilter, bookFormatFilter, preferredSourceVersion, metadataSelection) { all, language, format, _, selection -> all.map { section -> - val eligible = language == null || - LanguageFilter.matches(section.source.getLocale()?.language, language) + val eligible = format == null && (language == null || + SearchMetadataFilters.languageMatches(section.source.getLocale()?.language, language)) section.copy(list = if (!eligible) emptyList() else section.list.filter { row -> SearchMetadataFilters.tagMatches(row.manga.tags.map { it.title }, selection.genre) && SearchMetadataFilters.historyMatches(row.manga.id, selection.progress, selection.viewed) && @@ -229,6 +229,8 @@ class SearchViewModel @Inject constructor( /** Languages of the sources that answered so far, for the language filter. */ fun languagesInResults(): List = LanguageFilter.available(results.value.map { it.source.getLocale()?.language }) + .plus(catalogState.value.items.mapNotNull { it.language?.takeIf(String::isNotBlank) }) + .distinct().sorted() val selectedLanguage: String? get() = languageFilter.value @@ -243,7 +245,10 @@ class SearchViewModel @Inject constructor( val selectedHistoryFilter: SearchMetadataFilters.History get() = progressFilter.value val selectedReadingMode: String? get() = readingModeFilter.value - fun availableReaderModes(): List = savedReadingModes.value.values.distinct().sortedBy { it.id } + fun availableReaderModes(): List { + val visibleIds = results.value.flatMap { it.list }.mapTo(HashSet()) { it.manga.id } + return savedReadingModes.value.filterKeys { it in visibleIds }.values.distinct().sortedBy { it.id } + } fun setReadingModeFilter(mode: String?) { readingModeFilter.value = mode?.takeIf { code -> ReaderMode.entries.any { it.name == code } } diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 707c393db2..71cf69ae85 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6381850b34..4455007ed3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1462,4 +1462,12 @@ Optional — skip it and you appear as anon. Loading entertainment news Loading podcast and video episodes Retry feed + Official-site search, not an indexed result + Library could not be loaded: %1$s. Reopen the library to retry. + The provider requires browser verification or has limited automated access. Use Open in browser to continue on the official site. + No compatible EPUB was listed. Opening the publisher’s website. + This publisher did not provide an approved free EPUB download. + Saving %1$s for offline reading… + Saved %1$s to your library. + Could not save the EPUB: %1$s diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloaderTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloaderTest.kt new file mode 100644 index 0000000000..94ffdc835b --- /dev/null +++ b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/OfficialEpubDownloaderTest.kt @@ -0,0 +1,28 @@ +package org.koitharu.kotatsu.core.media + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OfficialEpubDownloaderTest { + @Test fun acceptsPublisherListedPublicDomainEpubDownloads() { + assertTrue(OfficialEpubDownloader.approved("Standard Ebooks", + "https://standardebooks.org/ebooks/jane-austen/pride-and-prejudice/downloads/jane-austen_pride-and-prejudice.epub")) + assertTrue(OfficialEpubDownloader.approved("Project Gutenberg", + "https://www.gutenberg.org/ebooks/74.epub3.images")) + assertTrue(OfficialEpubDownloader.approved("Project Gutenberg", + "https://www.gutenberg.org/cache/epub/74/pg74-images.epub")) + } + + @Test fun rejectsArbitraryMirrorsLoginAndHostLookalikes() { + assertFalse(OfficialEpubDownloader.approved("Google Books", "https://example.com/book.epub")) + assertFalse(OfficialEpubDownloader.approved("Standard Ebooks", + "https://standardebooks.org.evil.example/ebooks/a/downloads/b.epub")) + assertFalse(OfficialEpubDownloader.approved("Standard Ebooks", + "http://standardebooks.org/ebooks/a/downloads/b.epub")) + assertFalse(OfficialEpubDownloader.approved("Project Gutenberg", + "https://example.org/ebooks/74.epub")) + assertFalse(OfficialEpubDownloader.approved("Project Gutenberg", + "https://user@www.gutenberg.org/ebooks/74.epub")) + } +} diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt index 1cf99a3fea..880df6ec16 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchMetadataFiltersTest.kt @@ -21,7 +21,11 @@ class SearchMetadataFiltersTest { @Test fun sourceLanguageCodesMatchProviderLanguageNames() { assertTrue(SearchMetadataFilters.languageMatches("English", "en")) assertTrue(SearchMetadataFilters.languageMatches("en", "en")) + assertTrue(SearchMetadataFilters.languageMatches("en", "English")) assertFalse(SearchMetadataFilters.languageMatches("Japanese", "en")) + assertFalse(SearchMetadataFilters.languageMatches(null, "en")) + assertFalse(SearchMetadataFilters.languageMatches("", "en")) + assertTrue(SearchMetadataFilters.languageMatches(null, null)) } @Test fun filtersCatalogFormatsWithoutPretendingUnknownIsEpub() { assertTrue(SearchMetadataFilters.formatMatches("EPUB", "epub")) diff --git a/docs/CODE_FINISHING_14.md b/docs/CODE_FINISHING_14.md index a576d66fbf..9ecd1c862e 100644 --- a/docs/CODE_FINISHING_14.md +++ b/docs/CODE_FINISHING_14.md @@ -26,3 +26,11 @@ Important constraints: - Plex credentials must never appear in public URLs, logs, issue descriptions, or tests. Only use mocked IDs in source-controlled tests. - Do not merge or publish a signed v1.0.0 based solely on an unverified build. Build/test evidence and owner acceptance belong to the last stage. - Keep the original dirty P: checkout untouched; all development is in the dedicated clean-origin worktree. + +## Next implementation batch (October 16, 2026) + +- #14: Added a user-triggered offline EPUB import for compatible free Standard Ebooks and Project Gutenberg releases; publisher link and redirect host validation, 32 MiB transfer limit, ZIP/mimetype checks, 384 MiB expanded-size guard, temporary-file cleanup, stable local shelf identity, and direct launch into the in-app EPUB reader. Protected/borrowed items remain official-link-only. +- #32: Format filters no longer leave unrelated parser-backed manga/video rows visible; selecting a language does not falsely include unknown-language books, and genre offers real provider-tag suggestions. Reading-direction choices are limited to titles in the visible search data. +- #63/#66: Tablet search preview restores the selected manga/official catalog item across rotation after results load. Unified Library RecyclerView now has a persistent Android resource ID for scroll-state restoration, with a recoverable library I/O error state. +- #48: Fiction browsing remains restricted to the provider originally opened and offers explicit feedback for site HTTP 403/429/503, without pretending anti-bot access is a native parser. +- GitHub development workflow: `.github/workflows/code-compile.yml` compiles app and unit-test **source code only**, with no emulator, signing, runtime feature tests, or APK publishing. Full verification deliberately remains the last phase. diff --git a/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md b/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md index 687a9e0928..ad08a94e99 100644 --- a/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md +++ b/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md @@ -77,3 +77,6 @@ After PR #11 merged, published `devel` has **69 DONE, 14 PARTIAL, 0 PENDING** ou Latest finishing branch: `finish-83-20261015` in `coolchan03/Monogatari`, isolated worktree `C:/Users/coolc/Monogatari-Finish83-20261015`. Do not reset the original dirty P: checkout. See `docs/CODE_FINISHING_14.md` for the fourteen target item implementations. Early pushed commits: `a4605f1`, `612f4f3`, `6370587`, `d58e2e5`, `a3081b8`. User specifically instructed: **feature verification must be the very last stage**. Write Android code and push via Tunnel to Surface first; treat GitHub compilation checks as programming feedback only and do not claim feature validation from them. Do not merge into `devel`, publish a signed version, or mark all 14 DONE before final full test/owner acceptance. There is no Windows project. + +### October 16 follow-up development batch +Draft [PR #13](https://github.com/coolchan03/Monogatari/pull/13) is the exclusive integration route. Next code work: authorized offline EPUB import with format validation, accurate global metadata filtering, tablet preview and library state restoration, official-fiction HTTP error handling, and new GitHub-hosted **compile-only** workflow (`.github/workflows/code-compile.yml`). Preserve the distinction between compilation and full feature verification. Keep tracker statuses PARTIAL until the actual last-stage acceptance. From f4c932e88db0e12aa9a7efed422dea538ce6dd05 Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:30:56 -0500 Subject: [PATCH 8/9] fix: import resources for media feed loading controls --- .../kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt index 3d8913455c..4314c911ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/MediaFeedActivity.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.media +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.MonogatariChrome import org.koitharu.kotatsu.core.ui.setMonogatariContent import android.content.Intent From a3837b221ad127ca3f031b007e54617ec447b8c6 Mon Sep 17 00:00:00 2001 From: coolchan03 <126184077+coolchan03@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:11:43 -0500 Subject: [PATCH 9/9] feat: share all public book catalogs with global search and offline EPUB actions --- README.md | 4 + .../kotatsu/core/media/BookLibraryActivity.kt | 19 +-- .../kotatsu/core/media/PublicBookCatalog.kt | 17 ++- .../core/media/PublicCatalogProvider.kt | 34 +++++ .../kotatsu/list/ui/adapter/ListItemType.kt | 1 + .../ui/adapter/TypedListSpacingDecoration.kt | 3 +- .../search/ui/multi/BookCatalogListModel.kt | 3 + .../search/ui/multi/CatalogNoticeListModel.kt | 11 ++ .../kotatsu/search/ui/multi/SearchActivity.kt | 79 ++++++++++- .../search/ui/multi/SearchTabletPreview.kt | 20 +-- .../search/ui/multi/SearchViewModel.kt | 124 +++++++++++++----- .../search/ui/multi/adapter/BookCatalogAD.kt | 1 + .../ui/multi/adapter/CatalogNoticeAD.kt | 22 ++++ .../search/ui/multi/adapter/SearchAdapter.kt | 1 + app/src/main/res/values/strings.xml | 6 + .../core/media/PublicCatalogProviderTest.kt | 25 ++++ docs/CODE_FINISHING_14.md | 9 ++ docs/MONOGATARI_NEXT_CHAT_HANDOFF.md | 3 + 18 files changed, 315 insertions(+), 67 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProvider.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/CatalogNoticeListModel.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/CatalogNoticeAD.kt create mode 100644 app/src/test/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProviderTest.kt diff --git a/README.md b/README.md index 20aa2c08c6..766860e27b 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,7 @@ The Android finishing work is in [draft PR #13](https://github.com/coolchan03/Mo ## Credits Based on [Kotatsu-Redo](https://github.com/Kotatsu-Redo/Kotatsu-Redo) and inspired by [Mangayomi](https://github.com/kodjodevf/mangayomi). Original contributors retain their credits. + +### Unified Books search development + +Draft [PR #13](https://github.com/coolchan03/Monogatari/pull/13) now shares the full eleven-provider public Books registry with the global search, displays partial results as providers answer, and provides explicit source-availability notices. Where a publisher supplies a compatible free EPUB, global search can save it into the local reader library. Features remain **in development, not device-verified**. diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt index c3107c3d55..cd80e26158 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/BookLibraryActivity.kt @@ -357,8 +357,7 @@ class BookLibraryActivity : AppCompatActivity() { catalogStatus.text = "Searching ${if (source == "All") "all catalogs" else source}..." catalogJob = lifecycleScope.launch { val providers = if (source == "All") - listOf("Gutenberg", "Open Library", "Archive", "Wikisource", "Google Books", "Standard Ebooks", - "Faded Page", "Gutenberg Australia", "Global Grey", "Baen Free Library", "StoryWeaver") + PublicCatalogProvider.entries.map { it.label } else listOf(source) try { // A slow, restricted or unreachable catalog cannot hide other providers' results. @@ -408,6 +407,7 @@ class BookLibraryActivity : AppCompatActivity() { group.entries.mapNotNull { it.publicationYear }.minOrNull()?.toString(), book.language?.takeIf { it.isNotBlank() }, book.format?.takeIf { it.isNotBlank() }, + book.genres.firstOrNull()?.takeIf { it.isNotBlank() }, book.series?.let { getString(R.string.monogatari_catalog_series, it) }, book.editionCount?.let { getString(R.string.monogatari_catalog_editions, it) }, book.availability, @@ -571,20 +571,7 @@ class BookLibraryActivity : AppCompatActivity() { } private suspend fun searchProvider(source: String, term: String): List = - when (source) { - "Gutenberg" -> catalog.searchGutenberg(term) - "Open Library" -> catalog.searchOpenLibrary(term) - "Archive" -> catalog.searchInternetArchive(term) - "Wikisource" -> catalog.searchWikisource(term) - "Google Books" -> catalog.searchGoogleBooks(term) - "Standard Ebooks" -> catalog.searchStandardEbooks(term) - "Faded Page" -> catalog.searchFadedPage(term) - "Gutenberg Australia" -> catalog.searchGutenbergAustralia(term) - "Global Grey" -> catalog.searchGlobalGrey(term) - "Baen Free Library" -> catalog.searchBaenFreeLibrary(term) - "StoryWeaver" -> catalog.searchStoryWeaver(term) - else -> emptyList() - } + PublicCatalogProvider.fromLabel(source)?.search(catalog, term).orEmpty() private fun showSortOptions() { val options = BookCatalogSorter.Order.entries diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt index 3344665359..1f9b339ea9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicBookCatalog.kt @@ -8,6 +8,7 @@ import okhttp3.FormBody import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.qa.NewsFeedParser import org.json.JSONObject +import org.json.JSONArray import java.net.URLEncoder import javax.inject.Inject import java.util.concurrent.ConcurrentHashMap @@ -35,6 +36,8 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: /** Metadata describes access conditions, not a guarantee that lending is available now. */ val availability: String? = null, val attribution: String? = null, + /** Actual provider-supplied subjects/categories, never inferred from a title. */ + val genres: List = emptyList(), ) suspend fun searchGutenberg(query: String, page: Int = 1): List = withContext(Dispatchers.IO) { @@ -49,7 +52,8 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: Book(obj.optString("title"), obj.optJSONArray("authors")?.optJSONObject(0)?.optString("name").orEmpty(), "Project Gutenberg", "https://www.gutenberg.org/ebooks/$id", epub, coverUrl = image, language = obj.optJSONArray("languages")?.optString(0), - format = if (epub != null) "EPUB" else "Online book") + format = if (epub != null) "EPUB" else "Online book", + genres = readProviderTopics(obj.optJSONArray("subjects"))) } } @@ -71,7 +75,8 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: language = obj.optJSONArray("language")?.optString(0), format = "Online catalog", series = obj.optJSONArray("series")?.optString(0)?.takeIf { it.isNotBlank() }, editionCount = obj.optInt("edition_count").takeIf { it > 0 }, - availability = BookAccessMetadata.openLibraryAccess(obj.optString("ebook_access"))) + availability = BookAccessMetadata.openLibraryAccess(obj.optString("ebook_access")), + genres = readProviderTopics(obj.optJSONArray("subject"))) } } @@ -143,7 +148,8 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: downloadUrl = access.epubUrl, coverUrl = cover, publicationYear = year, language = info.optString("language"), - format = access.label) to access.freeToRead + format = access.label, + genres = readProviderTopics(info.optJSONArray("categories"))) to access.freeToRead }.sortedByDescending { it.second }.map { it.first } } @@ -260,5 +266,10 @@ class PublicBookCatalog @Inject constructor(@BaseHttpClient private val client: } } + /** Long, untrusted provider subject lists are bounded for the filter picker. */ + private fun readProviderTopics(values: JSONArray?): List = (0 until minOf(20, values?.length() ?: 0)) + .mapNotNull { i -> values?.optString(i)?.trim()?.take(90)?.takeIf(String::isNotBlank) } + .distinctBy { it.lowercase() } + private fun encode(value: String) = URLEncoder.encode(value, "UTF-8") } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProvider.kt new file mode 100644 index 0000000000..126ce822d4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProvider.kt @@ -0,0 +1,34 @@ +package org.koitharu.kotatsu.core.media + +/** Public book catalogs shared by global and Books search. */ +internal enum class PublicCatalogProvider(val label: String) { + GUTENBERG("Gutenberg"), + OPEN_LIBRARY("Open Library"), + INTERNET_ARCHIVE("Archive"), + WIKISOURCE("Wikisource"), + GOOGLE_BOOKS("Google Books"), + STANDARD_EBOOKS("Standard Ebooks"), + FADED_PAGE("Faded Page"), + GUTENBERG_AUSTRALIA("Gutenberg Australia"), + GLOBAL_GREY("Global Grey"), + BAEN_FREE("Baen Free Library"), + STORYWEAVER("StoryWeaver"); + suspend fun search(catalog: PublicBookCatalog, term: String): List = when (this) { + GUTENBERG -> catalog.searchGutenberg(term) + OPEN_LIBRARY -> catalog.searchOpenLibrary(term) + INTERNET_ARCHIVE -> catalog.searchInternetArchive(term) + WIKISOURCE -> catalog.searchWikisource(term) + GOOGLE_BOOKS -> catalog.searchGoogleBooks(term) + STANDARD_EBOOKS -> catalog.searchStandardEbooks(term) + FADED_PAGE -> catalog.searchFadedPage(term) + GUTENBERG_AUSTRALIA -> catalog.searchGutenbergAustralia(term) + GLOBAL_GREY -> catalog.searchGlobalGrey(term) + BAEN_FREE -> catalog.searchBaenFreeLibrary(term) + STORYWEAVER -> catalog.searchStoryWeaver(term) + } + + companion object { + fun fromLabel(value: String): PublicCatalogProvider? = + entries.firstOrNull { it.label == value } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt index 3b17e17b9e..bc74a305b8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt @@ -36,4 +36,5 @@ enum class ListItemType { CHAPTER_GRID, SOURCE_REPLACEMENT, BOOK_CATALOG, + BOOK_CATALOG_NOTICE, } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt index fd1eab315a..db0cf49120 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt @@ -48,7 +48,8 @@ class TypedListSpacingDecoration( ListItemType.MANGA_LIST_DETAILED, -> outRect.set(spacingNormal) - ListItemType.BOOK_CATALOG -> outRect.set(spacingSmall) + ListItemType.BOOK_CATALOG, + ListItemType.BOOK_CATALOG_NOTICE -> outRect.set(spacingSmall) ListItemType.PAGE_THUMB -> outRect.set(spacingNormal) ListItemType.MANGA_GRID -> outRect.set(0) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt index cb159e06c4..4305283e2f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/BookCatalogListModel.kt @@ -16,6 +16,9 @@ data class BookCatalogListModel( val attribution: String? = null, /** This row opens provider search; it is not a claim that a matching title was found. */ val isOfficialSiteSearch: Boolean = false, + /** Publisher-supplied free file URL; never fabricate downloads from catalog pages. */ + val downloadUrl: String? = null, + val genres: List = emptyList(), ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is BookCatalogListModel && other.url == url diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/CatalogNoticeListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/CatalogNoticeListModel.kt new file mode 100644 index 0000000000..1371be68ef --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/CatalogNoticeListModel.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.search.ui.multi + +import org.koitharu.kotatsu.list.ui.model.ListModel + +internal data class CatalogNoticeListModel( + val title: String, + val description: String, +) : ListModel { + override fun areItemsTheSame(other: ListModel): Boolean = + other is CatalogNoticeListModel && other.title == title +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index 7c73a3afcb..3511ba0825 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -3,6 +3,14 @@ package org.koitharu.kotatsu.search.ui.multi import android.content.Intent import android.net.Uri import android.os.Bundle +import java.io.File +import okhttp3.OkHttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -16,6 +24,11 @@ import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.media.MediaType +import org.koitharu.kotatsu.core.media.PublicBookCatalog +import org.koitharu.kotatsu.core.media.OfficialEpubDownloader +import org.koitharu.kotatsu.core.media.BookShelf +import org.koitharu.kotatsu.core.media.StandardEbooksCatalog +import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.media.StoryWeaverCatalog import org.koitharu.kotatsu.core.media.StoryWeaverReaderActivity import org.koitharu.kotatsu.core.media.OfficialMediaSites @@ -62,6 +75,10 @@ class SearchActivity : @Inject lateinit var settings: AppSettings + @Inject + lateinit var publicBookCatalog: PublicBookCatalog + @Inject @BaseHttpClient + lateinit var epubClient: OkHttpClient private val viewModel by viewModels() private lateinit var selectionController: ListSelectionController @@ -79,7 +96,7 @@ class SearchActivity : root = viewBinding.root, onDetails = { openMangaWithSourceChoice(it, false) }, onRead = { openMangaWithSourceChoice(it, true) }, - onUrl = { openCatalogWebsite(it) }, + onBookOpen = { openCatalogResult(it) }, ) title = when (viewModel.kind) { SearchKind.SIMPLE, @@ -120,7 +137,7 @@ class SearchActivity : selectionDecoration = selectionDecoration, onBookOpen = { book -> if (tabletPreview != null) tabletPreview?.show(book) - else openCatalogWebsite(book.url, book.attribution) + else openCatalogResult(book) }, ) viewBinding.recyclerView.adapter = adapter @@ -212,6 +229,64 @@ class SearchActivity : } } + /** A global book result has the same offline actions as Books, on phones and tablet panes. */ + private fun openCatalogResult(book: BookCatalogListModel) { + if (book.isOfficialSiteSearch || book.catalog == "StoryWeaver") { + openCatalogWebsite(book.url, book.attribution) + return + } + val direct = book.downloadUrl?.takeIf { OfficialEpubDownloader.approved(book.catalog, it) } + val canResolve = book.catalog == "Standard Ebooks" && StandardEbooksCatalog.officialPage(book.url) + if (direct == null && !canResolve) { + openCatalogWebsite(book.url, book.attribution) + return + } + val labels = listOf( + getString(R.string.search_book_save_offline), + getString(R.string.search_book_official_listing), + ) + if (direct != null) listOf(getString(R.string.search_book_open_in_browser)) else emptyList() + MaterialAlertDialogBuilder(this).setTitle(book.title) + .setItems(labels.toTypedArray()) { _, index -> + when (index) { + 0 -> saveCatalogBook(book) + 1 -> openCatalogWebsite(book.url, book.attribution) + 2 -> direct?.let { openCatalogWebsite(it) } + } + } + .setNegativeButton(android.R.string.cancel, null).show() + } + + private fun saveCatalogBook(book: BookCatalogListModel) { + val progress = Snackbar.make(viewBinding.root, + getString(R.string.monogatari_epub_downloading, book.title), Snackbar.LENGTH_INDEFINITE) + val download = lifecycleScope.launch { + try { + val url = if (book.catalog == "Standard Ebooks") withContext(Dispatchers.IO) { + publicBookCatalog.resolveStandardEbooksEpub(book.url) + } else book.downloadUrl + if (url == null || !OfficialEpubDownloader.approved(book.catalog, url)) { + Snackbar.make(viewBinding.root, R.string.monogatari_epub_unapproved, Snackbar.LENGTH_LONG).show() + return@launch + } + val stored = OfficialEpubDownloader.save(epubClient, File(filesDir, "imported-books"), + book.title, book.catalog, url) + Snackbar.make(viewBinding.root, getString(R.string.monogatari_epub_saved, book.title), + Snackbar.LENGTH_SHORT).show() + startActivity(BookShelf.readerIntent(this@SearchActivity, stored)) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Exception) { + Snackbar.make(viewBinding.root, + getString(R.string.monogatari_epub_download_error, error.message.orEmpty().take(130)), + Snackbar.LENGTH_LONG).show() + } finally { + progress.dismiss() + } + } + progress.setAction(android.R.string.cancel) { download.cancel() } + progress.show() + } + /** StoryWeaver opens its authorized illustrated reader in-app, rather than an unrelated browser tab. */ private fun openCatalogWebsite(url: String, attribution: String? = null) { if (StoryWeaverCatalog.officialStoryUrl(url)) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt index 617a33ec31..8dd13fa10a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchTabletPreview.kt @@ -16,7 +16,7 @@ internal class SearchTabletPreview private constructor( root: View, private val onDetails: (Manga) -> Unit, private val onRead: (Manga) -> Unit, - private val onUrl: (String) -> Unit, + private val onBookOpen: (BookCatalogListModel) -> Unit, ) { private val hint = root.findViewById(R.id.search_preview_hint) private val cover = root.findViewById(R.id.search_preview_cover) @@ -26,15 +26,15 @@ internal class SearchTabletPreview private constructor( private val open = root.findViewById(R.id.search_preview_open) private val read = root.findViewById(R.id.search_preview_read) private var selectedManga: Manga? = null - private var selectedUrl: String? = null + private var selectedBook: BookCatalogListModel? = null val selectedId: Long? get() = selectedManga?.id /** Stable identity for restoring a public-book preview after window resizing or rotation. */ - val selectedBookUrl: String? get() = selectedUrl + val selectedBookUrl: String? get() = selectedBook?.url init { open.setOnClickListener { val manga = selectedManga - if (manga != null) onDetails(manga) else selectedUrl?.let(onUrl) + if (manga != null) onDetails(manga) else selectedBook?.let(onBookOpen) } read.setOnClickListener { selectedManga?.let(onRead) } } @@ -42,7 +42,7 @@ internal class SearchTabletPreview private constructor( fun show(item: MangaListModel) { val manga = item.toMangaWithOverride() selectedManga = manga - selectedUrl = null + selectedBook = null val content = manga.description?.takeIf { it.isNotBlank() } showCommon(item.title, item.source.getTitle(title.context).toString(), content?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_COMPACT) } @@ -55,15 +55,15 @@ internal class SearchTabletPreview private constructor( fun show(item: BookCatalogListModel) { selectedManga = null - selectedUrl = item.url + selectedBook = item cover.isVisible = !item.coverUrl.isNullOrBlank() cover.setImageAsync(item.coverUrl) showCommon(item.title, item.catalog, listOfNotNull(item.author.takeIf { it.isNotBlank() }, if (item.isOfficialSiteSearch) title.context.getString(R.string.search_official_site_only) else null, - item.format, item.language, item.attribution) + item.format, item.language, item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank), item.attribution) .joinToString(" · ").ifBlank { title.context.getString(R.string.tablet_search_no_description) }) - open.setText(R.string.tablet_search_website) + open.setText(R.string.search_book_options) read.isVisible = false } @@ -84,10 +84,10 @@ internal class SearchTabletPreview private constructor( root: View, onDetails: (Manga) -> Unit, onRead: (Manga) -> Unit, - onUrl: (String) -> Unit, + onBookOpen: (BookCatalogListModel) -> Unit, ): SearchTabletPreview? { if (root.findViewById(R.id.search_preview_pane) == null) return null - return SearchTabletPreview(root, onDetails, onRead, onUrl) + return SearchTabletPreview(root, onDetails, onRead, onBookOpen) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 1ec9ed1e14..defe692cb4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -30,6 +31,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.media.PublicBookCatalog +import org.koitharu.kotatsu.core.media.PublicCatalogProvider import org.koitharu.kotatsu.core.media.OfficialMediaSites import org.koitharu.kotatsu.core.media.OpdsCatalog import org.koitharu.kotatsu.core.media.MediaType @@ -256,9 +258,10 @@ class SearchViewModel @Inject constructor( } fun availableGenres(): List = - SearchMetadataFilters.availableTags(results.value.flatMap { section -> - section.list.flatMap { row -> row.manga.tags.map { it.title } } - }) + SearchMetadataFilters.availableTags( + results.value.flatMap { section -> section.list.flatMap { row -> row.manga.tags.map { it.title } } } + + catalogState.value.items.flatMap { it.genres }, + ) fun availableBookFormats(): List = SearchMetadataFilters.availableFormats(catalogState.value.items.map { it.format }) @@ -328,13 +331,19 @@ class SearchViewModel @Inject constructor( private data class CatalogState( val items: List = emptyList(), val loading: Boolean = false, + val byProvider: Map> = emptyMap(), + val officialLinks: List = emptyList(), + val failures: List = emptyList(), + val completed: Int = 0, + val expected: Int = 0, ) private val catalogState = MutableStateFlow(CatalogState(loading = effectiveMediaType == null || effectiveMediaType == MediaType.BOOK)) private val filteredCatalog = combine(catalogState, bookFormatFilter, progressFilter, genreFilter, languageFilter) { catalog, format, progress, genre, language -> - catalog.copy(items = if (progress != SearchMetadataFilters.History.ANY || !genre.isNullOrBlank()) emptyList() + catalog.copy(items = if (progress != SearchMetadataFilters.History.ANY) emptyList() else catalog.items.filter { SearchMetadataFilters.formatMatches(it.format, format) && - SearchMetadataFilters.languageMatches(it.language, language) }) + SearchMetadataFilters.languageMatches(it.language, language) && + SearchMetadataFilters.tagMatches(it.genres, genre) }) } private val directionFilteredCatalog = combine(filteredCatalog, readingModeFilter) { catalog, direction -> if (direction == null) catalog else catalog.copy(items = emptyList()) @@ -345,6 +354,7 @@ class SearchViewModel @Inject constructor( catalog.copy(items = PublicBookYearSorter.display(catalog.items, sort, from, to, unknown)) } private var catalogJob: Job? = null + @Volatile private var catalogRunId = 0L /** * Where each source's results belong on screen, keyed by source name. Sources answer in whatever @@ -367,7 +377,13 @@ class SearchViewModel @Inject constructor( } else { list }.sortedForDisplay() - val combined: List = filteredList + catalog.items + val problems = if (catalog.loading || catalog.failures.isEmpty()) emptyList() else + listOf(CatalogNoticeListModel( + applicationContext.getString(R.string.search_catalog_unavailable_title), + applicationContext.getString(R.string.search_catalog_unavailable_detail, + catalog.failures.distinct().joinToString(", ")), + )) + val combined: List = filteredList + catalog.items + problems when { combined.isEmpty() -> listOf( when { @@ -500,30 +516,42 @@ class SearchViewModel @Inject constructor( } private fun doSearch() { - // Unified ALL search must really include books, not just parser-backed manga/video. - if (effectiveMediaType == null || effectiveMediaType == MediaType.BOOK) { + // Search every catalog that is available from Books; stream each provider as it answers. + val runId = ++catalogRunId + if ((effectiveMediaType == null || effectiveMediaType == MediaType.BOOK) && query.isNotBlank()) { + val officialFictionLinks = listOf( + OfficialMediaSites.Site.FICTIONPRESS to "FictionPress", + OfficialMediaSites.Site.FANFICTION to "FanFiction.net", + ).map { (site, provider) -> + BookCatalogListModel( + title = "Search $provider for: $query", author = "", + catalog = provider, url = OfficialMediaSites.searchUrl(site, query), + format = "Official-site search (results not indexed)", + attribution = "Browser verification or sign-in may be required", + isOfficialSiteSearch = true, + ) + } + catalogState.value = CatalogState( + items = officialFictionLinks, officialLinks = officialFictionLinks, + loading = true, expected = PublicCatalogProvider.entries.size + 1, + ) catalogJob = viewModelScope.launch(Dispatchers.IO) { - val publicResults = supervisorScope { - listOf( - async { safeCatalogSearch { publicBookCatalog.searchInternetArchive(query) } }, - async { safeCatalogSearch { publicBookCatalog.searchStandardEbooks(query) } }, - async { safeCatalogSearch { publicBookCatalog.searchStoryWeaver(query) } }, - async { searchSavedOpds(query) }, - ).awaitAll().flatten() - } - val officialFictionLinks = if (query.isBlank()) emptyList() else listOf( - OfficialMediaSites.Site.FICTIONPRESS to "FictionPress", - OfficialMediaSites.Site.FANFICTION to "FanFiction.net", - ).map { (site, provider) -> - BookCatalogListModel( - title = "Search $provider for: $query", author = "", - catalog = provider, url = OfficialMediaSites.searchUrl(site, query), - format = "Official-site search (results not indexed)", - attribution = "Browser verification or sign-in may be required", - isOfficialSiteSearch = true, - ) + supervisorScope { + val gate = Semaphore(5) + val publicJobs = PublicCatalogProvider.entries.map { provider -> + launch { + val fetched = gate.withPermit { + safeCatalogSearch { provider.search(publicBookCatalog, query) } + } + appendCatalog(runId, provider.label, fetched) + } + } + val savedCatalogs = launch { + val saved = runCatchingCancellable { searchSavedOpds(query) } + appendCatalog(runId, "Saved OPDS", CatalogFetch(saved.getOrDefault(emptyList()), unavailable = saved.isFailure)) + } + (publicJobs + savedCatalogs).joinAll() } - catalogState.value = CatalogState(publicResults.distinctBy { it.url } + officialFictionLinks) } } else { catalogState.value = CatalogState() @@ -570,22 +598,48 @@ class SearchViewModel @Inject constructor( return lastError } - /** Public catalogs share the Books search list without masquerading as manga parsers. */ + private data class CatalogFetch( + val items: List, + val unavailable: Boolean, + ) + + /** Keep results stable in Books-provider order, even when network responses complete out of order. */ + private fun appendCatalog(runId: Long, provider: String, fetched: CatalogFetch) { + catalogState.update { current -> + if (runId != catalogRunId) current else { + val byProvider = current.byProvider + (provider to fetched.items) + val providerOrder = PublicCatalogProvider.entries.map { it.label } + "Saved OPDS" + val entries = providerOrder.flatMap { byProvider[it].orEmpty() } + .distinctBy { it.url }.take(350) + current.officialLinks + val done = current.completed + 1 + current.copy( + items = entries, byProvider = byProvider, completed = done, + loading = done < current.expected, + failures = if (fetched.unavailable) current.failures + provider else current.failures, + ) + } + } + } + + /** Timeouts/errors are disclosed without leaking provider URLs or user account details. */ private suspend fun safeCatalogSearch( block: suspend () -> List, - ): List = try { - withTimeout(15_000) { - block().map { + ): CatalogFetch = try { + val books = withTimeout(15_000) { + block().take(25).map { BookCatalogListModel(it.title, it.author, it.catalog, it.url, it.publicationYear, - coverUrl = it.coverUrl, format = it.format, language = it.language, attribution = it.attribution) + coverUrl = it.coverUrl, format = it.format, language = it.language, + attribution = it.attribution, downloadUrl = it.downloadUrl, + genres = it.genres) } } + CatalogFetch(books, unavailable = false) } catch (_: TimeoutCancellationException) { - emptyList() + CatalogFetch(emptyList(), unavailable = true) } catch (cancel: CancellationException) { throw cancel } catch (_: Exception) { - emptyList() + CatalogFetch(emptyList(), unavailable = true) } /** Saved OPDS feeds can be searched within their first page; full remote OPDS search is not assumed. */ diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt index 075090250d..3a3d7da643 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/BookCatalogAD.kt @@ -22,6 +22,7 @@ fun bookCatalogAD(onOpen: (BookCatalogListModel) -> Unit) = item.publicationYear?.let { "Published $it" }, item.format, item.language, + item.genres.firstOrNull(), ).filterNotNull().joinToString(" · ") binding.root.setOnClickListener { onOpen(item) } binding.root.contentDescription = "${item.title}, ${binding.monogatariCatalogItemSummary.text}" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/CatalogNoticeAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/CatalogNoticeAD.kt new file mode 100644 index 0000000000..860416cd61 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/CatalogNoticeAD.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.search.ui.multi.adapter + +import androidx.core.view.isVisible +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemMonogatariCatalogBinding +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.search.ui.multi.CatalogNoticeListModel + +fun catalogNoticeAD() = + adapterDelegateViewBinding( + { inflater, parent -> ItemMonogatariCatalogBinding.inflate(inflater, parent, false) }, + ) { + bind { + binding.monogatariCatalogItemTitle.text = item.title + binding.monogatariCatalogItemSummary.text = item.description + binding.monogatariCatalogItemCover.isVisible = false + binding.monogatariCatalogItemAction.isVisible = false + binding.root.isClickable = false + binding.root.isFocusable = true + binding.root.contentDescription = "${item.title}. ${item.description}" + } + } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt index 236321c99a..4ab11537d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt @@ -39,6 +39,7 @@ class SearchAdapter( ), ) addDelegate(ListItemType.BOOK_CATALOG, bookCatalogAD(onBookOpen)) + addDelegate(ListItemType.BOOK_CATALOG_NOTICE, catalogNoticeAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(listener)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4455007ed3..ef18e6137d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1470,4 +1470,10 @@ Optional — skip it and you appear as anon. Saving %1$s for offline reading… Saved %1$s to your library. Could not save the EPUB: %1$s + Some public book catalogs are unavailable + Could not retrieve results from: %1$s. Results from other sources are still displayed. + Save EPUB and read offline + Open official listing + Open EPUB in browser + Open or save diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProviderTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProviderTest.kt new file mode 100644 index 0000000000..a9a0854d21 --- /dev/null +++ b/app/src/test/kotlin/org/koitharu/kotatsu/core/media/PublicCatalogProviderTest.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.core.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PublicCatalogProviderTest { + @Test fun allBooksProvidersAreAvailableToUnifiedSearch() { + val labels = PublicCatalogProvider.entries.map { it.label } + assertEquals(11, labels.size) + assertEquals(labels.size, labels.toSet().size) + assertTrue("Open Library" in labels) + assertTrue("Gutenberg" in labels) + assertTrue("StoryWeaver" in labels) + assertTrue("Wikisource" in labels) + assertTrue("Standard Ebooks" in labels) + } + + @Test fun unsupportedCatalogCannotBeImpersonatedByName() { + assertEquals(PublicCatalogProvider.OPEN_LIBRARY, PublicCatalogProvider.fromLabel("Open Library")) + assertNull(PublicCatalogProvider.fromLabel("Open Library mirror")) + assertNull(PublicCatalogProvider.fromLabel("")) + } +} diff --git a/docs/CODE_FINISHING_14.md b/docs/CODE_FINISHING_14.md index 9ecd1c862e..b8404af803 100644 --- a/docs/CODE_FINISHING_14.md +++ b/docs/CODE_FINISHING_14.md @@ -34,3 +34,12 @@ Important constraints: - #63/#66: Tablet search preview restores the selected manga/official catalog item across rotation after results load. Unified Library RecyclerView now has a persistent Android resource ID for scroll-state restoration, with a recoverable library I/O error state. - #48: Fiction browsing remains restricted to the provider originally opened and offers explicit feedback for site HTTP 403/429/503, without pretending anti-bot access is a native parser. - GitHub development workflow: `.github/workflows/code-compile.yml` compiles app and unit-test **source code only**, with no emulator, signing, runtime feature tests, or APK publishing. Full verification deliberately remains the last phase. + +## Follow-up implementation batch: unified public catalogs and offline actions (October 16) + +- A **single registry** in `PublicCatalogProvider.kt` now drives all 11 existing public Books providers and the global "All"/Books search. The global search no longer quietly excludes Gutenberg, Open Library, Wikisource, Google Books, Faded Page, Gutenberg Australia, Global Grey and Baen Free Library. +- Public catalog results are appended **progressively** with bounded parallelism and 15-second source timeouts. They retain stable provider order, preserve results from successful sources and show a separate, **non-clickable** notice naming providers that did not respond. Official FictionPress/FanFiction.net website search links stay explicitly marked as such, not indexed native results. +- Genre and demographic filters can now use **actual provider-reported book subjects**, where available, from Gutenberg, Open Library and Google Books. The catalog never guesses subjects based on an unrelated title. +- Global-search results now carry publisher-reported free EPUB links. Eligible Standard Ebooks and Project Gutenberg results offer the same "Save EPUB and read offline" flow as Books, including from the tablet preview pane; protected and unapproved sources still open official listings. +- `PublicCatalogProviderTest.kt` adds source-coverage regression cases. New adapter state distinguishes source failures from empty titles; no feature/device acceptance has been performed. +- **Do not mark any of the 14 PARTIAL items DONE yet.** GitHub Actions should run only the compile-only workflow during this code-development phase. Provider access, live downloads, device layout and user acceptance remain final-stage work. diff --git a/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md b/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md index ad08a94e99..4b0b43fab3 100644 --- a/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md +++ b/docs/MONOGATARI_NEXT_CHAT_HANDOFF.md @@ -80,3 +80,6 @@ User specifically instructed: **feature verification must be the very last stage ### October 16 follow-up development batch Draft [PR #13](https://github.com/coolchan03/Monogatari/pull/13) is the exclusive integration route. Next code work: authorized offline EPUB import with format validation, accurate global metadata filtering, tablet preview and library state restoration, official-fiction HTTP error handling, and new GitHub-hosted **compile-only** workflow (`.github/workflows/code-compile.yml`). Preserve the distinction between compilation and full feature verification. Keep tracker statuses PARTIAL until the actual last-stage acceptance. + +### October 16 next code batch: global Books completeness +Branch `finish-83-20261015`, draft PR #13. Shared `PublicCatalogProvider` registry covers all eleven Books providers in global search; concurrent results stream progressively while failed providers get an informational row. Gutenberg/Open Library/Google Books subjects power book genre filtering; global results retain official free EPUB URLs and provide in-app offline save/read choices (including tablet preview). This code is **not final-feature-tested**. The compile-only GitHub workflow is allowed; defer provider/device verification, merge, signing and release until the final stage. Details: `docs/CODE_FINISHING_14.md`.