- table.select("div.manga2")
- }.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
- val href = a.relUrl("href")
+ chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr ->
+ val href = tr?.selectFirst("a")?.relUrl("href") ?: return@mapIndexedNotNull null
MangaChapter(
id = generateUid(href),
- name = a.text().trim(),
+ name = tr.selectFirst("a")?.text().orEmpty(),
number = i + 1,
url = href,
- source = source
+ scanlator = null,
+ branch = null,
+ uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
+ source = source,
)
}
)
@@ -112,8 +119,9 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
MangaPage(
id = generateUid(url),
url = url,
+ preview = null,
referer = fullUrl,
- source = source
+ source = source,
)
}
}
@@ -123,12 +131,12 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
override suspend fun getTags(): Set
{
val domain = getDomain()
val doc = loaderContext.httpGet("https://$domain/catalog").parseHtml()
- val root = doc.body().selectFirst("div.main_fon").getElementById("side")
- .select("ul").last()
+ val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
+ ?.select("ul")?.last() ?: throw ParseException("Cannot find root")
return root.select("li.sidetag").mapToSet { li ->
- val a = li.children().last()
+ val a = li.children().last() ?: throw ParseException("a is null")
MangaTag(
- title = a.text().capitalize(),
+ title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
@@ -150,4 +158,5 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
SortOrder.NEWEST -> "datedesc"
else -> "favdesc"
}
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt
index c09530342..afae79bab 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt
@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.util.*
-import kotlin.collections.ArrayList
class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@@ -21,11 +20,11 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
SortOrder.ALPHABETICAL
)
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
if (query != null && offset != 0) {
return emptyList()
@@ -38,9 +37,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
append(getSortKey(sortOrder))
append("&page=")
append((offset / 20) + 1)
- if (tag != null) {
+ if (!tags.isNullOrEmpty()) {
append("&genres=")
- append(tag.key)
+ appendAll(tags, ",") { it.key }
}
if (query != null) {
append("&search=")
@@ -94,12 +93,17 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
description = json.getString("description"),
chapters = chaptersList.mapIndexed { i, it ->
val chid = it.getLong("id")
+ val volChap = "Том " + it.getString("vol") + ". " + "Глава " + it.getString("ch")
+ val title = if (it.getString("title") == "null") "" else it.getString("title")
MangaChapter(
id = generateUid(chid),
source = manga.source,
url = "$baseChapterUrl$chid",
- name = it.getStringOrNull("title") ?: "${manga.title} #${it.getDouble("ch")}",
- number = totalChapters - i
+ uploadDate = it.getLong("date") * 1000,
+ name = if (title.isEmpty()) volChap else "$volChap: $title",
+ number = totalChapters - i,
+ scanlator = null,
+ branch = null,
)
}.reversed()
)
@@ -114,20 +118,22 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
MangaPage(
id = generateUid(jo.getLong("id")),
referer = fullUrl,
+ preview = null,
source = chapter.source,
- url = jo.getString("img")
+ url = jo.getString("img"),
)
}
}
override suspend fun getTags(): Set {
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml()
- val root = doc.body().getElementById("animeFilter").selectFirst(".catalog-genres")
+ val root = doc.body().getElementById("animeFilter")
+ ?.selectFirst(".catalog-genres") ?: throw ParseException("Root not found")
return root.select("li").mapToSet {
MangaTag(
source = source,
- key = it.selectFirst("input").attr("data-genre"),
- title = it.selectFirst("label").text()
+ key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(),
+ title = it.selectFirst("label")?.text() ?: parseFailed()
)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt
new file mode 100644
index 000000000..41b86750e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt
@@ -0,0 +1,260 @@
+package org.koitharu.kotatsu.core.parser.site
+
+import org.jsoup.nodes.Element
+import org.koitharu.kotatsu.base.domain.MangaLoaderContext
+import org.koitharu.kotatsu.core.model.*
+import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
+import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
+import org.koitharu.kotatsu.utils.ext.*
+import kotlin.math.pow
+
+private const val DOMAIN_UNAUTHORIZED = "e-hentai.org"
+private const val DOMAIN_AUTHORIZED = "exhentai.org"
+
+class ExHentaiRepository(
+ loaderContext: MangaLoaderContext,
+) : RemoteMangaRepository(loaderContext), MangaRepositoryAuthProvider {
+
+ override val source = MangaSource.EXHENTAI
+
+ override val defaultDomain: String
+ get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED
+
+ override val authUrl: String
+ get() = "https://${getDomain()}/bounce_login.php"
+
+ private val ratingPattern = Regex("-?[0-9]+px")
+ private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
+ private var updateDm = false
+
+ init {
+ loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
+ loaderContext.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
+ }
+
+ override suspend fun getList2(
+ offset: Int,
+ query: String?,
+ tags: Set?,
+ sortOrder: SortOrder?,
+ ): List {
+ val page = (offset / 25f).toIntUp()
+ var search = query?.urlEncoded().orEmpty()
+ val url = buildString {
+ append("https://")
+ append(getDomain())
+ append("/?page=")
+ append(page)
+ if (!tags.isNullOrEmpty()) {
+ var fCats = 0
+ for (tag in tags) {
+ tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run {
+ search += tag.key + " "
+ }
+ }
+ if (fCats != 0) {
+ append("&f_cats=")
+ append(1023 - fCats)
+ }
+ }
+ if (search.isNotEmpty()) {
+ append("&f_search=")
+ append(search.trim().replace(' ', '+'))
+ }
+ // by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
+ if (updateDm) {
+ append("&inline_set=dm_e")
+ }
+ }
+ val body = loaderContext.httpGet(url).parseHtml().body()
+ val root = body.selectFirst("table.itg")
+ ?.selectFirst("tbody")
+ ?: if (updateDm) {
+ parseFailed("Cannot find root")
+ } else {
+ updateDm = true
+ return getList2(offset, query, tags, sortOrder)
+ }
+ updateDm = false
+ return root.children().mapNotNull { tr ->
+ if (tr.childrenSize() != 2) return@mapNotNull null
+ val (td1, td2) = tr.children()
+ val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found")
+ val a = glink.parents().select("a").first() ?: parseFailed("link not found")
+ val href = a.relUrl("href")
+ val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
+ val mainTag = td2.selectFirst("div.cn")?.let { div ->
+ MangaTag(
+ title = div.text(),
+ key = tagIdByClass(div.classNames()) ?: return@let null,
+ source = source,
+ )
+ }
+ Manga(
+ id = generateUid(href),
+ title = glink.text().cleanupTitle(),
+ altTitle = null,
+ url = href,
+ publicUrl = a.absUrl("href"),
+ rating = td2.selectFirst("div.ir")?.parseRating() ?: Manga.NO_RATING,
+ isNsfw = true,
+ coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
+ tags = setOfNotNull(mainTag),
+ state = null,
+ author = tagsDiv.getElementsContainingOwnText("artist:").first()
+ ?.nextElementSibling()?.text(),
+ source = source,
+ )
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
+ val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root")
+ val cover = root.getElementById("gd1")?.children()?.first()
+ val title = root.getElementById("gd2")
+ val taglist = root.getElementById("taglist")
+ val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
+ return manga.copy(
+ title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
+ altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
+ publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
+ rating = root.getElementById("rating_label")?.text()
+ ?.substringAfterLast(' ')
+ ?.toFloatOrNull()
+ ?.div(5f) ?: manga.rating,
+ largeCoverUrl = cover?.css("background")?.cssUrl(),
+ description = taglist?.select("tr")?.joinToString("
") { tr ->
+ val (tc, td) = tr.children()
+ val subtags = td.select("a").joinToString { it.html() }
+ "${tc.html()} $subtags"
+ },
+ chapters = tabs?.select("a")?.findLast { a ->
+ a.text().toIntOrNull() != null
+ }?.let { a ->
+ val count = a.text().toInt()
+ val chapters = ArrayList(count)
+ for (i in 1..count) {
+ val url = "${manga.url}?p=$i"
+ chapters += MangaChapter(
+ id = generateUid(url),
+ name = "${manga.title} #$i",
+ number = i,
+ url = url,
+ uploadDate = 0L,
+ source = source,
+ scanlator = null,
+ branch = null,
+ )
+ }
+ chapters
+ },
+ )
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val doc = loaderContext.httpGet(chapter.url.withDomain()).parseHtml()
+ val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found")
+ return root.select("a").mapNotNull { a ->
+ val url = a.relUrl("href")
+ MangaPage(
+ id = generateUid(url),
+ url = url,
+ referer = a.absUrl("href"),
+ preview = null,
+ source = source,
+ )
+ }
+ }
+
+ override suspend fun getPageUrl(page: MangaPage): String {
+ val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml()
+ return doc.body().getElementById("img")?.absUrl("src")
+ ?: parseFailed("Image not found")
+ }
+
+ override suspend fun getTags(): Set {
+ val doc = loaderContext.httpGet("https://${getDomain()}").parseHtml()
+ val root = doc.body().getElementById("searchbox")?.selectFirst("table")
+ ?: parseFailed("Root not found")
+ return root.select("div.cs").mapNotNullToSet { div ->
+ val id = div.id().substringAfterLast('_').toIntOrNull()
+ ?: return@mapNotNullToSet null
+ MangaTag(
+ title = div.text(),
+ key = id.toString(),
+ source = source
+ )
+ }
+ }
+
+ override fun isAuthorized(): Boolean {
+ val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
+ if (authorized) {
+ if (!isAuthorized(DOMAIN_AUTHORIZED)) {
+ loaderContext.cookieJar.copyCookies(
+ DOMAIN_UNAUTHORIZED,
+ DOMAIN_AUTHORIZED,
+ authCookies,
+ )
+ loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
+ }
+ return true
+ }
+ return false
+ }
+
+ private fun isAuthorized(domain: String): Boolean {
+ val cookies = loaderContext.cookieJar.getCookies(domain).mapToSet { x -> x.name }
+ return authCookies.all { it in cookies }
+ }
+
+ private fun Element.parseRating(): Float {
+ return runCatching {
+ val style = requireNotNull(attr("style"))
+ val (v1, v2) = ratingPattern.find(style)!!.destructured
+ var p1 = v1.dropLast(2).toInt()
+ val p2 = v2.dropLast(2).toInt()
+ if (p2 != -1) {
+ p1 += 8
+ }
+ (80 - p1) / 80f
+ }.getOrDefault(Manga.NO_RATING)
+ }
+
+ private fun String.cleanupTitle(): String {
+ val result = StringBuilder(length)
+ var skip = false
+ for (c in this) {
+ when {
+ c == '[' -> skip = true
+ c == ']' -> skip = false
+ c.isWhitespace() && result.isEmpty() -> continue
+ !skip -> result.append(c)
+ }
+ }
+ while (result.lastOrNull()?.isWhitespace() == true) {
+ result.deleteCharAt(result.lastIndex)
+ }
+ return result.toString()
+ }
+
+ private fun String.cssUrl(): String? {
+ val fromIndex = indexOf("url(")
+ if (fromIndex == -1) {
+ return null
+ }
+ val toIndex = indexOf(')', startIndex = fromIndex)
+ return if (toIndex == -1) {
+ null
+ } else {
+ substring(fromIndex + 4, toIndex).trim()
+ }
+ }
+
+ private fun tagIdByClass(classNames: Collection): String? {
+ val className = classNames.find { x -> x.startsWith("ct") } ?: return null
+ val num = className.drop(2).toIntOrNull(16) ?: return null
+ return 2.0.pow(num).toInt().toString()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt
index b680b5cf0..598a43bf0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt
@@ -1,11 +1,14 @@
package org.koitharu.kotatsu.core.parser.site
+import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Response
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
+import java.text.SimpleDateFormat
import java.util.*
abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
@@ -18,11 +21,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
SortOrder.RATING
)
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
val domain = getDomain()
val doc = when {
@@ -33,22 +36,24 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString()
)
)
- tag == null -> loaderContext.httpGet(
+ tags.isNullOrEmpty() -> loaderContext.httpGet(
"https://$domain/list?sortType=${
getSortKey(
sortOrder
)
- }&offset=${offset upBy PAGE_SIZE}"
+ }&offset=${offset upBy PAGE_SIZE}", HEADER
)
- else -> loaderContext.httpGet(
- "https://$domain/list/genre/${tag.key}?sortType=${
+ tags.size == 1 -> loaderContext.httpGet(
+ "https://$domain/list/genre/${tags.first().key}?sortType=${
getSortKey(
sortOrder
)
- }&offset=${offset upBy PAGE_SIZE}"
+ }&offset=${offset upBy PAGE_SIZE}", HEADER
)
- }.parseHtml()
- val root = doc.body().getElementById("mangaBox")
+ offset > 0 -> return emptyList()
+ else -> advancedSearch(domain, tags)
+ }.parseHtml().body()
+ val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host
return root.select("div.tile").mapNotNull { node ->
@@ -57,7 +62,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
if (descDiv.selectFirst("i.fa-user") != null) {
return@mapNotNull null //skip author
}
- val href = imgDiv.selectFirst("a").attr("href")?.inContextOf(node)
+ val href = imgDiv.selectFirst("a")?.attr("href")?.inContextOf(node)
if (href == null || href.toHttpUrl().host != baseHost) {
return@mapNotNull null // skip external links
}
@@ -101,14 +106,15 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
}
override suspend fun getDetails(manga: Manga): Manga {
- val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
+ val doc = loaderContext.httpGet(manga.url.withDomain(), HEADER).parseHtml()
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: throw ParseException("Cannot find root")
+ val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
+ val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
return manga.copy(
description = root.selectFirst("div.manga-description")?.html(),
- largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
- "data-full"
- ),
+ largeCoverUrl = coverImg?.attr("data-full"),
+ coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
.mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
@@ -119,21 +125,32 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
)
},
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
- ?.select("a")?.asReversed()?.mapIndexed { i, a ->
+ ?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr ->
+ val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null
val href = a.relUrl("href")
+ var translators = ""
+ val translatorElement = a.attr("title")
+ if (!translatorElement.isNullOrBlank()) {
+ translators = translatorElement
+ .replace("(Переводчик),", "&")
+ .removeSuffix(" (Переводчик)")
+ }
MangaChapter(
id = generateUid(href),
- name = a.ownText().removePrefix(manga.title).trim(),
+ name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
number = i + 1,
url = href,
- source = source
+ uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
+ scanlator = translators,
+ source = source,
+ branch = null,
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List {
- val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1").parseHtml()
+ val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1", HEADER).parseHtml()
val scripts = doc.select("script")
for (script in scripts) {
val data = script.html()
@@ -151,8 +168,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
MangaPage(
id = generateUid(url),
url = url,
+ preview = null,
referer = chapter.url,
- source = source
+ source = source,
)
}
}
@@ -160,12 +178,12 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
}
override suspend fun getTags(): Set {
- val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml()
- val root = doc.body().getElementById("mangaBox").selectFirst("div.leftContent")
- .selectFirst("table.table")
+ val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name", HEADER).parseHtml()
+ val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
+ ?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a ->
MangaTag(
- title = a.text().capitalize(),
+ title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
@@ -182,9 +200,50 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
null -> "updated"
}
+ private suspend fun advancedSearch(domain: String, tags: Set): Response {
+ val url = "https://$domain/search/advanced"
+ // Step 1: map catalog genres names to advanced-search genres ids
+ val tagsIndex = loaderContext.httpGet(url, HEADER).parseHtml()
+ .body().selectFirst("form.search-form")
+ ?.select("div.form-group")
+ ?.get(1) ?: parseFailed("Genres filter element not found")
+ val tagNames = tags.map { it.title.lowercase() }
+ val payload = HashMap()
+ var foundGenres = 0
+ tagsIndex.select("li.property").forEach { li ->
+ val name = li.text().trim().lowercase()
+ val id = li.selectFirst("input")?.id()
+ ?: parseFailed("Id for tag $name not found")
+ payload[id] = if (name in tagNames) {
+ foundGenres++
+ "in"
+ } else ""
+ }
+ if (foundGenres != tags.size) {
+ parseFailed("Some genres are not found")
+ }
+ // Step 2: advanced search
+ payload["q"] = ""
+ payload["s_high_rate"] = ""
+ payload["s_single"] = ""
+ payload["s_mature"] = ""
+ payload["s_completed"] = ""
+ payload["s_translated"] = ""
+ payload["s_many_chapters"] = ""
+ payload["s_wait_upload"] = ""
+ payload["s_sale"] = ""
+ payload["years"] = "1900,2099"
+ payload["+"] = "Искать".urlEncoded()
+ return loaderContext.httpPost(url, payload)
+ }
+
private companion object {
private const val PAGE_SIZE = 70
private const val PAGE_SIZE_SEARCH = 50
+ private val HEADER = Headers.Builder()
+ .add("User-Agent", "readmangafun")
+ .build()
}
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt
index e405aed9c..072c7611b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt
@@ -8,22 +8,20 @@ import org.koitharu.kotatsu.utils.ext.parseHtml
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
- override val defaultDomain = "hentaichan.pro"
+ override val defaultDomain = "hentaichan.live"
override val source = MangaSource.HENCHAN
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
- return super.getList(offset, query, sortOrder, tag).map {
- val cover = it.coverUrl
- if (cover.contains("_blur")) {
- it.copy(coverUrl = cover.replace("_blur", ""))
- } else {
- it
- }
+ return super.getList2(offset, query, tags, sortOrder).map {
+ it.copy(
+ coverUrl = it.coverUrl.replace("_blur", ""),
+ isNsfw = true,
+ )
}
}
@@ -36,7 +34,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
description = root.getElementById("description")?.html()?.substringBeforeLast(" = EnumSet.of(
+ SortOrder.UPDATED,
+ SortOrder.ALPHABETICAL,
+ SortOrder.NEWEST,
+ SortOrder.POPULARITY,
+ )
+
+ override suspend fun getList2(
+ offset: Int,
+ query: String?,
+ tags: Set
?,
+ sortOrder: SortOrder?,
+ ): List {
+ val domain = getDomain()
+ val url = buildString {
+ append("https://api.")
+ append(domain)
+ append("/manga?limit=")
+ append(PAGE_SIZE)
+ append("&offset=")
+ append(offset)
+ append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
+ tags?.forEach { tag ->
+ append("includedTags[]=")
+ append(tag.key)
+ append('&')
+ }
+ if (!query.isNullOrEmpty()) {
+ append("title=")
+ append(query.urlEncoded())
+ append('&')
+ }
+ append(CONTENT_RATING)
+ append("&order")
+ append(when (sortOrder) {
+ null,
+ SortOrder.UPDATED,
+ -> "[latestUploadedChapter]=desc"
+ SortOrder.ALPHABETICAL -> "[title]=asc"
+ SortOrder.NEWEST -> "[createdAt]=desc"
+ SortOrder.POPULARITY -> "[followedCount]=desc"
+ else -> "[followedCount]=desc"
+ })
+ }
+ val json = loaderContext.httpGet(url).parseJson().getJSONArray("data")
+ return json.map { jo ->
+ val id = jo.getString("id")
+ val attrs = jo.getJSONObject("attributes")
+ val relations = jo.getJSONArray("relationships").associateByKey("type")
+ val cover = relations["cover_art"]
+ ?.getJSONObject("attributes")
+ ?.getString("fileName")
+ ?.let {
+ "https://uploads.$domain/covers/$id/$it"
+ }
+ Manga(
+ id = generateUid(id),
+ title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
+ "Title should not be null"
+ },
+ altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
+ url = id,
+ publicUrl = "https://$domain/title/$id",
+ rating = Manga.NO_RATING,
+ isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
+ coverUrl = cover?.plus(".256.jpg").orEmpty(),
+ largeCoverUrl = cover,
+ description = attrs.optJSONObject("description")?.selectByLocale(),
+ tags = attrs.getJSONArray("tags").mapToSet { tag ->
+ MangaTag(
+ title = tag.getJSONObject("attributes")
+ .getJSONObject("name")
+ .firstStringValue(),
+ key = tag.getString("id"),
+ source = source,
+ )
+ },
+ state = when (jo.getStringOrNull("status")) {
+ "ongoing" -> MangaState.ONGOING
+ "completed" -> MangaState.FINISHED
+ else -> null
+ },
+ author = (relations["author"] ?: relations["artist"])
+ ?.getJSONObject("attributes")
+ ?.getStringOrNull("name"),
+ source = source,
+ )
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
+ val domain = getDomain()
+ val attrsDeferred = async {
+ loaderContext.httpGet(
+ "https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art"
+ ).parseJson().getJSONObject("data").getJSONObject("attributes")
+ }
+ val feedDeferred = async {
+ val url = buildString {
+ append("https://api.")
+ append(domain)
+ append("/manga/")
+ append(manga.url)
+ append("/feed")
+ append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&")
+ append(CONTENT_RATING)
+ }
+ loaderContext.httpGet(url).parseJson().getJSONArray("data")
+ }
+ val mangaAttrs = attrsDeferred.await()
+ val feed = feedDeferred.await()
+ //2022-01-02T00:27:11+00:00
+ val dateFormat = SimpleDateFormat(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ "yyyy-MM-dd'T'HH:mm:ssX"
+ } else {
+ "yyyy-MM-dd'T'HH:mm:ss'+00:00'"
+ },
+ Locale.ROOT
+ )
+ manga.copy(
+ description = mangaAttrs.getJSONObject("description").selectByLocale()
+ ?: manga.description,
+ chapters = feed.mapNotNull { jo ->
+ val id = jo.getString("id")
+ val attrs = jo.getJSONObject("attributes")
+ if (attrs.optJSONArray("data").isNullOrEmpty()) {
+ return@mapNotNull null
+ }
+ val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
+ val relations = jo.getJSONArray("relationships").associateByKey("type")
+ val number = attrs.optInt("chapter", 0)
+ MangaChapter(
+ id = generateUid(id),
+ name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
+ ?: "Chapter #$number",
+ number = number,
+ url = id,
+ scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
+ uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
+ branch = locale.displayName.toTitleCase(locale),
+ source = source,
+ )
+ }
+ )
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val domain = getDomain()
+ val attrs = loaderContext.httpGet("https://api.$domain/chapter/${chapter.url}")
+ .parseJson()
+ .getJSONObject("data")
+ .getJSONObject("attributes")
+ val data = attrs.getJSONArray("data")
+ val prefix = "https://uploads.$domain/data/${attrs.getString("hash")}/"
+ val referer = "https://$domain/"
+ return List(data.length()) { i ->
+ val url = prefix + data.getString(i)
+ MangaPage(
+ id = generateUid(url),
+ url = url,
+ referer = referer,
+ preview = null, // TODO prefix + dataSaver.getString(i),
+ source = source,
+ )
+ }
+ }
+
+ override suspend fun getTags(): Set {
+ val tags = loaderContext.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
+ .getJSONArray("data")
+ return tags.mapToSet { jo ->
+ MangaTag(
+ title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(),
+ key = jo.getString("id"),
+ source = source,
+ )
+ }
+ }
+
+ private fun JSONObject.firstStringValue() = values().next() as String
+
+ private fun JSONObject.selectByLocale(): String? {
+ val preferredLocales = LocaleListCompat.getAdjustedDefault()
+ repeat(preferredLocales.size()) { i ->
+ val locale = preferredLocales.get(i)
+ getStringOrNull(locale.language)?.let { return it }
+ getStringOrNull(locale.toLanguageTag())?.let { return it }
+ }
+ return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt
index 8d2c6c87f..ed58f073c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt
@@ -9,8 +9,8 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
+import java.text.SimpleDateFormat
import java.util.*
-import kotlin.collections.ArrayList
open class MangaLibRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
@@ -27,11 +27,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
SortOrder.NEWEST
)
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList()
@@ -44,20 +44,21 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
append(getSortKey(sortOrder))
append("&page=")
append(page)
- if (tag != null) {
- append("&includeGenres[]=")
+ tags?.forEach { tag ->
+ append("&genres[include][]=")
append(tag.key)
}
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found")
- val items = root.selectFirst("div.media-cards-grid").select("div.media-card-wrap")
+ val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap")
+ ?: return emptyList()
return items.mapNotNull { card ->
val a = card.selectFirst("a.media-card") ?: return@mapNotNull null
val href = a.relUrl("href")
Manga(
id = generateUid(href),
- title = card.selectFirst("h3").text(),
+ title = card.selectFirst("h3")?.text().orEmpty(),
coverUrl = a.absUrl("data-src"),
altTitle = null,
author = null,
@@ -79,6 +80,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
val info = root.selectFirst("div.media-content")
val chaptersDoc = loaderContext.httpGet("$fullUrl?section=chapters").parseHtml()
val scripts = chaptersDoc.select("script")
+ val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
var chapters: ArrayList? = null
scripts@ for (script in scripts) {
val raw = script.html().lines()
@@ -91,29 +93,33 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
for (i in 0 until total) {
val item = list.getJSONObject(i)
val chapterId = item.getLong("chapter_id")
- val branchName = item.getStringOrNull("username")
+ val scanlator = item.getStringOrNull("username")
val url = buildString {
append(manga.url)
append("/v")
append(item.getInt("chapter_volume"))
append("/c")
append(item.getString("chapter_number"))
+ @Suppress("BlockingMethodInNonBlockingContext") // lint issue
append('/')
append(item.optString("chapter_string"))
}
- var name = item.getString("chapter_name")
- if (name.isNullOrBlank() || name == "null") {
- name = "Том " + item.getInt("chapter_volume") +
- " Глава " + item.getString("chapter_number")
- }
+ val nameChapter = item.getStringOrNull("chapter_name")
+ val volume = item.getInt("chapter_volume")
+ val number = item.getString("chapter_number")
+ val fullNameChapter = "Том $volume. Глава $number"
chapters.add(
MangaChapter(
id = generateUid(chapterId),
url = url,
source = source,
- branch = branchName,
number = total - i,
- name = name
+ uploadDate = dateFormat.tryParse(
+ item.getString("chapter_created_at").substringBefore(" ")
+ ),
+ scanlator = scanlator,
+ branch = null,
+ name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter",
)
)
}
@@ -128,17 +134,17 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
rating = root.selectFirst("div.media-stats-item__score")
?.selectFirst("span")
?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating,
- author = info.getElementsMatchingOwnText("Автор").firstOrNull()
+ author = info?.getElementsMatchingOwnText("Автор")?.firstOrNull()
?.nextElementSibling()?.text() ?: manga.author,
- tags = info.selectFirst("div.media-tags")
+ tags = info?.selectFirst("div.media-tags")
?.select("a.media-tag-item")?.mapToSet { a ->
MangaTag(
- title = a.text().capitalize(),
+ title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('='),
source = source
)
} ?: manga.tags,
- description = info.selectFirst("div.media-description__text")?.html(),
+ description = info?.selectFirst("div.media-description__text")?.html(),
chapters = chapters
)
}
@@ -146,11 +152,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
override suspend fun getPages(chapter: MangaChapter): List {
val fullUrl = chapter.url.withDomain()
val doc = loaderContext.httpGet(fullUrl).parseHtml()
- if (doc.location()?.endsWith("/register") == true) {
+ if (doc.location().endsWith("/register")) {
throw AuthRequiredException("/login".inContextOf(doc))
}
val scripts = doc.head().select("script")
- val pg = doc.body().getElementById("pg").html()
+ val pg = (doc.body().getElementById("pg")?.html() ?: parseFailed("Element #pg not found"))
.substringAfter('=')
.substringBeforeLast(';')
val pages = JSONArray(pg)
@@ -173,8 +179,9 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
MangaPage(
id = generateUid(pageUrl),
url = pageUrl,
+ preview = null,
referer = fullUrl,
- source = source
+ source = source,
)
}
}
@@ -196,7 +203,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
result += MangaTag(
source = source,
key = x.getInt("id").toString(),
- title = x.getString("name").capitalize()
+ title = x.getString("name").toCamelCase()
)
}
return result
@@ -234,8 +241,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
.toFloatOrNull()?.div(5f) ?: Manga.NO_RATING,
state = null,
source = source,
- coverUrl = "https://$domain${covers.getString("thumbnail")}",
- largeCoverUrl = "https://$domain${covers.getString("default")}"
+ coverUrl = covers.getString("thumbnail"),
+ largeCoverUrl = covers.getString("default")
)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt
new file mode 100644
index 000000000..da9af43bd
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt
@@ -0,0 +1,164 @@
+package org.koitharu.kotatsu.core.parser.site
+
+import org.koitharu.kotatsu.base.domain.MangaLoaderContext
+import org.koitharu.kotatsu.core.exceptions.ParseException
+import org.koitharu.kotatsu.core.model.*
+import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
+import org.koitharu.kotatsu.utils.ext.*
+import java.text.SimpleDateFormat
+import java.util.*
+
+class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
+
+ override val source = MangaSource.MANGAOWL
+
+ override val defaultDomain = "mangaowls.com"
+
+ override val sortOrders: Set = EnumSet.of(
+ SortOrder.POPULARITY,
+ SortOrder.NEWEST,
+ SortOrder.UPDATED
+ )
+
+ override suspend fun getList2(
+ offset: Int,
+ query: String?,
+ tags: Set?,
+ sortOrder: SortOrder?,
+ ): List {
+ val page = (offset / 36f).toIntUp().inc()
+ val link = buildString {
+ append("https://")
+ append(getDomain())
+ when {
+ !query.isNullOrEmpty() -> {
+ append("/search/${page}?search=")
+ append(query.urlEncoded())
+ }
+ !tags.isNullOrEmpty() -> {
+ for (tag in tags) {
+ append(tag.key)
+ }
+ append("/${page}?type=${getAlternativeSortKey(sortOrder)}")
+ }
+ else -> {
+ append("/${getSortKey(sortOrder)}/${page}")
+ }
+ }
+ }
+ val doc = loaderContext.httpGet(link).parseHtml()
+ val slides = doc.body().select("ul.slides") ?: parseFailed("An error occurred while parsing")
+ val items = slides.select("div.col-md-2")
+ return items.mapNotNull { item ->
+ val href = item.selectFirst("h6 a")?.relUrl("href") ?: return@mapNotNull null
+ Manga(
+ id = generateUid(href),
+ title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null,
+ coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"),
+ altTitle = null,
+ author = null,
+ rating = runCatching {
+ item.selectFirst("div.block-stars")
+ ?.text()
+ ?.toFloatOrNull()
+ ?.div(10f)
+ }.getOrNull() ?: Manga.NO_RATING,
+ url = href,
+ publicUrl = href.withDomain(),
+ source = source
+ )
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
+ val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing")
+ val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
+ val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
+ return manga.copy(
+ description = info.selectFirst(".description")?.html(),
+ largeCoverUrl = info.select("img").first()?.let { img ->
+ if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src")
+ },
+ author = info.selectFirst("p.fexi_header_para a.author_link")?.text(),
+ state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()),
+ tags = manga.tags + info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]")
+ .mapNotNull {
+ val a = it.selectFirst("a") ?: return@mapNotNull null
+ MangaTag(
+ title = a.text(),
+ key = a.attr("href"),
+ source = source
+ )
+ },
+ chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
+ val a = li.select("a")
+ val href = a.attr("data-href").ifEmpty {
+ parseFailed("Link is missing")
+ }
+ MangaChapter(
+ id = generateUid(href),
+ name = a.select("label").text(),
+ number = i + 1,
+ url = href,
+ scanlator = null,
+ branch = null,
+ uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
+ source = MangaSource.MANGAOWL,
+ )
+ }
+ )
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val fullUrl = chapter.url.withDomain()
+ val doc = loaderContext.httpGet(fullUrl).parseHtml()
+ val root = doc.body().select("div.item img.owl-lazy") ?: throw ParseException("Root not found")
+ return root.map { div ->
+ val url = div?.relUrl("data-src") ?: parseFailed("Page image not found")
+ MangaPage(
+ id = generateUid(url),
+ url = url,
+ preview = null,
+ referer = fullUrl,
+ source = MangaSource.MANGAOWL,
+ )
+ }
+ }
+
+ private fun parseStatus(status: String?) = when {
+ status == null -> null
+ status.contains("Ongoing") -> MangaState.ONGOING
+ status.contains("Completed") -> MangaState.FINISHED
+ else -> null
+ }
+
+ override suspend fun getTags(): Set {
+ val doc = loaderContext.httpGet("https://${getDomain()}/").parseHtml()
+ val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li")
+ return root.mapToSet { p ->
+ val a = p.selectFirst("a") ?: parseFailed("a is null")
+ MangaTag(
+ title = a.text().toCamelCase(),
+ key = a.attr("href"),
+ source = source
+ )
+ }
+ }
+
+ private fun getSortKey(sortOrder: SortOrder?) =
+ when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
+ SortOrder.POPULARITY -> "popular"
+ SortOrder.NEWEST -> "new_release"
+ SortOrder.UPDATED -> "lastest"
+ else -> "lastest"
+ }
+
+ private fun getAlternativeSortKey(sortOrder: SortOrder?) =
+ when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
+ SortOrder.POPULARITY -> "0"
+ SortOrder.NEWEST -> "2"
+ SortOrder.UPDATED -> "3"
+ else -> "3"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt
index 4fb4085dc..7f1285369 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt
@@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.utils.ext.*
+import java.text.DateFormat
+import java.text.SimpleDateFormat
import java.util.*
class MangaTownRepository(loaderContext: MangaLoaderContext) :
@@ -23,11 +25,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
SortOrder.UPDATED
)
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
val sortKey = when (sortOrder) {
SortOrder.ALPHABETICAL -> "?name.az"
@@ -43,22 +45,28 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
}
"/search?name=${query.urlEncoded()}".withDomain()
}
- tag != null -> "/directory/${tag.key}/$page.htm$sortKey".withDomain()
- else -> "/directory/$page.htm$sortKey".withDomain()
+ tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".withDomain()
+ tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".withDomain()
+ else -> tags.joinToString(
+ prefix = "/search?page=$page".withDomain()
+ ) { tag ->
+ "&genres[${tag.key}]=1"
+ }
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.manga_pic_list")
?: throw ParseException("Root not found")
return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a.manga_cover")
- val href = a.relUrl("href")
+ val href = a?.relUrl("href")
+ ?: return@mapNotNull null
val views = li.select("p.view")
val status = views.findOwnText { x -> x.startsWith("Status:") }
- ?.substringAfter(':')?.trim()?.toLowerCase(Locale.ROOT)
+ ?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT)
Manga(
id = generateUid(href),
title = a.attr("title"),
- coverUrl = a.selectFirst("img").absUrl("src"),
+ coverUrl = a.selectFirst("img")?.absUrl("src").orEmpty(),
source = MangaSource.MANGATOWN,
altTitle = null,
rating = li.selectFirst("p.score")?.selectFirst("b")
@@ -87,11 +95,12 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
val root = doc.body().selectFirst("section.main")
?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root")
- val info = root.selectFirst("div.detail_info").selectFirst("ul")
+ val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
+ val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return manga.copy(
- tags = manga.tags + info.select("li").find { x ->
+ tags = manga.tags + info?.select("li")?.find { x ->
x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a ->
MangaTag(
@@ -100,9 +109,10 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
source = MangaSource.MANGATOWN
)
}.orEmpty(),
- description = info.getElementById("show")?.ownText(),
+ description = info?.getElementById("show")?.ownText(),
chapters = chaptersList?.mapIndexedNotNull { i, li ->
- val href = li.selectFirst("a").relUrl("href")
+ val href = li.selectFirst("a")?.relUrl("href")
+ ?: return@mapIndexedNotNull null
val name = li.select("span").filter { it.className().isEmpty() }
.joinToString(" - ") { it.text() }.trim()
MangaChapter(
@@ -110,7 +120,13 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
url = href,
source = MangaSource.MANGATOWN,
number = i + 1,
- name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name
+ uploadDate = parseChapterDate(
+ dateFormat,
+ li.selectFirst("span.time")?.text()
+ ),
+ name = name.ifEmpty { "${manga.title} - ${i + 1}" },
+ scanlator = null,
+ branch = null,
)
}
)
@@ -121,7 +137,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
val doc = loaderContext.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.page_select")
?: throw ParseException("Cannot find root")
- return root.selectFirst("select").select("option").mapNotNull {
+ return root.selectFirst("select")?.select("option")?.mapNotNull {
val href = it.relUrl("value")
if (href.endsWith("featured.html")) {
return@mapNotNull null
@@ -129,23 +145,24 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
MangaPage(
id = generateUid(href),
url = href,
+ preview = null,
referer = fullUrl,
- source = MangaSource.MANGATOWN
+ source = MangaSource.MANGATOWN,
)
- }
+ } ?: parseFailed("Pages list not found")
}
override suspend fun getPageUrl(page: MangaPage): String {
val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml()
- return doc.getElementById("image").absUrl("src")
+ return doc.getElementById("image")?.absUrl("src") ?: parseFailed("Image not found")
}
override suspend fun getTags(): Set {
val doc = loaderContext.httpGet("/directory/".withDomain()).parseHtml()
val root = doc.body().selectFirst("aside.right")
- .getElementsContainingOwnText("Genres")
- .first()
- .nextElementSibling()
+ ?.getElementsContainingOwnText("Genres")
+ ?.first()
+ ?.nextElementSibling() ?: parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val key = a.attr("href").parseTagKey()
@@ -160,6 +177,15 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
}
}
+ private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
+ return when {
+ date.isNullOrEmpty() -> 0L
+ date.contains("Today") -> Calendar.getInstance().timeInMillis
+ date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis
+ else -> dateFormat.tryParse(date)
+ }
+ }
+
override fun onCreatePreferences(map: MutableMap) {
super.onCreatePreferences(map)
map[SourceSettings.KEY_USE_SSL] = true
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt
index 96430b755..17aced99d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt
@@ -4,7 +4,10 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
+import org.koitharu.kotatsu.utils.WordSet
import org.koitharu.kotatsu.utils.ext.*
+import java.text.DateFormat
+import java.text.SimpleDateFormat
import java.util.*
class MangareadRepository(
@@ -20,17 +23,19 @@ class MangareadRepository(
SortOrder.POPULARITY
)
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
- if (offset % PAGE_SIZE != 0) {
- return emptyList()
+ val tag = when {
+ tags.isNullOrEmpty() -> null
+ tags.size == 1 -> tags.first()
+ else -> throw NotImplementedError("Multiple genres are not supported by this source")
}
val payload = createRequestTemplate()
- payload["page"] = (offset / PAGE_SIZE).toString()
+ payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
payload["vars[meta_key]"] = when (sortOrder) {
SortOrder.POPULARITY -> "_wp_manga_views"
SortOrder.UPDATED -> "_latest_update"
@@ -43,25 +48,26 @@ class MangareadRepository(
payload
).parseHtml()
return doc.select("div.row.c-tabs-item__content").map { div ->
- val href = div.selectFirst("a").relUrl("href")
+ val href = div.selectFirst("a")?.relUrl("href")
+ ?: parseFailed("Link not found")
val summary = div.selectFirst(".tab-summary")
Manga(
id = generateUid(href),
url = href,
publicUrl = href.inContextOf(div),
- coverUrl = div.selectFirst("img").absUrl("src"),
- title = summary.selectFirst("h3").text(),
+ coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(),
+ title = summary?.selectFirst("h3")?.text().orEmpty(),
rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f,
- tags = summary.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
+ tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(),
source = MangaSource.MANGAREAD
)
}.orEmpty(),
- author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
- state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content")
+ author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
+ state = when (summary?.selectFirst(".mg_status")?.selectFirst(".summary-content")
?.ownText()?.trim()) {
"OnGoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
@@ -75,9 +81,9 @@ class MangareadRepository(
override suspend fun getTags(): Set {
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml()
val root = doc.body().selectFirst("header")
- .selectFirst("ul.second-menu")
+ ?.selectFirst("ul.second-menu") ?: parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li ->
- val a = li.selectFirst("a")
+ val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast("genres/", "")
if (href.isEmpty()) {
@@ -101,8 +107,8 @@ class MangareadRepository(
val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found")
- val mangaId = doc.getElementsByAttribute("data-postid").firstOrNull()
- ?.attr("data-postid")?.toLongOrNull()
+ val mangaId = doc.getElementsByAttribute("data-post").firstOrNull()
+ ?.attr("data-post")?.toLongOrNull()
?: throw ParseException("Cannot obtain manga id")
val doc2 = loaderContext.httpPost(
"https://${getDomain()}/wp-admin/admin-ajax.php",
@@ -111,6 +117,7 @@ class MangareadRepository(
"manga" to mangaId.toString()
)
).parseHtml()
+ val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
return manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a")
?.mapNotNullToSet { a ->
@@ -127,13 +134,21 @@ class MangareadRepository(
?.joinToString { it.html() },
chapters = doc2.select("li").asReversed().mapIndexed { i, li ->
val a = li.selectFirst("a")
- val href = a.relUrl("href")
+ val href = a?.relUrl("href").orEmpty().ifEmpty {
+ parseFailed("Link is missing")
+ }
MangaChapter(
id = generateUid(href),
- name = a.ownText(),
+ name = a!!.ownText(),
number = i + 1,
url = href,
- source = MangaSource.MANGAREAD
+ uploadDate = parseChapterDate(
+ dateFormat,
+ doc2.selectFirst("span.chapter-release-date i")?.text()
+ ),
+ source = MangaSource.MANGAREAD,
+ scanlator = null,
+ branch = null,
)
}
)
@@ -147,16 +162,82 @@ class MangareadRepository(
?: throw ParseException("Root not found")
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img")
- val url = img.relUrl("src")
+ val url = img?.relUrl("src") ?: parseFailed("Page image not found")
MangaPage(
id = generateUid(url),
url = url,
+ preview = null,
referer = fullUrl,
- source = MangaSource.MANGAREAD
+ source = MangaSource.MANGAREAD,
)
}
}
+ private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
+
+ date ?: return 0
+ return when {
+ date.endsWith(" ago", ignoreCase = true) -> {
+ parseRelativeDate(date)
+ }
+ // Handle translated 'ago' in Portuguese.
+ date.endsWith(" atrás", ignoreCase = true) -> {
+ parseRelativeDate(date)
+ }
+ // Handle translated 'ago' in Turkish.
+ date.endsWith(" önce", ignoreCase = true) -> {
+ parseRelativeDate(date)
+ }
+ // Handle 'yesterday' and 'today', using midnight
+ date.startsWith("year", ignoreCase = true) -> {
+ Calendar.getInstance().apply {
+ add(Calendar.DAY_OF_MONTH, -1) // yesterday
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+ }
+ date.startsWith("today", ignoreCase = true) -> {
+ Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+ }
+ date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
+ // Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
+ date.split(" ").map {
+ if (it.contains(Regex("""\d\D\D"""))) {
+ it.replace(Regex("""\D"""), "")
+ } else {
+ it
+ }
+ }
+ .let { dateFormat.tryParse(it.joinToString(" ")) }
+ }
+ else -> dateFormat.tryParse(date)
+ }
+ }
+
+ // Parses dates in this form:
+ // 21 hours ago
+ private fun parseRelativeDate(date: String): Long {
+ val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
+ val cal = Calendar.getInstance()
+
+ return when {
+ WordSet("hari", "gün", "jour", "día", "dia", "day").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
+ WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
+ WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
+ WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
+ WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
+ WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
+ else -> 0
+ }
+ }
+
private companion object {
private const val PAGE_SIZE = 12
@@ -169,4 +250,4 @@ class MangareadRepository(
it.substring(0, pos) to it.substring(pos + 1)
}.toMutableMap()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt
index aca8baca3..7b782ab1c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
+import java.text.SimpleDateFormat
import java.util.*
abstract class NineMangaRepository(
@@ -16,62 +17,66 @@ abstract class NineMangaRepository(
) : RemoteMangaRepository(loaderContext) {
init {
- loaderContext.insertCookies(getDomain(), "ninemanga_template_desk=yes")
+ loaderContext.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes")
}
override val sortOrders: Set = EnumSet.of(
SortOrder.POPULARITY,
)
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?,
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
val url = buildString {
append("https://")
append(getDomain())
- if (query.isNullOrEmpty()) {
- append("/category/")
- if (tag != null) {
- append(tag.key)
- } else {
- append("index")
+ when {
+ !query.isNullOrEmpty() -> {
+ append("/search/?name_sel=&wd=")
+ append(query.urlEncoded())
+ append("&page=")
+ }
+ !tags.isNullOrEmpty() -> {
+ append("/search/?category_id=")
+ for (tag in tags) {
+ append(tag.key)
+ append(',')
+ }
+ append("&page=")
+ }
+ else -> {
+ append("/category/index_")
}
- append("_")
- append(page)
- append(".html")
- } else {
- append("/search/?name_sel=&wd=")
- append(query.urlEncoded())
- append("&page=")
- append(page)
- append(".html")
}
+ append(page)
+ append(".html")
}
val doc = loaderContext.httpGet(url, PREDEFINED_HEADERS).parseHtml()
val root = doc.body().selectFirst("ul.direlist")
?: throw ParseException("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host
return root.select("li").map { node ->
- val href = node.selectFirst("a").absUrl("href")
+ val href = node.selectFirst("a")?.absUrl("href")
+ ?: parseFailed("Link not found")
val relUrl = href.toRelativeUrl(baseHost)
val dd = node.selectFirst("dd")
Manga(
id = generateUid(relUrl),
url = relUrl,
publicUrl = href,
- title = dd.selectFirst("a.bookname").text().toCamelCase(),
+ title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(),
altTitle = null,
- coverUrl = node.selectFirst("img").absUrl("src"),
+ coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(),
rating = Manga.NO_RATING,
author = null,
tags = emptySet(),
state = null,
source = source,
- description = dd.selectFirst("p").html(),
+ description = dd?.selectFirst("p")?.html(),
)
}
}
@@ -86,7 +91,7 @@ abstract class NineMangaRepository(
val infoRoot = root.selectFirst("div.bookintro")
?: throw ParseException("Cannot find info")
return manga.copy(
- tags = infoRoot.getElementsByAttributeValue("itemprop", "genre")?.first()
+ tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
?.select("a")?.mapToSet { a ->
MangaTag(
title = a.text(),
@@ -94,20 +99,23 @@ abstract class NineMangaRepository(
source = source,
)
}.orEmpty(),
- author = infoRoot.getElementsByAttributeValue("itemprop", "author")?.first()?.text(),
- description = infoRoot.getElementsByAttributeValue("itemprop", "description")?.first()
+ author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
+ state = parseStatus(infoRoot.select("li a.red").text()),
+ description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
?.html()?.substringAfter(""),
- chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul")
- ?.select("li")?.asReversed()?.mapIndexed { i, li ->
- val a = li.selectFirst("a")
- val href = a.relUrl("href")
+ chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
+ ?.asReversed()?.mapIndexed { i, li ->
+ val a = li.selectFirst("a.chapter_list_a")
+ val href = a?.relUrl("href")?.replace("%20", " ") ?: parseFailed("Link not found")
MangaChapter(
id = generateUid(href),
name = a.text(),
number = i + 1,
url = href,
- branch = null,
+ uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
source = source,
+ scanlator = null,
+ branch = null,
)
}
)
@@ -135,17 +143,62 @@ abstract class NineMangaRepository(
}
override suspend fun getTags(): Set {
- val doc = loaderContext.httpGet("https://${getDomain()}/category/", PREDEFINED_HEADERS)
+ val doc = loaderContext.httpGet("https://${getDomain()}/search/?type=high", PREDEFINED_HEADERS)
.parseHtml()
- val root = doc.body().selectFirst("ul.genreidex")
- return root.select("li").mapToSet { li ->
- val a = li.selectFirst("a")
+ val root = doc.body().getElementById("search_form")
+ return root?.select("li.cate_list")?.mapNotNullToSet { li ->
+ val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null
+ val a = li.selectFirst("a") ?: return@mapNotNullToSet null
MangaTag(
- title = a.text(),
- key = a.attr("href").substringBetweenLast("/", "."),
+ title = a.text().toTitleCase(),
+ key = cateId,
source = source
)
+ } ?: parseFailed("Root not found")
+ }
+
+ private fun parseStatus(status: String) = when {
+ status.contains("Ongoing") -> MangaState.ONGOING
+ status.contains("Completed") -> MangaState.FINISHED
+ else -> null
+ }
+
+ private fun parseChapterDateByLang(date: String): Long {
+ val dateWords = date.split(" ")
+
+ if (dateWords.size == 3) {
+ if (dateWords[1].contains(",")) {
+ SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).tryParse(date)
+ } else {
+ val timeAgo = Integer.parseInt(dateWords[0])
+ return Calendar.getInstance().apply {
+ when (dateWords[1]) {
+ "minutes" -> Calendar.MINUTE // EN-FR
+ "hours" -> Calendar.HOUR // EN
+
+ "minutos" -> Calendar.MINUTE // ES
+ "horas" -> Calendar.HOUR
+
+ // "minutos" -> Calendar.MINUTE // BR
+ "hora" -> Calendar.HOUR
+
+ "минут" -> Calendar.MINUTE // RU
+ "часа" -> Calendar.HOUR
+
+ "Stunden" -> Calendar.HOUR // DE
+
+ "minuti" -> Calendar.MINUTE // IT
+ "ore" -> Calendar.HOUR
+
+ "heures" -> Calendar.HOUR // FR ("minutes" also French word)
+ else -> null
+ }?.let {
+ add(it, -timeAgo)
+ }
+ }.timeInMillis
+ }
}
+ return 0L
}
class English(loaderContext: MangaLoaderContext) : NineMangaRepository(
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt
index 3871a966f..ab95903d5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt
@@ -5,6 +5,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
- override val defaultDomain = "readmanga.live"
+ override val defaultDomain = "readmanga.io"
override val source = MangaSource.READMANGA_RU
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt
index f368db41e..d589a9490 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt
@@ -6,16 +6,20 @@ import org.json.JSONObject
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
+import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
+import java.text.SimpleDateFormat
import java.util.*
-import kotlin.collections.ArrayList
-class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
+class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext),
+ MangaRepositoryAuthProvider {
override val source = MangaSource.REMANGA
override val defaultDomain = "remanga.org"
+ override val authUrl: String
+ get() = "https://${getDomain()}/user/login"
override val sortOrders: Set = EnumSet.of(
SortOrder.UPDATED,
@@ -24,12 +28,13 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
SortOrder.NEWEST
)
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
+ copyCookies()
val domain = getDomain()
val urlBuilder = StringBuilder()
.append("https://api.")
@@ -40,8 +45,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
} else {
urlBuilder.append("/api/search/catalog/?ordering=")
.append(getSortKey(sortOrder))
- if (tag != null) {
- urlBuilder.append("&genres=" + tag.key)
+ tags?.forEach { tag ->
+ urlBuilder.append("&genres=")
+ urlBuilder.append(tag.key)
}
}
urlBuilder
@@ -77,6 +83,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
}
override suspend fun getDetails(manga: Manga): Manga {
+ copyCookies()
val domain = getDomain()
val slug = manga.url.find(LAST_URL_PATH_REGEX)
?: throw ParseException("Cannot obtain slug from ${manga.url}")
@@ -93,6 +100,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
val chapters = loaderContext.httpGet(
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId"
).parseJson().getJSONArray("content")
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
return manga.copy(
description = content.getString("description"),
state = when (content.optJSONObject("status")?.getInt("id")) {
@@ -109,12 +117,16 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
},
chapters = chapters.mapIndexed { i, jo ->
val id = jo.getLong("id")
- val name = jo.getString("name")
+ val name = jo.getString("name").toTitleCase(Locale.ROOT)
+ val publishers = jo.getJSONArray("publishers")
MangaChapter(
id = generateUid(id),
url = "/api/titles/chapters/$id/",
number = chapters.length() - i,
name = buildString {
+ append("Том ")
+ append(jo.getString("tome"))
+ append(". ")
append("Глава ")
append(jo.getString("chapter"))
if (name.isNotEmpty()) {
@@ -122,7 +134,10 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
append(name)
}
},
- source = MangaSource.REMANGA
+ uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
+ scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
+ source = MangaSource.REMANGA,
+ branch = null,
)
}.asReversed()
)
@@ -156,6 +171,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
}
}
+ override fun isAuthorized(): Boolean {
+ return loaderContext.cookieJar.getCookies(getDomain()).any {
+ it.name == "user"
+ }
+ }
+
+ private fun copyCookies() {
+ val domain = getDomain()
+ loaderContext.cookieJar.copyCookies(domain, "api.$domain")
+ }
+
private fun getSortKey(order: SortOrder?) = when (order) {
SortOrder.UPDATED -> "-chapter_date"
SortOrder.POPULARITY -> "-rating"
@@ -167,8 +193,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
id = generateUid(jo.getLong("id")),
url = jo.getString("link"),
+ preview = null,
referer = referer,
- source = source
+ source = source,
)
private companion object {
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt
index 076da352d..77edbb5b1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt
@@ -29,7 +29,10 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa
name = a.text().trim(),
number = i + 1,
url = href,
- source = source
+ uploadDate = 0L,
+ source = source,
+ scanlator = null,
+ branch = null,
)
}
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index 370f69720..da6b349b6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -8,12 +8,15 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.channels.sendBlocking
+import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File
+import java.text.DateFormat
+import java.text.SimpleDateFormat
+import java.util.*
class AppSettings private constructor(private val prefs: SharedPreferences) :
SharedPreferences by prefs {
@@ -76,6 +79,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
+ var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false)
+
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
val zoomMode by EnumPreferenceDelegate(
@@ -104,6 +109,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
+ val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
+
fun getStorageDir(context: Context): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
@@ -121,6 +128,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
}
}
+ fun dateFormat(format: String? = prefs.getString(KEY_DATE_FORMAT, "")): DateFormat =
+ when (format) {
+ "" -> DateFormat.getDateInstance(DateFormat.SHORT)
+ else -> SimpleDateFormat(format, Locale.getDefault())
+ }
+
@Deprecated("Use observe()")
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
@@ -132,7 +145,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
fun observe() = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
- sendBlocking(key)
+ trySendBlocking(key)
}
prefs.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
@@ -152,6 +165,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_APP_SECTION = "app_section"
const val KEY_THEME = "theme"
const val KEY_THEME_AMOLED = "amoled_theme"
+ const val KEY_DATE_FORMAT = "date_format"
const val KEY_HIDE_TOOLBAR = "hide_toolbar"
const val KEY_SOURCES_ORDER = "sources_order"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
@@ -167,8 +181,6 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_TRACK_WARNING = "track_warning"
- const val KEY_APP_UPDATE = "app_update"
- const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
@@ -184,5 +196,16 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
+ const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
+ const val KEY_PAGES_NUMBERS = "pages_numbers"
+
+ // About
+ const val KEY_APP_UPDATE = "app_update"
+ const val KEY_APP_UPDATE_AUTO = "app_update_auto"
+ const val KEY_APP_TRANSLATION = "about_app_translation"
+ const val KEY_APP_GRATITUDES = "about_gratitudes"
+ const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
+ const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
+ const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
index 6ee3d0747..7a6036d06 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
@@ -27,5 +27,6 @@ interface SourceSettings {
const val KEY_DOMAIN = "domain"
const val KEY_USE_SSL = "ssl"
+ const val KEY_AUTH = "auth"
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
index 8deefacfe..0d38d95ce 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
@@ -14,16 +14,35 @@ sealed class DateTimeAgo : ListModel {
}
}
- data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
+ class MinutesAgo(val minutes: Int) : DateTimeAgo() {
+
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as MinutesAgo
+ return minutes == other.minutes
+ }
+
+ override fun hashCode(): Int = minutes
}
- data class HoursAgo(val hours: Int) : DateTimeAgo() {
+ class HoursAgo(val hours: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as HoursAgo
+ return hours == other.hours
+ }
+
+ override fun hashCode(): Int = hours
}
object Today : DateTimeAgo() {
@@ -38,10 +57,19 @@ sealed class DateTimeAgo : ListModel {
}
}
- data class DaysAgo(val days: Int) : DateTimeAgo() {
+ class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days)
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as DaysAgo
+ return days == other.days
+ }
+
+ override fun hashCode(): Int = days
}
object LongAgo : DateTimeAgo() {
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
index fdf29d88e..c2205bf30 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
@@ -15,19 +15,20 @@ import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
-import org.koitharu.kotatsu.download.DownloadService
+import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
class ChaptersFragment : BaseFragment(),
- OnListItemClickListener, ActionMode.Callback, AdapterView.OnItemSelectedListener {
+ OnListItemClickListener,
+ ActionMode.Callback,
+ AdapterView.OnItemSelectedListener {
private val viewModel by sharedViewModel()
@@ -105,9 +106,9 @@ class ChaptersFragment : BaseFragment(),
else -> super.onOptionsItemSelected(item)
}
- override fun onItemClick(item: MangaChapter, view: View) {
+ override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
- selectionDecoration?.toggleItemChecked(item.id)
+ selectionDecoration?.toggleItemChecked(item.chapter.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
@@ -116,6 +117,10 @@ class ChaptersFragment : BaseFragment(),
}
return
}
+ if (item.isMissing) {
+ (activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
+ return
+ }
val options = ActivityOptions.makeScaleUpAnimation(
view,
0,
@@ -127,17 +132,17 @@ class ChaptersFragment : BaseFragment(),
ReaderActivity.newIntent(
view.context,
viewModel.manga.value ?: return,
- ReaderState(item.id, 0, 0)
+ ReaderState(item.chapter.id, 0, 0)
), options.toBundle()
)
}
- override fun onItemLongClick(item: MangaChapter, view: View): Boolean {
+ override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return actionMode?.also {
- selectionDecoration?.setItemIsChecked(item.id, true)
+ selectionDecoration?.setItemIsChecked(item.chapter.id, true)
binding.recyclerViewChapters.invalidateItemDecorations()
it.invalidate()
} != null
@@ -148,7 +153,7 @@ class ChaptersFragment : BaseFragment(),
R.id.action_save -> {
DownloadService.start(
context ?: return false,
- viewModel.manga.value ?: return false,
+ viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds
)
mode.finish()
@@ -174,17 +179,20 @@ class ChaptersFragment : BaseFragment(),
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val manga = viewModel.manga.value
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
- menu.findItem(R.id.action_save).isVisible = manga?.source != MangaSource.LOCAL
mode.title = manga?.title
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
- val count = selectionDecoration?.checkedItemsCount ?: return false
+ val selectedIds = selectionDecoration?.checkedItemsIds ?: return false
+ val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty()
+ menu.findItem(R.id.action_save).isVisible = items.none { x ->
+ x.chapter.source == MangaSource.LOCAL
+ }
mode.subtitle = resources.getQuantityString(
R.plurals.chapters_from_x,
- count,
- count,
+ items.size,
+ items.size,
chaptersAdapter?.itemCount ?: 0
)
return true
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
index 8cc025234..2d7166ae6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -33,15 +33,18 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
-import org.koitharu.kotatsu.download.DownloadService
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper
+import org.koitharu.kotatsu.utils.ext.buildAlertDialog
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity(),
TabLayoutMediator.TabConfigurationStrategy {
- private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) {
+ private val viewModel by viewModel {
parametersOf(MangaIntent.from(intent))
}
@@ -82,13 +85,15 @@ class DetailsActivity : BaseActivity(),
finishAfterTransition()
}
else -> {
- Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG)
- .show()
+ binding.snackbar.show(e.getDisplayMessage(resources))
}
}
}
override fun onWindowInsetsChanged(insets: Insets) {
+ binding.snackbar.updatePadding(
+ bottom = insets.bottom
+ )
binding.toolbar.updatePadding(
top = insets.top,
left = insets.left,
@@ -228,6 +233,33 @@ class DetailsActivity : BaseActivity(),
binding.pager.isUserInputEnabled = true
}
+ fun showChapterMissingDialog(chapterId: Long) {
+ val remoteManga = viewModel.getRemoteManga()
+ if (remoteManga == null) {
+ Snackbar.make(binding.pager, R.string.chapter_is_missing, Snackbar.LENGTH_LONG)
+ .show()
+ return
+ }
+ buildAlertDialog(this) {
+ setMessage(R.string.chapter_is_missing_text)
+ setTitle(R.string.chapter_is_missing)
+ setNegativeButton(android.R.string.cancel, null)
+ setPositiveButton(R.string.read) { _, _ ->
+ startActivity(
+ ReaderActivity.newIntent(
+ this@DetailsActivity,
+ remoteManga,
+ ReaderState(chapterId, 0, 0)
+ )
+ )
+ }
+ setNeutralButton(R.string.download) { _, _ ->
+ DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId))
+ }
+ setCancelable(true)
+ }.show()
+ }
+
companion object {
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
index b3d11d3df..844cb06ac 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
@@ -1,19 +1,21 @@
package org.koitharu.kotatsu.details.ui
+import android.app.ActivityOptions
import android.os.Bundle
import android.text.Spanned
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
+import coil.request.ImageRequest
import coil.util.CoilUtils
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
@@ -23,20 +25,22 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
+import org.koitharu.kotatsu.core.model.MangaSource
+import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
+import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
+import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.*
-import kotlin.math.roundToInt
class DetailsFragment : BaseFragment(), View.OnClickListener,
View.OnLongClickListener {
private val viewModel by sharedViewModel()
private val coil by inject(mode = LazyThreadSafetyMode.NONE)
- private var tagsJob: Job? = null
override fun onInflateView(
inflater: LayoutInflater,
@@ -45,6 +49,11 @@ class DetailsFragment : BaseFragment(), View.OnClickList
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ binding.textViewAuthor.setOnClickListener(this)
+ binding.buttonFavorite.setOnClickListener(this)
+ binding.buttonRead.setOnClickListener(this)
+ binding.buttonRead.setOnLongClickListener(this)
+ binding.coverCard.setOnClickListener(this)
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
@@ -53,19 +62,33 @@ class DetailsFragment : BaseFragment(), View.OnClickList
private fun onMangaUpdated(manga: Manga) {
with(binding) {
- imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl)
- .referer(manga.publicUrl)
- .fallback(R.drawable.ic_placeholder)
- .placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey)
- .lifecycle(viewLifecycleOwner)
- .enqueueWith(coil)
+ // Main
+ loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
+ sourceContainer.isVisible = manga.source != MangaSource.LOCAL
textViewSource.text = manga.source.title
textViewDescription.text =
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
?: getString(R.string.no_description)
+ when (manga.state) {
+ MangaState.FINISHED -> {
+ textViewState.apply {
+ textAndVisible = resources.getString(R.string.state_finished)
+ drawableStart = ResourcesCompat.getDrawable(resources, R.drawable.ic_state_finished, context.theme)
+ }
+ }
+ MangaState.ONGOING -> {
+ textViewState.apply {
+ textAndVisible = resources.getString(R.string.state_ongoing)
+ drawableStart = ResourcesCompat.getDrawable(resources, R.drawable.ic_state_ongoing, context.theme)
+ }
+ }
+ else -> textViewState.isVisible = false
+ }
+
+ // Info containers
if (manga.chapters?.isNotEmpty() == true) {
chaptersContainer.isVisible = true
textViewChapters.text = manga.chapters.let {
@@ -96,10 +119,11 @@ class DetailsFragment : BaseFragment(), View.OnClickList
} else {
sizeContainer.isVisible = false
}
- buttonFavorite.setOnClickListener(this@DetailsFragment)
- buttonRead.setOnClickListener(this@DetailsFragment)
- buttonRead.setOnLongClickListener(this@DetailsFragment)
+
+ // Buttons
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
+
+ // Chips
bindTags(manga)
}
}
@@ -127,24 +151,53 @@ class DetailsFragment : BaseFragment(), View.OnClickList
}
private fun onLoadingStateChanged(isLoading: Boolean) {
- binding.progressBar.isVisible = isLoading
+ if (isLoading) {
+ binding.progressBar.show()
+ } else {
+ binding.progressBar.hide()
+ }
}
override fun onClick(v: View) {
- val manga = viewModel.manga.value
+ val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.button_favorite -> {
- FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
+ FavouriteCategoriesDialog.show(childFragmentManager, manga)
}
R.id.button_read -> {
+ val chapterId = viewModel.readingHistory.value?.chapterId
+ if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
+ (activity as? DetailsActivity)?.showChapterMissingDialog(chapterId)
+ } else {
+ startActivity(
+ ReaderActivity.newIntent(
+ context ?: return,
+ manga,
+ null
+ )
+ )
+ }
+ }
+ R.id.textView_author -> {
startActivity(
- ReaderActivity.newIntent(
- context ?: return,
- manga ?: return,
- null
+ SearchActivity.newIntent(
+ context = v.context,
+ source = manga.source,
+ query = manga.author ?: return,
)
)
}
+ R.id.cover_card -> {
+ val options = ActivityOptions.makeSceneTransitionAnimation(
+ requireActivity(),
+ binding.imageViewCover,
+ binding.imageViewCover.transitionName,
+ )
+ startActivity(
+ ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl),
+ options.toBundle()
+ )
+ }
}
}
@@ -186,16 +239,31 @@ class DetailsFragment : BaseFragment(), View.OnClickList
}
private fun bindTags(manga: Manga) {
- tagsJob?.cancel()
- tagsJob = viewLifecycleScope.launch {
- val tags = ArrayList(manga.tags.size + 2)
- for (tag in manga.tags) {
- tags += ChipsView.ChipModel(
+ binding.chipsTags.setChips(
+ manga.tags.map { tag ->
+ ChipsView.ChipModel(
title = tag.title,
icon = 0
)
}
- binding.chipsTags.setChips(tags)
+ )
+ }
+
+ private fun loadCover(manga: Manga) {
+ val currentCover = binding.imageViewCover.drawable
+ val request = ImageRequest.Builder(context ?: return)
+ .target(binding.imageViewCover)
+ if (currentCover != null) {
+ request.data(manga.largeCoverUrl ?: return)
+ .placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey)
+ .fallback(currentCover)
+ } else {
+ request.crossfade(true)
+ .data(manga.coverUrl)
+ .fallback(R.drawable.ic_placeholder)
}
+ request.referer(manga.publicUrl)
+ .lifecycle(viewLifecycleOwner)
+ .enqueueWith(coil)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
index 4c0292ad5..8b7b241e6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
@@ -11,7 +11,11 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.model.MangaChapter
+import org.koitharu.kotatsu.core.model.MangaSource
+import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.ChapterExtra
@@ -20,7 +24,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.mapToSet
+import org.koitharu.kotatsu.utils.ext.toTitleCase
import java.io.IOException
+import java.util.*
class DetailsViewModel(
intent: MangaIntent,
@@ -29,7 +35,7 @@ class DetailsViewModel(
private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository,
private val mangaDataRepository: MangaDataRepository,
- private val settings: AppSettings
+ private val settings: AppSettings,
) : BaseViewModel() {
private val mangaData = MutableStateFlow(intent.manga)
@@ -53,6 +59,18 @@ class DetailsViewModel(
trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
+ private val remoteManga = MutableStateFlow(null)
+ /*private val remoteManga = mangaData.mapLatest {
+ if (it?.source == MangaSource.LOCAL) {
+ runCatching {
+ val m = localMangaRepository.getRemoteManga(it) ?: return@mapLatest null
+ MangaRepository(m.source).getDetails(m)
+ }.getOrNull()
+ } else {
+ null
+ }
+ }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)*/
+
private val chaptersReversed = settings.observe()
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
.map { settings.chaptersReverse }
@@ -85,24 +103,19 @@ class DetailsViewModel(
val chapters = combine(
mangaData.map { it?.chapters.orEmpty() },
+ remoteManga,
history.map { it?.chapterId },
newChapters,
- chaptersReversed,
selectedBranch
- ) { chapters, currentId, newCount, reversed, branch ->
- val currentIndex = chapters.indexOfFirst { it.id == currentId }
- val firstNewIndex = chapters.size - newCount
- val res = chapters.mapIndexed { index, chapter ->
- chapter.toListItem(
- when {
- index >= firstNewIndex -> ChapterExtra.NEW
- index == currentIndex -> ChapterExtra.CURRENT
- index < currentIndex -> ChapterExtra.READ
- else -> ChapterExtra.UNREAD
- }
- )
- }.filter { it.chapter.branch == branch }
- if (reversed) res.asReversed() else res
+ ) { chapters, sourceManga, currentId, newCount, branch ->
+ val sourceChapters = sourceManga?.chapters
+ if (sourceChapters.isNullOrEmpty()) {
+ mapChapters(chapters, currentId, newCount, branch)
+ } else {
+ mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch)
+ }
+ }.combine(chaptersReversed) { list, reversed ->
+ if (reversed) list.asReversed() else list
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init {
@@ -116,11 +129,15 @@ class DetailsViewModel(
selectedBranch.value = if (hist != null) {
manga.chapters?.find { it.id == hist.chapterId }?.branch
} else {
- manga.chapters
- ?.groupBy { it.branch }
- ?.maxByOrNull { it.value.size }?.key
+ predictBranch(manga.chapters)
}
mangaData.value = manga
+ if (manga.source == MangaSource.LOCAL) {
+ remoteManga.value = runCatching {
+ val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
+ MangaRepository(m.source).getDetails(m)
+ }.getOrNull()
+ }
}
}
@@ -142,4 +159,102 @@ class DetailsViewModel(
fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch
}
+
+ fun getRemoteManga(): Manga? {
+ return remoteManga.value
+ }
+
+ private fun mapChapters(
+ chapters: List,
+ currentId: Long?,
+ newCount: Int,
+ branch: String?,
+ ): List {
+ val result = ArrayList(chapters.size)
+ val dateFormat = settings.dateFormat()
+ val currentIndex = chapters.indexOfFirst { it.id == currentId }
+ val firstNewIndex = chapters.size - newCount
+ for (i in chapters.indices) {
+ val chapter = chapters[i]
+ if (chapter.branch != branch) {
+ continue
+ }
+ result += chapter.toListItem(
+ extra = when {
+ i >= firstNewIndex -> ChapterExtra.NEW
+ i == currentIndex -> ChapterExtra.CURRENT
+ i < currentIndex -> ChapterExtra.READ
+ else -> ChapterExtra.UNREAD
+ },
+ isMissing = false,
+ dateFormat = dateFormat,
+ )
+ }
+ return result
+ }
+
+ private fun mapChaptersWithSource(
+ chapters: List,
+ sourceChapters: List,
+ currentId: Long?,
+ newCount: Int,
+ branch: String?,
+ ): List {
+ val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
+ val result = ArrayList(sourceChapters.size)
+ val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
+ val firstNewIndex = sourceChapters.size - newCount
+ val dateFormat = settings.dateFormat()
+ for (i in sourceChapters.indices) {
+ val chapter = sourceChapters[i]
+ if (chapter.branch != branch) {
+ continue
+ }
+ val localChapter = chaptersMap.remove(chapter.id)
+ result += localChapter?.toListItem(
+ extra = when {
+ i >= firstNewIndex -> ChapterExtra.NEW
+ i == currentIndex -> ChapterExtra.CURRENT
+ i < currentIndex -> ChapterExtra.READ
+ else -> ChapterExtra.UNREAD
+ },
+ isMissing = false,
+ dateFormat = dateFormat,
+ ) ?: chapter.toListItem(
+ extra = when {
+ i >= firstNewIndex -> ChapterExtra.NEW
+ i == currentIndex -> ChapterExtra.CURRENT
+ i < currentIndex -> ChapterExtra.READ
+ else -> ChapterExtra.UNREAD
+ },
+ isMissing = true,
+ dateFormat = dateFormat,
+ )
+ }
+ if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
+ result.ensureCapacity(result.size + chaptersMap.size)
+ chaptersMap.values.mapTo(result) {
+ it.toListItem(ChapterExtra.UNREAD, false, dateFormat)
+ }
+ result.sortBy { it.chapter.number }
+ }
+ return result
+ }
+
+ private fun predictBranch(chapters: List?): String? {
+ if (chapters.isNullOrEmpty()) {
+ return null
+ }
+ val groups = chapters.groupBy { it.branch }
+ val locale = Locale.getDefault()
+ var language = locale.displayLanguage.toTitleCase(locale)
+ if (groups.containsKey(language)) {
+ return language
+ }
+ language = locale.displayName.toTitleCase(locale)
+ if (groups.containsKey(language)) {
+ return language
+ }
+ return groups.maxByOrNull { it.value.size }?.key
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
index d339216c0..8a6d9b3e4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
@@ -3,28 +3,29 @@ package org.koitharu.kotatsu.details.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.history.domain.ChapterExtra
import org.koitharu.kotatsu.utils.ext.getThemeColor
+import org.koitharu.kotatsu.utils.ext.textAndVisible
fun chapterListItemAD(
- clickListener: OnListItemClickListener
+ clickListener: OnListItemClickListener,
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) {
itemView.setOnClickListener {
- clickListener.onItemClick(item.chapter, it)
+ clickListener.onItemClick(item, it)
}
itemView.setOnLongClickListener {
- clickListener.onItemLongClick(item.chapter, it)
+ clickListener.onItemLongClick(item, it)
}
- bind { payload ->
+ bind {
binding.textViewTitle.text = item.chapter.name
binding.textViewNumber.text = item.chapter.number.toString()
+ binding.textViewDescription.textAndVisible = item.description()
when (item.extra) {
ChapterExtra.UNREAD -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
@@ -43,5 +44,8 @@ fun chapterListItemAD(
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
}
+ binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f
+ binding.textViewDescription.alpha = if (item.isMissing) 0.3f else 1f
+ binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
index b98a427e7..855f0f4e9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.details.ui.adapter
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter(
- onItemClickListener: OnListItemClickListener
+ onItemClickListener: OnListItemClickListener,
) : AsyncListDifferDelegationAdapter(DiffCallback()) {
init {
@@ -20,10 +19,6 @@ class ChaptersAdapter(
return items[position].chapter.id
}
- fun setItems(newItems: List, callback: Runnable) {
- differ.submitList(newItems, callback)
- }
-
private class DiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
@@ -38,7 +33,7 @@ class ChaptersAdapter(
}
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? {
- if (oldItem.extra != newItem.extra) {
+ if (oldItem.extra != newItem.extra && oldItem.chapter == newItem.chapter) {
return newItem.extra
}
return null
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
index 5fad6e03a..f45228688 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
@@ -5,5 +5,17 @@ import org.koitharu.kotatsu.history.domain.ChapterExtra
data class ChapterListItem(
val chapter: MangaChapter,
- val extra: ChapterExtra
-)
+ val extra: ChapterExtra,
+ val isMissing: Boolean,
+ val uploadDate: String?,
+) {
+
+ fun description(): CharSequence? {
+ val scanlator = chapter.scanlator?.takeUnless { it.isBlank() }
+ return when {
+ uploadDate != null && scanlator != null -> "$uploadDate • $scanlator"
+ scanlator != null -> scanlator
+ else -> uploadDate
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
index dc1df8e0f..a5de76fd1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
@@ -2,8 +2,15 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra
+import java.text.DateFormat
-fun MangaChapter.toListItem(extra: ChapterExtra) = ChapterListItem(
+fun MangaChapter.toListItem(
+ extra: ChapterExtra,
+ isMissing: Boolean,
+ dateFormat: DateFormat,
+) = ChapterListItem(
chapter = this,
- extra = extra
+ extra = extra,
+ isMissing = isMissing,
+ uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt
deleted file mode 100644
index 10868d5e3..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-package org.koitharu.kotatsu.download
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.os.Build
-import androidx.core.app.NotificationCompat
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.drawable.toBitmap
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.model.Manga
-import org.koitharu.kotatsu.details.ui.DetailsActivity
-import org.koitharu.kotatsu.utils.PendingIntentCompat
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import kotlin.math.roundToInt
-
-class DownloadNotification(private val context: Context) {
-
- private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
- private val manager =
- context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
-
- init {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- && manager.getNotificationChannel(CHANNEL_ID) == null
- ) {
- val channel = NotificationChannel(
- CHANNEL_ID,
- context.getString(R.string.downloads),
- NotificationManager.IMPORTANCE_LOW
- )
- channel.enableVibration(false)
- channel.enableLights(false)
- channel.setSound(null, null)
- manager.createNotificationChannel(channel)
- }
- builder.setOnlyAlertOnce(true)
- builder.setDefaults(0)
- builder.color = ContextCompat.getColor(context, R.color.blue_primary)
- }
-
- fun fillFrom(manga: Manga) {
- builder.setContentTitle(manga.title)
- builder.setContentText(context.getString(R.string.manga_downloading_))
- builder.setProgress(1, 0, true)
- builder.setSmallIcon(android.R.drawable.stat_sys_download)
- builder.setLargeIcon(null)
- builder.setContentIntent(null)
- builder.setStyle(null)
- }
-
- fun setCancelId(startId: Int) {
- if (startId == 0) {
- builder.clearActions()
- } else {
- val intent = DownloadService.getCancelIntent(context, startId)
- builder.addAction(
- R.drawable.ic_cross,
- context.getString(android.R.string.cancel),
- PendingIntent.getService(
- context,
- startId,
- intent,
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
- )
- )
- }
- }
-
- fun setError(e: Throwable) {
- val message = e.getDisplayMessage(context.resources)
- builder.setProgress(0, 0, false)
- builder.setSmallIcon(android.R.drawable.stat_notify_error)
- builder.setSubText(context.getString(R.string.error))
- builder.setContentText(message)
- builder.setAutoCancel(true)
- builder.setContentIntent(null)
- builder.setCategory(NotificationCompat.CATEGORY_ERROR)
- builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
- }
-
- fun setLargeIcon(icon: Drawable?) {
- builder.setLargeIcon(icon?.toBitmap())
- }
-
- fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) {
- val max = chaptersTotal * PROGRESS_STEP
- val progress =
- chapter * PROGRESS_STEP + (page / pagesTotal.toFloat() * PROGRESS_STEP).roundToInt()
- val percent = (progress / max.toFloat() * 100).roundToInt()
- builder.setProgress(max, progress, false)
- builder.setContentText("%d%%".format(percent))
- builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
- builder.setStyle(null)
- }
-
- fun setWaitingForNetwork() {
- builder.setProgress(0, 0, false)
- builder.setContentText(context.getString(R.string.waiting_for_network))
- builder.setStyle(null)
- }
-
- fun setPostProcessing() {
- builder.setProgress(1, 0, true)
- builder.setContentText(context.getString(R.string.processing_))
- builder.setStyle(null)
- }
-
- fun setDone(manga: Manga) {
- builder.setProgress(0, 0, false)
- builder.setContentText(context.getString(R.string.download_complete))
- builder.setContentIntent(createIntent(context, manga))
- builder.setAutoCancel(true)
- builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
- builder.setCategory(null)
- builder.setStyle(null)
- }
-
- fun setCancelling() {
- builder.setProgress(1, 0, true)
- builder.setContentText(context.getString(R.string.cancelling_))
- builder.setContentIntent(null)
- builder.setStyle(null)
- }
-
- fun update(id: Int = NOTIFICATION_ID) {
- manager.notify(id, builder.build())
- }
-
- fun dismiss(id: Int = NOTIFICATION_ID) {
- manager.cancel(id)
- }
-
- operator fun invoke(): Notification = builder.build()
-
- companion object {
-
- const val NOTIFICATION_ID = 201
- const val CHANNEL_ID = "download"
-
- private const val PROGRESS_STEP = 20
-
- private fun createIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
- context,
- manga.hashCode(),
- DetailsActivity.newIntent(context, manga),
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt
deleted file mode 100644
index 2cc0ca30d..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt
+++ /dev/null
@@ -1,274 +0,0 @@
-package org.koitharu.kotatsu.download
-
-import android.content.Context
-import android.content.Intent
-import android.net.ConnectivityManager
-import android.os.PowerManager
-import android.webkit.MimeTypeMap
-import android.widget.Toast
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.lifecycleScope
-import coil.ImageLoader
-import coil.request.ImageRequest
-import kotlinx.coroutines.*
-import kotlinx.coroutines.sync.Mutex
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import okio.IOException
-import org.koin.android.ext.android.get
-import org.koin.android.ext.android.inject
-import org.koin.core.context.GlobalContext
-import org.koitharu.kotatsu.BuildConfig
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.base.ui.BaseService
-import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
-import org.koitharu.kotatsu.core.model.Manga
-import org.koitharu.kotatsu.core.network.CommonHeaders
-import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.local.data.MangaZip
-import org.koitharu.kotatsu.local.data.PagesCache
-import org.koitharu.kotatsu.local.domain.LocalMangaRepository
-import org.koitharu.kotatsu.utils.CacheUtils
-import org.koitharu.kotatsu.utils.ext.*
-import java.io.File
-import java.util.concurrent.TimeUnit
-import kotlin.collections.set
-import kotlin.math.absoluteValue
-
-class DownloadService : BaseService() {
-
- private lateinit var notification: DownloadNotification
- private lateinit var wakeLock: PowerManager.WakeLock
- private lateinit var connectivityManager: ConnectivityManager
-
- private val okHttp by inject()
- private val cache by inject()
- private val settings by inject()
- private val imageLoader by inject()
- private val jobs = HashMap()
- private val mutex = Mutex()
-
- override fun onCreate() {
- super.onCreate()
- notification = DownloadNotification(this)
- connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
- .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- super.onStartCommand(intent, flags, startId)
- when (intent?.action) {
- ACTION_DOWNLOAD_START -> {
- val manga = intent.getParcelableExtra(EXTRA_MANGA)
- val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
- if (manga != null) {
- jobs[startId] = downloadManga(manga, chapters, startId)
- Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
- } else {
- stopSelf(startId)
- }
- }
- ACTION_DOWNLOAD_CANCEL -> {
- val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
- jobs.remove(cancelId)?.cancel()
- stopSelf(startId)
- }
- else -> stopSelf(startId)
- }
- return START_NOT_STICKY
- }
-
- private fun downloadManga(manga: Manga, chaptersIds: Set?, startId: Int): Job {
- return lifecycleScope.launch(Dispatchers.Default) {
- mutex.lock()
- wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
- notification.fillFrom(manga)
- notification.setCancelId(startId)
- withContext(Dispatchers.Main) {
- startForeground(DownloadNotification.NOTIFICATION_ID, notification())
- }
- val destination = settings.getStorageDir(this@DownloadService)
- checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
- var output: MangaZip? = null
- try {
- val repo = mangaRepositoryOf(manga.source)
- val cover = runCatching {
- imageLoader.execute(
- ImageRequest.Builder(this@DownloadService)
- .data(manga.coverUrl)
- .build()
- ).drawable
- }.getOrNull()
- notification.setLargeIcon(cover)
- notification.update()
- val data = if (manga.chapters == null) repo.getDetails(manga) else manga
- output = MangaZip.findInDir(destination, data)
- output.prepare(data)
- val coverUrl = data.largeCoverUrl ?: data.coverUrl
- downloadFile(coverUrl, data.publicUrl, destination).let { file ->
- output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
- }
- val chapters = if (chaptersIds == null) {
- data.chapters.orEmpty()
- } else {
- data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
- }
- for ((chapterIndex, chapter) in chapters.withIndex()) {
- if (chaptersIds == null || chapter.id in chaptersIds) {
- val pages = repo.getPages(chapter)
- for ((pageIndex, page) in pages.withIndex()) {
- failsafe@ do {
- try {
- val url = repo.getPageUrl(page)
- val file =
- cache[url] ?: downloadFile(url, page.referer, destination)
- output.addPage(
- chapter,
- file,
- pageIndex,
- MimeTypeMap.getFileExtensionFromUrl(url)
- )
- } catch (e: IOException) {
- notification.setWaitingForNetwork()
- notification.update()
- connectivityManager.waitForNetwork()
- continue@failsafe
- }
- } while (false)
- notification.setProgress(
- chapters.size,
- pages.size,
- chapterIndex,
- pageIndex
- )
- notification.update()
- }
- }
- }
- notification.setCancelId(0)
- notification.setPostProcessing()
- notification.update()
- if (!output.compress()) {
- throw RuntimeException("Cannot create target file")
- }
- val result = get().getFromFile(output.file)
- notification.setDone(result)
- notification.dismiss()
- notification.update(manga.id.toInt().absoluteValue)
- } catch (_: CancellationException) {
- withContext(NonCancellable) {
- notification.setCancelling()
- notification.setCancelId(0)
- notification.update()
- }
- } catch (e: Throwable) {
- if (BuildConfig.DEBUG) {
- e.printStackTrace()
- }
- notification.setError(e)
- notification.setCancelId(0)
- notification.dismiss()
- notification.update(manga.id.toInt().absoluteValue)
- } finally {
- withContext(NonCancellable) {
- jobs.remove(startId)
- output?.cleanup()
- destination.sub(TEMP_PAGE_FILE).deleteAwait()
- withContext(Dispatchers.Main) {
- stopForeground(true)
- notification.dismiss()
- stopSelf(startId)
- }
- if (wakeLock.isHeld) {
- wakeLock.release()
- }
- mutex.unlock()
- }
- }
- }
- }
-
- private suspend fun downloadFile(url: String, referer: String, destination: File): File {
- val request = Request.Builder()
- .url(url)
- .header(CommonHeaders.REFERER, referer)
- .cacheControl(CacheUtils.CONTROL_DISABLED)
- .get()
- .build()
- val call = okHttp.newCall(request)
- var attempts = MAX_DOWNLOAD_ATTEMPTS
- val file = destination.sub(TEMP_PAGE_FILE)
- while (true) {
- try {
- val response = call.clone().await()
- withContext(Dispatchers.IO) {
- file.outputStream().use { out ->
- checkNotNull(response.body).byteStream().copyTo(out)
- }
- }
- return file
- } catch (e: IOException) {
- attempts--
- if (attempts <= 0) {
- throw e
- } else {
- delay(DOWNLOAD_ERROR_DELAY)
- }
- }
- }
- }
-
- companion object {
-
- private const val ACTION_DOWNLOAD_START =
- "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_START"
- private const val ACTION_DOWNLOAD_CANCEL =
- "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
-
- private const val EXTRA_MANGA = "manga"
- private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
- private const val EXTRA_CANCEL_ID = "cancel_id"
-
- private const val MAX_DOWNLOAD_ATTEMPTS = 3
- private const val DOWNLOAD_ERROR_DELAY = 500L
- private const val TEMP_PAGE_FILE = "page.tmp"
-
- fun start(context: Context, manga: Manga, chaptersIds: Collection? = null) {
- confirmDataTransfer(context) {
- val intent = Intent(context, DownloadService::class.java)
- intent.action = ACTION_DOWNLOAD_START
- intent.putExtra(EXTRA_MANGA, manga)
- if (chaptersIds != null) {
- intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
- }
- ContextCompat.startForegroundService(context, intent)
- }
- }
-
- fun getCancelIntent(context: Context, startId: Int) =
- Intent(context, DownloadService::class.java)
- .setAction(ACTION_DOWNLOAD_CANCEL)
- .putExtra(ACTION_DOWNLOAD_CANCEL, startId)
-
- private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
- val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- val settings = GlobalContext.get().get()
- if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
- CheckBoxAlertDialog.Builder(context)
- .setTitle(R.string.warning)
- .setMessage(R.string.network_consumption_warning)
- .setCheckBoxText(R.string.dont_ask_again)
- .setCheckBoxChecked(false)
- .setNegativeButton(android.R.string.cancel, null)
- .setPositiveButton(R.string._continue) { _, doNotAsk ->
- settings.isTrafficWarningEnabled = !doNotAsk
- callback()
- }.create()
- .show()
- } else {
- callback()
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
new file mode 100644
index 000000000..75905aa51
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
@@ -0,0 +1,239 @@
+package org.koitharu.kotatsu.download.domain
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.net.ConnectivityManager
+import android.webkit.MimeTypeMap
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.size.Scale
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okio.IOException
+import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.network.CommonHeaders
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.local.data.MangaZip
+import org.koitharu.kotatsu.local.data.PagesCache
+import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.utils.CacheUtils
+import org.koitharu.kotatsu.utils.ext.await
+import org.koitharu.kotatsu.utils.ext.deleteAwait
+import org.koitharu.kotatsu.utils.ext.waitForNetwork
+import java.io.File
+
+class DownloadManager(
+ private val context: Context,
+ private val settings: AppSettings,
+ private val imageLoader: ImageLoader,
+ private val okHttp: OkHttpClient,
+ private val cache: PagesCache,
+ private val localMangaRepository: LocalMangaRepository,
+) {
+
+ private val connectivityManager = context.getSystemService(
+ Context.CONNECTIVITY_SERVICE
+ ) as ConnectivityManager
+ private val coverWidth = context.resources.getDimensionPixelSize(
+ androidx.core.R.dimen.compat_notification_large_icon_max_width
+ )
+ private val coverHeight = context.resources.getDimensionPixelSize(
+ androidx.core.R.dimen.compat_notification_large_icon_max_height
+ )
+
+ fun downloadManga(manga: Manga, chaptersIds: Set?, startId: Int) = flow {
+ emit(State.Preparing(startId, manga, null))
+ var cover: Drawable? = null
+ val destination = settings.getStorageDir(context)
+ checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
+ var output: MangaZip? = null
+ try {
+ val repo = MangaRepository(manga.source)
+ cover = runCatching {
+ imageLoader.execute(
+ ImageRequest.Builder(context)
+ .data(manga.coverUrl)
+ .size(coverWidth, coverHeight)
+ .scale(Scale.FILL)
+ .build()
+ ).drawable
+ }.getOrNull()
+ emit(State.Preparing(startId, manga, cover))
+ val data = if (manga.chapters == null) repo.getDetails(manga) else manga
+ output = MangaZip.findInDir(destination, data)
+ output.prepare(data)
+ val coverUrl = data.largeCoverUrl ?: data.coverUrl
+ downloadFile(coverUrl, data.publicUrl, destination).let { file ->
+ output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
+ }
+ val chapters = if (chaptersIds == null) {
+ data.chapters.orEmpty()
+ } else {
+ data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
+ }
+ for ((chapterIndex, chapter) in chapters.withIndex()) {
+ if (chaptersIds == null || chapter.id in chaptersIds) {
+ val pages = repo.getPages(chapter)
+ for ((pageIndex, page) in pages.withIndex()) {
+ failsafe@ do {
+ try {
+ val url = repo.getPageUrl(page)
+ val file =
+ cache[url] ?: downloadFile(url, page.referer, destination)
+ output.addPage(
+ chapter,
+ file,
+ pageIndex,
+ MimeTypeMap.getFileExtensionFromUrl(url)
+ )
+ } catch (e: IOException) {
+ emit(State.WaitingForNetwork(startId, manga, cover))
+ connectivityManager.waitForNetwork()
+ continue@failsafe
+ }
+ } while (false)
+
+ emit(State.Progress(
+ startId, manga, cover,
+ totalChapters = chapters.size,
+ currentChapter = chapterIndex,
+ totalPages = pages.size,
+ currentPage = pageIndex,
+ ))
+ }
+ }
+ }
+ emit(State.PostProcessing(startId, manga, cover))
+ if (!output.compress()) {
+ throw RuntimeException("Cannot create target file")
+ }
+ val localManga = localMangaRepository.getFromFile(output.file)
+ emit(State.Done(startId, manga, cover, localManga))
+ } catch (_: CancellationException) {
+ emit(State.Cancelling(startId, manga, cover))
+ } catch (e: Throwable) {
+ if (BuildConfig.DEBUG) {
+ e.printStackTrace()
+ }
+ emit(State.Error(startId, manga, cover, e))
+ } finally {
+ withContext(NonCancellable) {
+ output?.cleanup()
+ File(destination, TEMP_PAGE_FILE).deleteAwait()
+ }
+ }
+ }.catch { e ->
+ emit(State.Error(startId, manga, null, e))
+ }
+
+ private suspend fun downloadFile(url: String, referer: String, destination: File): File {
+ val request = Request.Builder()
+ .url(url)
+ .header(CommonHeaders.REFERER, referer)
+ .cacheControl(CacheUtils.CONTROL_DISABLED)
+ .get()
+ .build()
+ val call = okHttp.newCall(request)
+ var attempts = MAX_DOWNLOAD_ATTEMPTS
+ val file = File(destination, TEMP_PAGE_FILE)
+ while (true) {
+ try {
+ val response = call.clone().await()
+ withContext(Dispatchers.IO) {
+ file.outputStream().use { out ->
+ checkNotNull(response.body).byteStream().copyTo(out)
+ }
+ }
+ return file
+ } catch (e: IOException) {
+ attempts--
+ if (attempts <= 0) {
+ throw e
+ } else {
+ delay(DOWNLOAD_ERROR_DELAY)
+ }
+ }
+ }
+ }
+
+ sealed interface State {
+
+ val startId: Int
+ val manga: Manga
+ val cover: Drawable?
+
+ data class Queued(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : State
+
+ data class Preparing(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : State
+
+ data class Progress(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ val totalChapters: Int,
+ val currentChapter: Int,
+ val totalPages: Int,
+ val currentPage: Int,
+ ): State {
+
+ val max: Int = totalChapters * totalPages
+
+ val progress: Int = totalPages * currentChapter + currentPage + 1
+
+ val percent: Float = progress.toFloat() / max
+ }
+
+ data class WaitingForNetwork(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ): State
+
+ data class Done(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ val localManga: Manga,
+ ) : State
+
+ data class Error(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ val error: Throwable,
+ ) : State
+
+ data class Cancelling(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ): State
+
+ data class PostProcessing(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : State
+ }
+
+ private companion object {
+
+ private const val MAX_DOWNLOAD_ATTEMPTS = 3
+ private const val DOWNLOAD_ERROR_DELAY = 500L
+ private const val TEMP_PAGE_FILE = "page.tmp"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
new file mode 100644
index 000000000..20724d769
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
@@ -0,0 +1,106 @@
+package org.koitharu.kotatsu.download.ui
+
+import androidx.core.view.isVisible
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.ItemDownloadBinding
+import org.koitharu.kotatsu.download.domain.DownloadManager
+import org.koitharu.kotatsu.utils.JobStateFlow
+import org.koitharu.kotatsu.utils.ext.*
+
+fun downloadItemAD(
+ scope: CoroutineScope,
+ coil: ImageLoader,
+) = adapterDelegateViewBinding, JobStateFlow, ItemDownloadBinding>(
+ { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
+) {
+
+ var job: Job? = null
+
+ bind {
+ job?.cancel()
+ job = item.onFirst { state ->
+ binding.imageViewCover.newImageRequest(state.manga.coverUrl)
+ .referer(state.manga.publicUrl)
+ .placeholder(state.cover)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .allowRgb565(true)
+ .enqueueWith(coil)
+ }.onEach { state ->
+ binding.textViewTitle.text = state.manga.title
+ when (state) {
+ is DownloadManager.State.Cancelling -> {
+ binding.textViewStatus.setText(R.string.cancelling_)
+ binding.progressBar.setIndeterminateCompat(true)
+ binding.progressBar.isVisible = true
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadManager.State.Done -> {
+ binding.textViewStatus.setText(R.string.download_complete)
+ binding.progressBar.setIndeterminateCompat(false)
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadManager.State.Error -> {
+ binding.textViewStatus.setText(R.string.error_occurred)
+ binding.progressBar.setIndeterminateCompat(false)
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
+ binding.textViewDetails.isVisible = true
+ }
+ is DownloadManager.State.PostProcessing -> {
+ binding.textViewStatus.setText(R.string.processing_)
+ binding.progressBar.setIndeterminateCompat(true)
+ binding.progressBar.isVisible = true
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadManager.State.Preparing -> {
+ binding.textViewStatus.setText(R.string.preparing_)
+ binding.progressBar.setIndeterminateCompat(true)
+ binding.progressBar.isVisible = true
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadManager.State.Progress -> {
+ binding.textViewStatus.setText(R.string.manga_downloading_)
+ binding.progressBar.setIndeterminateCompat(false)
+ binding.progressBar.isVisible = true
+ binding.progressBar.max = state.max
+ binding.progressBar.setProgressCompat(state.progress, true)
+ binding.textViewPercent.text = (state.percent * 100f).format(1) + "%"
+ binding.textViewPercent.isVisible = true
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadManager.State.Queued -> {
+ binding.textViewStatus.setText(R.string.queued)
+ binding.progressBar.setIndeterminateCompat(false)
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadManager.State.WaitingForNetwork -> {
+ binding.textViewStatus.setText(R.string.waiting_for_network)
+ binding.progressBar.setIndeterminateCompat(false)
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ }
+ }.launchIn(scope)
+ }
+
+ onViewRecycled {
+ job?.cancel()
+ job = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
new file mode 100644
index 000000000..b9f3695da
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
@@ -0,0 +1,59 @@
+package org.koitharu.kotatsu.download.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.graphics.Insets
+import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.koin.android.ext.android.get
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
+
+class DownloadsActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ val adapter = DownloadsAdapter(lifecycleScope, get())
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.adapter = adapter
+ LifecycleAwareServiceConnection.bindService(
+ this,
+ this,
+ Intent(this, DownloadService::class.java),
+ 0
+ ).service.flatMapLatest { binder ->
+ (binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
+ }.onEach {
+ adapter.items = it?.toList().orEmpty()
+ binding.textViewHolder.isVisible = it.isNullOrEmpty()
+ }.launchIn(lifecycleScope)
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.recyclerView.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ bottom = insets.bottom
+ )
+ binding.toolbar.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ top = insets.top
+ )
+ }
+
+ companion object {
+
+ fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
new file mode 100644
index 000000000..325180a79
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
@@ -0,0 +1,40 @@
+package org.koitharu.kotatsu.download.ui
+
+import androidx.recyclerview.widget.DiffUtil
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import kotlinx.coroutines.CoroutineScope
+import org.koitharu.kotatsu.download.domain.DownloadManager
+import org.koitharu.kotatsu.utils.JobStateFlow
+
+class DownloadsAdapter(
+ scope: CoroutineScope,
+ coil: ImageLoader,
+) : AsyncListDifferDelegationAdapter>(DiffCallback()) {
+
+ init {
+ delegatesManager.addDelegate(downloadItemAD(scope, coil))
+ setHasStableIds(true)
+ }
+
+ override fun getItemId(position: Int): Long {
+ return items[position].value.startId.toLong()
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback>() {
+
+ override fun areItemsTheSame(
+ oldItem: JobStateFlow,
+ newItem: JobStateFlow,
+ ): Boolean {
+ return oldItem.value.startId == newItem.value.startId
+ }
+
+ override fun areContentsTheSame(
+ oldItem: JobStateFlow,
+ newItem: JobStateFlow,
+ ): Boolean {
+ return oldItem.value == newItem.value
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
new file mode 100644
index 000000000..0d38a3326
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
@@ -0,0 +1,144 @@
+package org.koitharu.kotatsu.download.ui.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.toBitmap
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.details.ui.DetailsActivity
+import org.koitharu.kotatsu.download.domain.DownloadManager
+import org.koitharu.kotatsu.download.ui.DownloadsActivity
+import org.koitharu.kotatsu.utils.PendingIntentCompat
+import org.koitharu.kotatsu.utils.ext.format
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class DownloadNotification(
+ private val context: Context,
+ startId: Int,
+) {
+
+ private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
+ private val cancelAction = NotificationCompat.Action(
+ R.drawable.ic_cross,
+ context.getString(android.R.string.cancel),
+ PendingIntent.getBroadcast(
+ context,
+ startId,
+ DownloadService.getCancelIntent(startId),
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
+ )
+ )
+ private val listIntent = PendingIntent.getActivity(
+ context,
+ REQUEST_LIST,
+ DownloadsActivity.newIntent(context),
+ PendingIntentCompat.FLAG_IMMUTABLE,
+ )
+
+ init {
+ builder.setOnlyAlertOnce(true)
+ builder.setDefaults(0)
+ builder.color = ContextCompat.getColor(context, R.color.blue_primary)
+ }
+
+ fun create(state: DownloadManager.State): Notification {
+ builder.setContentTitle(state.manga.title)
+ builder.setContentText(context.getString(R.string.manga_downloading_))
+ builder.setProgress(1, 0, true)
+ builder.setSmallIcon(android.R.drawable.stat_sys_download)
+ builder.setContentIntent(listIntent)
+ builder.setStyle(null)
+ builder.setLargeIcon(state.cover?.toBitmap())
+ builder.clearActions()
+ when (state) {
+ is DownloadManager.State.Cancelling -> {
+ builder.setProgress(1, 0, true)
+ builder.setContentText(context.getString(R.string.cancelling_))
+ builder.setContentIntent(null)
+ builder.setStyle(null)
+ }
+ is DownloadManager.State.Done -> {
+ builder.setProgress(0, 0, false)
+ builder.setContentText(context.getString(R.string.download_complete))
+ builder.setContentIntent(createMangaIntent(context, state.localManga))
+ builder.setAutoCancel(true)
+ builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
+ builder.setCategory(null)
+ builder.setStyle(null)
+ }
+ is DownloadManager.State.Error -> {
+ val message = state.error.getDisplayMessage(context.resources)
+ builder.setProgress(0, 0, false)
+ builder.setSmallIcon(android.R.drawable.stat_notify_error)
+ builder.setSubText(context.getString(R.string.error))
+ builder.setContentText(message)
+ builder.setAutoCancel(true)
+ builder.setCategory(NotificationCompat.CATEGORY_ERROR)
+ builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ }
+ is DownloadManager.State.PostProcessing -> {
+ builder.setProgress(1, 0, true)
+ builder.setContentText(context.getString(R.string.processing_))
+ builder.setStyle(null)
+ }
+ is DownloadManager.State.Queued,
+ is DownloadManager.State.Preparing -> {
+ builder.setProgress(1, 0, true)
+ builder.setContentText(context.getString(R.string.preparing_))
+ builder.setStyle(null)
+ builder.addAction(cancelAction)
+ }
+ is DownloadManager.State.Progress -> {
+ builder.setProgress(state.max, state.progress, false)
+ builder.setContentText((state.percent * 100).format() + "%")
+ builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ builder.setStyle(null)
+ builder.addAction(cancelAction)
+ }
+ is DownloadManager.State.WaitingForNetwork -> {
+ builder.setProgress(0, 0, false)
+ builder.setContentText(context.getString(R.string.waiting_for_network))
+ builder.setStyle(null)
+ builder.addAction(cancelAction)
+ }
+ }
+ return builder.build()
+ }
+
+ private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
+ context,
+ manga.hashCode(),
+ DetailsActivity.newIntent(context, manga),
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
+ )
+
+ companion object {
+
+ private const val CHANNEL_ID = "download"
+ private const val REQUEST_LIST = 6
+
+ fun createChannel(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = NotificationManagerCompat.from(context)
+ if (manager.getNotificationChannel(CHANNEL_ID) == null) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.downloads),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ channel.enableVibration(false)
+ channel.enableLights(false)
+ channel.setSound(null, null)
+ manager.createNotificationChannel(channel)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
new file mode 100644
index 000000000..d44204533
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
@@ -0,0 +1,201 @@
+package org.koitharu.kotatsu.download.ui.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.ConnectivityManager
+import android.os.Binder
+import android.os.IBinder
+import android.os.PowerManager
+import android.widget.Toast
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.koin.android.ext.android.get
+import org.koin.core.context.GlobalContext
+import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseService
+import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
+import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.download.domain.DownloadManager
+import org.koitharu.kotatsu.utils.JobStateFlow
+import org.koitharu.kotatsu.utils.ext.toArraySet
+import java.util.concurrent.TimeUnit
+import kotlin.collections.set
+
+class DownloadService : BaseService() {
+
+ private lateinit var notificationManager: NotificationManagerCompat
+ private lateinit var wakeLock: PowerManager.WakeLock
+ private lateinit var downloadManager: DownloadManager
+
+ private val jobs = LinkedHashMap>()
+ private val jobCount = MutableStateFlow(0)
+ private val mutex = Mutex()
+ private val controlReceiver = ControlReceiver()
+ private var binder: DownloadBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ notificationManager = NotificationManagerCompat.from(this)
+ wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
+ downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
+ DownloadNotification.createChannel(this)
+ registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+ val manga = intent?.getParcelableExtra(EXTRA_MANGA)
+ val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
+ return if (manga != null) {
+ jobs[startId] = downloadManga(startId, manga, chapters)
+ jobCount.value = jobs.size
+ Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
+ START_REDELIVER_INTENT
+ } else {
+ stopSelf(startId)
+ START_NOT_STICKY
+ }
+ }
+
+ override fun onBind(intent: Intent): IBinder {
+ super.onBind(intent)
+ return binder ?: DownloadBinder(this).also { binder = it }
+ }
+
+ override fun onDestroy() {
+ unregisterReceiver(controlReceiver)
+ binder = null
+ super.onDestroy()
+ }
+
+ private fun downloadManga(
+ startId: Int,
+ manga: Manga,
+ chaptersIds: Set?,
+ ): JobStateFlow {
+ val initialState = DownloadManager.State.Queued(startId, manga, null)
+ val stateFlow = MutableStateFlow(initialState)
+ val job = lifecycleScope.launch {
+ mutex.withLock {
+ wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
+ val notification = DownloadNotification(this@DownloadService, startId)
+ startForeground(startId, notification.create(initialState))
+ try {
+ withContext(Dispatchers.Default) {
+ downloadManager.downloadManga(manga, chaptersIds, startId)
+ .collect { state ->
+ stateFlow.value = state
+ notificationManager.notify(startId, notification.create(state))
+ }
+ }
+ if (stateFlow.value is DownloadManager.State.Done) {
+ sendBroadcast(
+ Intent(ACTION_DOWNLOAD_COMPLETE)
+ .putExtra(EXTRA_MANGA, manga)
+ )
+ }
+ } finally {
+ ServiceCompat.stopForeground(
+ this@DownloadService,
+ if (isActive) {
+ ServiceCompat.STOP_FOREGROUND_DETACH
+ } else {
+ ServiceCompat.STOP_FOREGROUND_REMOVE
+ }
+ )
+ if (wakeLock.isHeld) {
+ wakeLock.release()
+ }
+ stopSelf(startId)
+ }
+ }
+ }
+ return JobStateFlow(stateFlow, job)
+ }
+
+ inner class ControlReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent?) {
+ when (intent?.action) {
+ ACTION_DOWNLOAD_CANCEL -> {
+ val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
+ jobs.remove(cancelId)?.cancel()
+ jobCount.value = jobs.size
+ }
+ }
+ }
+ }
+
+ class DownloadBinder(private val service: DownloadService) : Binder() {
+
+ val downloads: Flow>>
+ get() = service.jobCount.mapLatest { service.jobs.values }
+ }
+
+ companion object {
+
+ const val ACTION_DOWNLOAD_COMPLETE =
+ "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
+
+ private const val ACTION_DOWNLOAD_CANCEL =
+ "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
+
+ private const val EXTRA_MANGA = "manga"
+ private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
+ private const val EXTRA_CANCEL_ID = "cancel_id"
+
+ fun start(context: Context, manga: Manga, chaptersIds: Collection? = null) {
+ if (chaptersIds?.isEmpty() == true) {
+ return
+ }
+ confirmDataTransfer(context) {
+ val intent = Intent(context, DownloadService::class.java)
+ intent.putExtra(EXTRA_MANGA, manga)
+ if (chaptersIds != null) {
+ intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
+ }
+ ContextCompat.startForegroundService(context, intent)
+ }
+ }
+
+ fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
+ .putExtra(ACTION_DOWNLOAD_CANCEL, startId)
+
+ private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val settings = GlobalContext.get().get()
+ if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
+ CheckBoxAlertDialog.Builder(context)
+ .setTitle(R.string.warning)
+ .setMessage(R.string.network_consumption_warning)
+ .setCheckBoxText(R.string.dont_ask_again)
+ .setCheckBoxChecked(false)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string._continue) { _, doNotAsk ->
+ settings.isTrafficWarningEnabled = !doNotAsk
+ callback()
+ }.create()
+ .show()
+ } else {
+ callback()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
index e56cf6999..436dc12ea 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
-import org.koitharu.kotatsu.core.model.FavouriteCategory
@Dao
abstract class FavouriteCategoriesDao {
@@ -13,6 +12,9 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract fun observeAll(): Flow>
+ @Query("SELECT * FROM favourite_categories WHERE category_id = :id")
+ abstract fun observe(id: Long): Flow
+
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@@ -23,10 +25,13 @@ abstract class FavouriteCategoriesDao {
abstract suspend fun delete(id: Long)
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
- abstract suspend fun update(id: Long, title: String)
+ abstract suspend fun updateTitle(id: Long, title: String)
+
+ @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
+ abstract suspend fun updateOrder(id: Long, order: String)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
- abstract suspend fun update(id: Long, sortKey: Int)
+ abstract suspend fun updateSortKey(id: Long, sortKey: Int)
@Query("SELECT MAX(sort_key) FROM favourite_categories")
protected abstract suspend fun getMaxSortKey(): Int?
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
index 1da715ae5..aa054c441 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
@@ -4,21 +4,24 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
@Entity(tableName = "favourite_categories")
-data class FavouriteCategoryEntity(
+class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
- @ColumnInfo(name = "title") val title: String
+ @ColumnInfo(name = "title") val title: String,
+ @ColumnInfo(name = "order") val order: String,
) {
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(),
title = title,
sortKey = sortKey,
- createdAt = Date(createdAt)
+ order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
+ createdAt = Date(createdAt),
)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
index 95ae66e87..d79660a12 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
)
]
)
-data class FavouriteEntity(
+class FavouriteEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt
index 5b74bc08f..e98c84904 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
-data class FavouriteManga(
+class FavouriteManga(
@Embedded val favourite: FavouriteEntity,
@Relation(
parentColumn = "manga_id",
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
index 7928ba470..cf7d0f8f9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
@@ -1,8 +1,11 @@
package org.koitharu.kotatsu.favourites.data
import androidx.room.*
+import androidx.sqlite.db.SimpleSQLiteQuery
+import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.model.SortOrder
@Dao
abstract class FavouritesDao {
@@ -11,9 +14,13 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List
- @Transaction
- @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
- abstract fun observeAll(): Flow>
+ fun observeAll(order: SortOrder): Flow> {
+ val orderBy = getOrderBy(order)
+ val query = SimpleSQLiteQuery(
+ "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id GROUP BY favourites.manga_id ORDER BY $orderBy",
+ )
+ return observeAllRaw(query)
+ }
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
@@ -23,9 +30,14 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(categoryId: Long): List
- @Transaction
- @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
- abstract fun observeAll(categoryId: Long): Flow>
+ fun observeAll(categoryId: Long, order: SortOrder): Flow> {
+ val orderBy = getOrderBy(order)
+ val query = SimpleSQLiteQuery(
+ "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id WHERE category_id = ? GROUP BY favourites.manga_id ORDER BY $orderBy",
+ arrayOf(categoryId),
+ )
+ return observeAllRaw(query)
+ }
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
@@ -63,4 +75,16 @@ abstract class FavouritesDao {
insert(entity)
}
}
+
+ @Transaction
+ @RawQuery(observedEntities = [FavouriteEntity::class])
+ protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow>
+
+ private fun getOrderBy(sortOrder: SortOrder) = when(sortOrder) {
+ SortOrder.RATING -> "rating DESC"
+ SortOrder.NEWEST,
+ SortOrder.UPDATED -> "created_at DESC"
+ SortOrder.ALPHABETICAL -> "title ASC"
+ else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
index 15cefac92..48d6a34aa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
@@ -3,12 +3,14 @@ package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.utils.ext.mapItems
@@ -21,26 +23,26 @@ class FavouritesRepository(private val db: MangaDatabase) {
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
- fun observeAll(): Flow> {
- return db.favouritesDao.observeAll()
+ fun observeAll(order: SortOrder): Flow> {
+ return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
- suspend fun getAllManga(offset: Int): List {
- val entities = db.favouritesDao.findAll(offset, 20)
- return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
- }
-
suspend fun getManga(categoryId: Long): List {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
- fun observeAll(categoryId: Long): Flow> {
- return db.favouritesDao.observeAll(categoryId)
+ fun observeAll(categoryId: Long, order: SortOrder): Flow> {
+ return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
+ fun observeAll(categoryId: Long): Flow> {
+ return observeOrder(categoryId)
+ .flatMapLatest { order -> observeAll(categoryId, order) }
+ }
+
suspend fun getManga(categoryId: Long, offset: Int): List {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
@@ -77,25 +79,30 @@ class FavouritesRepository(private val db: MangaDatabase) {
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
- categoryId = 0
+ categoryId = 0,
+ order = SortOrder.UPDATED.name,
)
val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id)
}
suspend fun renameCategory(id: Long, title: String) {
- db.favouriteCategoriesDao.update(id, title)
+ db.favouriteCategoriesDao.updateTitle(id, title)
}
suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id)
}
+ suspend fun setCategoryOrder(id: Long, order: SortOrder) {
+ db.favouriteCategoriesDao.updateOrder(id, order.name)
+ }
+
suspend fun reorderCategories(orderedIds: List) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
- dao.update(id, i)
+ dao.updateSortKey(id, i)
}
}
}
@@ -117,4 +124,10 @@ class FavouritesRepository(private val db: MangaDatabase) {
suspend fun removeFromFavourites(manga: Manga) {
db.favouritesDao.delete(manga.id)
}
+
+ private fun observeOrder(categoryId: Long): Flow {
+ return db.favouriteCategoriesDao.observe(categoryId)
+ .map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
+ .distinctUntilChanged()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
index c20c2c6f3..096d26cfd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle
import android.view.*
import androidx.core.graphics.Insets
+import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
@@ -11,15 +12,17 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
+import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import java.util.*
-import kotlin.collections.ArrayList
class FavouritesContainerFragment : BaseFragment(),
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback,
@@ -27,9 +30,7 @@ class FavouritesContainerFragment : BaseFragment(),
override val recycledViewPool = RecyclerView.RecycledViewPool()
- private val viewModel by viewModel(
- mode = LazyThreadSafetyMode.NONE
- )
+ private val viewModel by viewModel()
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
CategoriesEditDelegate(requireContext(), this)
}
@@ -65,10 +66,22 @@ class FavouritesContainerFragment : BaseFragment(),
}
override fun onWindowInsetsChanged(insets: Insets) {
- binding.tabs.updatePadding(
- left = insets.left,
- right = insets.right
+ val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
+ binding.root.updatePadding(
+ top = headerHeight - insets.top
)
+ binding.pager.updatePadding(
+ top = -headerHeight
+ )
+ binding.tabs.apply {
+ updatePadding(
+ left = insets.left,
+ right = insets.right
+ )
+ updateLayoutParams {
+ topMargin = insets.top
+ }
+ }
}
private fun onCategoriesChanged(categories: List) {
@@ -100,11 +113,19 @@ class FavouritesContainerFragment : BaseFragment(),
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
- tabView.showPopupMenu(menuRes) {
+ tabView.showPopupMenu(menuRes, { menu ->
+ createOrderSubmenu(menu, category)
+ }) {
when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category)
R.id.action_create -> editDelegate.createCategory()
+ R.id.action_order -> return@showPopupMenu false
+ else -> {
+ val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
+ ?: return@showPopupMenu false
+ viewModel.setCategoryOrder(category.id, order)
+ }
}
true
}
@@ -125,11 +146,26 @@ class FavouritesContainerFragment : BaseFragment(),
private fun wrapCategories(categories: List): List {
val data = ArrayList(categories.size + 1)
- data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, Date())
+ data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
data += categories
return data
}
+ private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
+ val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
+ for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
+ val menuItem = submenu.add(
+ R.id.group_order,
+ Menu.NONE,
+ i,
+ item.titleRes
+ )
+ menuItem.isCheckable = true
+ menuItem.isChecked = item == category.order
+ }
+ submenu.setGroupCheckable(R.id.group_order, true, true)
+ }
+
companion object {
fun newInstance() = FavouritesContainerFragment()
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
index 83368d802..29de80809 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
@@ -36,7 +36,7 @@ class FavouritesPagerAdapter(
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position]
tab.text = item.title
- tab.view.tag = item
+ tab.view.tag = item.id
tab.view.setOnLongClickListener(this)
}
@@ -45,7 +45,8 @@ class FavouritesPagerAdapter(
}
override fun onLongClick(v: View): Boolean {
- val item = v.tag as? FavouriteCategory ?: return false
+ val itemId = v.tag as? Long ?: return false
+ val item = differ.currentList.find { x -> x.id == itemId } ?: return false
return longClickListener.onTabLongClick(v, item)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
index e2ad1153e..5461152a6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
@@ -5,6 +5,7 @@ import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle
+import android.view.Menu
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
@@ -20,6 +21,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.showPopupMenu
@@ -28,9 +30,7 @@ class CategoriesActivity : BaseActivity(),
OnListItemClickListener,
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
- private val viewModel by viewModel(
- mode = LazyThreadSafetyMode.NONE
- )
+ private val viewModel by viewModel()
private lateinit var adapter: CategoriesAdapter
private lateinit var reorderHelper: ItemTouchHelper
@@ -44,6 +44,7 @@ class CategoriesActivity : BaseActivity(),
adapter = CategoriesAdapter(this)
editDelegate = CategoriesEditDelegate(this, this)
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
+ binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
@@ -60,10 +61,17 @@ class CategoriesActivity : BaseActivity(),
}
override fun onItemClick(item: FavouriteCategory, view: View) {
- view.showPopupMenu(R.menu.popup_category) {
+ view.showPopupMenu(R.menu.popup_category, { menu ->
+ createOrderSubmenu(menu, item)
+ }) {
when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(item)
R.id.action_rename -> editDelegate.renameCategory(item)
+ R.id.action_order -> return@showPopupMenu false
+ else -> {
+ val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
+ viewModel.setCategoryOrder(item.id, order)
+ }
}
true
}
@@ -116,6 +124,21 @@ class CategoriesActivity : BaseActivity(),
viewModel.createCategory(name)
}
+ private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
+ val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
+ for ((i, item) in SORT_ORDERS.withIndex()) {
+ val menuItem = submenu.add(
+ R.id.group_order,
+ Menu.NONE,
+ i,
+ item.titleRes
+ )
+ menuItem.isCheckable = true
+ menuItem.isChecked = item == category.order
+ }
+ submenu.setGroupCheckable(R.id.group_order, true, true)
+ }
+
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) {
@@ -144,6 +167,12 @@ class CategoriesActivity : BaseActivity(),
companion object {
+ val SORT_ORDERS = arrayOf(
+ SortOrder.ALPHABETICAL,
+ SortOrder.NEWEST,
+ SortOrder.RATING,
+ )
+
fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
index 5bc88aa05..adf19ca9c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
class CategoriesAdapter(
- onItemClickListener: OnListItemClickListener
+ onItemClickListener: OnListItemClickListener,
) : AsyncListDifferDelegationAdapter(DiffCallback()) {
init {
@@ -20,12 +20,27 @@ class CategoriesAdapter(
private class DiffCallback : DiffUtil.ItemCallback() {
- override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
+ override fun areItemsTheSame(
+ oldItem: FavouriteCategory,
+ newItem: FavouriteCategory,
+ ): Boolean {
return oldItem.id == newItem.id
}
- override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
+ override fun areContentsTheSame(
+ oldItem: FavouriteCategory,
+ newItem: FavouriteCategory,
+ ): Boolean {
return oldItem.id == newItem.id && oldItem.title == newItem.title
+ && oldItem.order == newItem.order
+ }
+
+ override fun getChangePayload(
+ oldItem: FavouriteCategory,
+ newItem: FavouriteCategory,
+ ): Any? = when {
+ oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order
+ else -> super.getChangePayload(oldItem, newItem)
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
index a9a80ce59..7c93f3d25 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context
import android.text.InputType
+import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
@@ -32,7 +33,12 @@ class CategoriesEditDelegate(
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.rename) { _, name ->
- callback.onRenameCategory(category, name)
+ val trimmed = name.trim()
+ if (trimmed.isEmpty()) {
+ Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
+ } else {
+ callback.onRenameCategory(category, name)
+ }
}.create()
.show()
}
@@ -45,7 +51,12 @@ class CategoriesEditDelegate(
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.add) { _, name ->
- callback.onCreateCategory(name)
+ val trimmed = name.trim()
+ if (trimmed.isEmpty()) {
+ Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
+ } else {
+ callback.onCreateCategory(trimmed)
+ }
}.create()
.show()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
index 8c648a47b..a9202d749 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
@@ -4,10 +4,10 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.*
-import kotlin.collections.ArrayList
class FavouritesCategoriesViewModel(
private val repository: FavouritesRepository
@@ -19,23 +19,29 @@ class FavouritesCategoriesViewModel(
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
fun createCategory(name: String) {
- launchJob(Dispatchers.Default) {
+ launchJob {
repository.addCategory(name)
}
}
fun renameCategory(id: Long, name: String) {
- launchJob(Dispatchers.Default) {
+ launchJob {
repository.renameCategory(id, name)
}
}
fun deleteCategory(id: Long) {
- launchJob(Dispatchers.Default) {
+ launchJob {
repository.removeCategory(id)
}
}
+ fun setCategoryOrder(id: Long, order: SortOrder) {
+ launchJob {
+ repository.setCategoryOrder(id, order)
+ }
+ }
+
fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt
index 3736b959a..c1496aa4c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt
@@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener {
- private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) {
+ private val viewModel by viewModel {
parametersOf(requireNotNull(arguments?.getParcelable(MangaIntent.KEY_MANGA)))
}
@@ -36,7 +36,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet(mode = LazyThreadSafetyMode.NONE) {
+ override val viewModel by viewModel {
parametersOf(categoryId)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
index ec13864b9..344c3d5a1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -22,7 +23,11 @@ class FavouritesListViewModel(
) : MangaListViewModel(settings) {
override val content = combine(
- if (categoryId == 0L) repository.observeAll() else repository.observeAll(categoryId),
+ if (categoryId == 0L) {
+ repository.observeAll(SortOrder.NEWEST)
+ } else {
+ repository.observeAll(categoryId)
+ },
createListModeFlow()
) { list, mode ->
when {
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
index 6ca026947..bf6ea6304 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule
get() = module {
- single { HistoryRepository(get(), get()) }
+ single { HistoryRepository(get(), get(), get()) }
viewModel { HistoryListViewModel(get(), get(), get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
index 34a75b6a5..770181595 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
@@ -18,14 +18,14 @@ import java.util.*
)
]
)
-data class HistoryEntity(
+class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
- @ColumnInfo(name = "scroll") val scroll: Float
+ @ColumnInfo(name = "scroll") val scroll: Float,
) {
fun toMangaHistory() = MangaHistory(
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt
index d1c8ee2b0..55f41adc6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
-data class HistoryWithManga(
+class HistoryWithManga(
@Embedded val history: HistoryEntity,
@Relation(
parentColumn = "manga_id",
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
index cece71003..220f06dca 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
+import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet
class HistoryRepository(
private val db: MangaDatabase,
private val trackingRepository: TrackingRepository,
+ private val settings: AppSettings,
) {
suspend fun getList(offset: Int, limit: Int = 20): List {
@@ -45,6 +47,9 @@ class HistoryRepository(
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
+ if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
+ return
+ }
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao.upsert(tags)
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
index 311adb720..76dc36ee8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
class HistoryListFragment : MangaListFragment() {
- override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE)
+ override val viewModel by viewModel()
override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
index 539d68d77..8422232bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
import java.util.concurrent.TimeUnit
-import kotlin.collections.ArrayList
class HistoryListViewModel(
private val repository: HistoryRepository,
@@ -81,8 +80,11 @@ class HistoryListViewModel(
grouped: Boolean,
mode: ListMode
): List {
- val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size)
+ val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
var prevDate: DateTimeAgo? = null
+ if (!grouped) {
+ result += ListHeader(null, R.string.history)
+ }
for ((manga, history) in list) {
if (grouped) {
val date = timeAgo(history.updatedAt)
diff --git a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
new file mode 100644
index 000000000..ca14d2f4d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
@@ -0,0 +1,92 @@
+package org.koitharu.kotatsu.image.ui
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.graphics.Insets
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.view.updatePadding
+import coil.ImageLoader
+import coil.request.CachePolicy
+import coil.request.ImageRequest
+import coil.target.PoolableViewTarget
+import com.davemorrissey.labs.subscaleview.ImageSource
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+import org.koin.android.ext.android.inject
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.databinding.ActivityImageBinding
+import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.indicator
+
+class ImageActivity : BaseActivity() {
+
+ private val coil: ImageLoader by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityImageBinding.inflate(layoutInflater))
+ supportActionBar?.run {
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowTitleEnabled(false)
+ }
+ loadImage(intent.data)
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.toolbar.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ top = insets.top,
+ )
+ }
+
+ private fun loadImage(url: Uri?) {
+ ImageRequest.Builder(this)
+ .data(url)
+ .memoryCachePolicy(CachePolicy.DISABLED)
+ .lifecycle(this)
+ .target(SsivTarget(binding.ssiv))
+ .indicator(binding.progressBar)
+ .enqueueWith(coil)
+ }
+
+ private class SsivTarget(
+ override val view: SubsamplingScaleImageView,
+ ) : PoolableViewTarget {
+
+ override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
+
+ override fun onError(error: Drawable?) = setDrawable(error)
+
+ override fun onSuccess(result: Drawable) = setDrawable(result)
+
+ override fun onClear() = setDrawable(null)
+
+ override fun equals(other: Any?): Boolean {
+ return (this === other) || (other is SsivTarget && view == other.view)
+ }
+
+ override fun hashCode() = view.hashCode()
+
+ override fun toString() = "SsivTarget(view=$view)"
+
+ private fun setDrawable(drawable: Drawable?) {
+ if (drawable != null) {
+ view.setImage(ImageSource.bitmap(drawable.toBitmap()))
+ } else {
+ view.recycle()
+ }
+ }
+ }
+
+ companion object {
+
+ fun newIntent(context: Context, url: String): Intent {
+ return Intent(context, ImageActivity::class.java)
+ .setData(Uri.parse(url))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt b/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt
new file mode 100644
index 000000000..414a219db
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt
@@ -0,0 +1,30 @@
+package org.koitharu.kotatsu.list.domain
+
+import org.koitharu.kotatsu.core.model.MangaTag
+import org.koitharu.kotatsu.core.model.SortOrder
+
+class AvailableFilters(
+ val sortOrders: Set,
+ val tags: Set,
+) {
+
+ val size: Int
+ get() = sortOrders.size + tags.size
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as AvailableFilters
+ if (sortOrders != other.sortOrders) return false
+ if (tags != other.tags) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = sortOrders.hashCode()
+ result = 31 * result + tags.hashCode()
+ return result
+ }
+
+ fun isEmpty(): Boolean = sortOrders.isEmpty() && tags.isEmpty()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt
deleted file mode 100644
index e2147aa97..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package org.koitharu.kotatsu.list.ui
-
-import org.koitharu.kotatsu.core.model.MangaFilter
-import org.koitharu.kotatsu.core.model.MangaTag
-import org.koitharu.kotatsu.core.model.SortOrder
-
-data class MangaFilterConfig(
- val sortOrders: List,
- val tags: List,
- val currentFilter: MangaFilter?
-)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index d75a627df..11b29eaa4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
@@ -22,31 +22,29 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.ItemTypeDividerDecoration
-import org.koitharu.kotatsu.base.ui.list.decor.SectionItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
import org.koitharu.kotatsu.core.model.Manga
-import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
-import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
-import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
+import org.koitharu.kotatsu.list.ui.filter.FilterAdapter2
+import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.main.ui.AppBarOwner
+import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
-import org.koitharu.kotatsu.utils.ext.clearItemDecorations
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.toggleDrawer
-import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
+import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment : BaseFragment(),
- PaginationScrollListener.Callback, OnListItemClickListener, OnFilterChangedListener,
- SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener {
+ PaginationScrollListener.Callback, OnListItemClickListener,
+ SwipeRefreshLayout.OnRefreshListener {
private var listAdapter: MangaListAdapter? = null
+ private var filterAdapter: FilterAdapter2? = null
private var paginationListener: PaginationScrollListener? = null
private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
@@ -72,7 +70,14 @@ abstract class MangaListFragment : BaseFragment(),
super.onViewCreated(view, savedInstanceState)
drawer = binding.root as? DrawerLayout
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
- listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this, ::resolveException)
+ listAdapter = MangaListAdapter(
+ coil = get(),
+ lifecycleOwner = viewLifecycleOwner,
+ clickListener = this,
+ onRetryClick = ::resolveException,
+ onTagRemoveClick = viewModel::onRemoveFilterTag
+ )
+ filterAdapter = FilterAdapter2(viewModel)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
@@ -89,8 +94,7 @@ abstract class MangaListFragment : BaseFragment(),
}
with(binding.recyclerViewFilter) {
setHasFixedSize(true)
- addItemDecoration(ItemTypeDividerDecoration(view.context))
- addItemDecoration(SectionItemDecoration(false, this@MangaListFragment))
+ adapter = filterAdapter
}
(parentFragment as? RecycledViewPoolHolder)?.let {
@@ -108,6 +112,7 @@ abstract class MangaListFragment : BaseFragment(),
override fun onDestroyView() {
drawer = null
listAdapter = null
+ filterAdapter = null
paginationListener = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
@@ -198,42 +203,42 @@ abstract class MangaListFragment : BaseFragment(),
}
}
- protected fun onInitFilter(config: MangaFilterConfig) {
- binding.recyclerViewFilter.adapter = FilterAdapter(
- sortOrders = config.sortOrders,
- tags = config.tags,
- state = config.currentFilter,
- listener = this
- )
+ protected fun onInitFilter(filter: List) {
+ filterAdapter?.items = filter
drawer?.setDrawerLockMode(
- if (config.sortOrders.isEmpty() && config.tags.isEmpty()) {
+ if (filter.isEmpty()) {
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
} else {
DrawerLayout.LOCK_MODE_UNLOCKED
}
) ?: binding.dividerFilter?.let {
- it.isGone = config.sortOrders.isEmpty() && config.tags.isEmpty()
+ it.isGone = filter.isEmpty()
binding.recyclerViewFilter.isVisible = it.isVisible
}
activity?.invalidateOptionsMenu()
}
- @CallSuper
- override fun onFilterChanged(filter: MangaFilter) {
- drawer?.closeDrawers()
- }
-
override fun onWindowInsetsChanged(insets: Insets) {
- binding.recyclerView.updatePadding(
- bottom = insets.bottom
- )
+ val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerViewFilter.updatePadding(
+ top = headerHeight,
bottom = insets.bottom
)
binding.root.updatePadding(
left = insets.left,
right = insets.right
)
+ if (activity is MainActivity) {
+ binding.recyclerView.updatePadding(
+ top = headerHeight,
+ bottom = insets.bottom
+ )
+ binding.swipeRefreshLayout.setProgressViewOffset(
+ true,
+ headerHeight + resources.resolveDp(-72),
+ headerHeight + resources.resolveDp(10)
+ )
+ }
}
private fun onGridScaleChanged(scale: Float) {
@@ -272,20 +277,6 @@ abstract class MangaListFragment : BaseFragment(),
}
}
- final override fun isSection(position: Int): Boolean {
- return position == 0 || binding.recyclerViewFilter.adapter?.run {
- getItemViewType(position) != getItemViewType(position - 1)
- } ?: false
- }
-
- final override fun getSectionTitle(position: Int): CharSequence? {
- return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) {
- FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
- FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genre)
- else -> null
- }
- }
-
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt
index 0fa365629..e2a463f4e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt
@@ -1,22 +1,32 @@
package org.koitharu.kotatsu.list.ui
+import androidx.annotation.CallSuper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.*
+import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.model.MangaFilter
+import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
+import org.koitharu.kotatsu.list.domain.AvailableFilters
+import org.koitharu.kotatsu.list.ui.filter.FilterItem
+import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
abstract class MangaListViewModel(
- private val settings: AppSettings
-) : BaseViewModel() {
+ private val settings: AppSettings,
+) : BaseViewModel(), OnFilterChangedListener {
abstract val content: LiveData>
- val filter = MutableLiveData()
+ val filter = MutableLiveData>()
val listMode = MutableLiveData()
val gridScale = settings.observe()
.filter { it == AppSettings.KEY_GRID_SIZE }
@@ -36,6 +46,63 @@ abstract class MangaListViewModel(
}
}
+ protected var currentFilter: MangaFilter = MangaFilter(null, emptySet())
+ private set(value) {
+ field = value
+ onFilterChanged()
+ }
+ protected var availableFilters: AvailableFilters? = null
+ private var filterJob: Job? = null
+
+ final override fun onSortItemClick(item: FilterItem.Sort) {
+ currentFilter = currentFilter.copy(sortOrder = item.order)
+ }
+
+ final override fun onTagItemClick(item: FilterItem.Tag) {
+ val tags = if (item.isChecked) {
+ currentFilter.tags - item.tag
+ } else {
+ currentFilter.tags + item.tag
+ }
+ currentFilter = currentFilter.copy(tags = tags)
+ }
+
+ fun onRemoveFilterTag(tag: MangaTag) {
+ val tags = currentFilter.tags
+ if (tag !in tags) {
+ return
+ }
+ currentFilter = currentFilter.copy(tags = tags - tag)
+ }
+
+ @CallSuper
+ open fun onFilterChanged() {
+ val previousJob = filterJob
+ filterJob = launchJob(Dispatchers.Default) {
+ previousJob?.cancelAndJoin()
+ filter.postValue(
+ availableFilters?.run {
+ val list = ArrayList(size + 2)
+ if (sortOrders.isNotEmpty()) {
+ val selectedSort = currentFilter.sortOrder ?: sortOrders.first()
+ list += FilterItem.Header(R.string.sort_order)
+ sortOrders.sortedBy { it.ordinal }.mapTo(list) {
+ FilterItem.Sort(it, isSelected = it == selectedSort)
+ }
+ }
+ if (tags.isNotEmpty()) {
+ list += FilterItem.Header(R.string.genres)
+ tags.sortedBy { it.title }.mapTo(list) {
+ FilterItem.Tag(it, isChecked = it in currentFilter.tags)
+ }
+ }
+ ensureActive()
+ list
+ }.orEmpty()
+ )
+ }
+ }
+
abstract fun onRefresh()
abstract fun onRetry()
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt
new file mode 100644
index 000000000..4848a27b8
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt
@@ -0,0 +1,23 @@
+package org.koitharu.kotatsu.list.ui.adapter
+
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.base.ui.widgets.ChipsView
+import org.koitharu.kotatsu.core.model.MangaTag
+import org.koitharu.kotatsu.databinding.ItemCurrentFilterBinding
+import org.koitharu.kotatsu.list.ui.model.CurrentFilterModel
+import org.koitharu.kotatsu.list.ui.model.ListModel
+
+fun currentFilterAD(
+ onTagRemoveClick: (MangaTag) -> Unit,
+) = adapterDelegateViewBinding(
+ { inflater, parent -> ItemCurrentFilterBinding.inflate(inflater, parent, false) }
+) {
+
+ binding.chipsTags.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data ->
+ onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener)
+ }
+
+ bind {
+ binding.chipsTags.setChips(item.chips)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt
new file mode 100644
index 000000000..4d25060ac
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt
@@ -0,0 +1,19 @@
+package org.koitharu.kotatsu.list.ui.adapter
+
+import android.widget.TextView
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.list.ui.model.ListHeader
+import org.koitharu.kotatsu.list.ui.model.ListModel
+
+fun listHeaderAD() = adapterDelegate(R.layout.item_header) {
+
+ bind {
+ val textView = (itemView as TextView)
+ if (item.text != null) {
+ textView.text = item.text
+ } else {
+ textView.setText(item.textRes)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
index 0ca8ecabc..61cd60c03 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
@@ -6,6 +6,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
@@ -17,7 +18,8 @@ class MangaListAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener,
- onRetryClick: (Throwable) -> Unit
+ onRetryClick: (Throwable) -> Unit,
+ onTagRemoveClick: (MangaTag) -> Unit,
) : AsyncListDifferDelegationAdapter(DiffCallback()) {
init {
@@ -37,10 +39,8 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD())
- }
-
- fun setItems(list: List, commitCallback: Runnable) {
- differ.submitList(list, commitCallback)
+ .addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
+ .addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
}
private class DiffCallback : DiffUtil.ItemCallback() {
@@ -77,5 +77,7 @@ class MangaListAdapter(
const val ITEM_TYPE_ERROR_STATE = 6
const val ITEM_TYPE_ERROR_FOOTER = 7
const val ITEM_TYPE_EMPTY = 8
+ const val ITEM_TYPE_HEADER = 9
+ const val ITEM_TYPE_FILTER = 10
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt
deleted file mode 100644
index b0b32096a..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt
+++ /dev/null
@@ -1,93 +0,0 @@
-package org.koitharu.kotatsu.list.ui.filter
-
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
-import org.koitharu.kotatsu.core.model.MangaFilter
-import org.koitharu.kotatsu.core.model.MangaTag
-import org.koitharu.kotatsu.core.model.SortOrder
-import java.util.*
-import kotlin.collections.ArrayList
-
-class FilterAdapter(
- sortOrders: List = emptyList(),
- tags: List = emptyList(),
- state: MangaFilter?,
- private val listener: OnFilterChangedListener
-) : RecyclerView.Adapter