?,
+ sortOrder: SortOrder?
): List
{
if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList()
@@ -44,20 +43,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,
@@ -98,10 +98,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
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")
+ var name = item.getStringOrNull("chapter_name")
if (name.isNullOrBlank() || name == "null") {
name = "Том " + item.getInt("chapter_volume") +
" Глава " + item.getString("chapter_number")
@@ -128,17 +129,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 +147,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)
@@ -196,7 +197,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
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..bf5ce1b8f 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
@@ -23,11 +23,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 +43,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 +93,11 @@ 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()
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 +106,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 +117,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
url = href,
source = MangaSource.MANGATOWN,
number = i + 1,
- name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name
+ name = name.ifEmpty { "${manga.title} - ${i + 1}" }
)
}
)
@@ -121,7 +128,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
@@ -132,20 +139,20 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
referer = fullUrl,
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()
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 e4677e1be..0a94c3d04 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
@@ -20,17 +20,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,26 +45,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").attr("data-srcset")
- .split(',').firstOrNull()?.substringBeforeLast(' ').orEmpty(),
- 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
)
- },
- author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
- state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content")
+ }.orEmpty(),
+ 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
@@ -76,9 +78,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()) {
@@ -102,8 +104,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",
@@ -128,10 +130,12 @@ 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
@@ -148,7 +152,7 @@ class MangareadRepository(
?: throw ParseException("Root not found")
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img")
- val url = img.relUrl("data-src")
+ val url = img?.relUrl("src") ?: parseFailed("Page image not found")
MangaPage(
id = generateUid(url),
url = url,
@@ -170,4 +174,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
new file mode 100644
index 000000000..9c67b2146
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt
@@ -0,0 +1,206 @@
+package org.koitharu.kotatsu.core.parser.site
+
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+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.util.*
+
+abstract class NineMangaRepository(
+ loaderContext: MangaLoaderContext,
+ override val source: MangaSource,
+ override val defaultDomain: String,
+) : RemoteMangaRepository(loaderContext) {
+
+ init {
+ loaderContext.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes")
+ }
+
+ override val sortOrders: Set = EnumSet.of(
+ SortOrder.POPULARITY,
+ )
+
+ override suspend fun getList2(
+ offset: Int,
+ query: String?,
+ tags: Set?,
+ sortOrder: SortOrder?
+ ): List {
+ val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
+ val url = buildString {
+ append("https://")
+ append(getDomain())
+ 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(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")
+ ?: 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().orEmpty(),
+ altTitle = null,
+ coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(),
+ rating = Manga.NO_RATING,
+ author = null,
+ tags = emptySet(),
+ state = null,
+ source = source,
+ description = dd?.selectFirst("p")?.html(),
+ )
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val doc = loaderContext.httpGet(
+ manga.url.withDomain() + "?waring=1",
+ PREDEFINED_HEADERS
+ ).parseHtml()
+ val root = doc.body().selectFirst("div.manga")
+ ?: throw ParseException("Cannot find root")
+ val infoRoot = root.selectFirst("div.bookintro")
+ ?: throw ParseException("Cannot find info")
+ return manga.copy(
+ tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
+ ?.select("a")?.mapToSet { a ->
+ MangaTag(
+ title = a.text(),
+ key = a.attr("href").substringBetween("/", "."),
+ source = source,
+ )
+ }.orEmpty(),
+ author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.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") ?: parseFailed("Link not found")
+ MangaChapter(
+ id = generateUid(href),
+ name = a.text(),
+ number = i + 1,
+ url = href,
+ branch = null,
+ source = source,
+ )
+ }
+ )
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val doc = loaderContext.httpGet(chapter.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
+ return doc.body().getElementById("page")?.select("option")?.map { option ->
+ val url = option.attr("value")
+ MangaPage(
+ id = generateUid(url),
+ url = url,
+ referer = chapter.url.withDomain(),
+ preview = null,
+ source = source,
+ )
+ } ?: throw ParseException("Pages list not found at ${chapter.url}")
+ }
+
+ override suspend fun getPageUrl(page: MangaPage): String {
+ val doc = loaderContext.httpGet(page.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
+ val root = doc.body()
+ return root.selectFirst("a.pic_download")?.absUrl("href")
+ ?: throw ParseException("Page image not found")
+ }
+
+ override suspend fun getTags(): Set {
+ val doc = loaderContext.httpGet("https://${getDomain()}/search/?type=high", PREDEFINED_HEADERS)
+ .parseHtml()
+ 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().toTitleCase(),
+ key = cateId,
+ source = source
+ )
+ } ?: parseFailed("Root not found")
+ }
+
+ class English(loaderContext: MangaLoaderContext) : NineMangaRepository(
+ loaderContext,
+ MangaSource.NINEMANGA_EN,
+ "www.ninemanga.com",
+ )
+
+ class Spanish(loaderContext: MangaLoaderContext) : NineMangaRepository(
+ loaderContext,
+ MangaSource.NINEMANGA_ES,
+ "es.ninemanga.com",
+ )
+
+ class Russian(loaderContext: MangaLoaderContext) : NineMangaRepository(
+ loaderContext,
+ MangaSource.NINEMANGA_RU,
+ "ru.ninemanga.com",
+ )
+
+ class Deutsch(loaderContext: MangaLoaderContext) : NineMangaRepository(
+ loaderContext,
+ MangaSource.NINEMANGA_DE,
+ "de.ninemanga.com",
+ )
+
+ class Brazil(loaderContext: MangaLoaderContext) : NineMangaRepository(
+ loaderContext,
+ MangaSource.NINEMANGA_BR,
+ "br.ninemanga.com",
+ )
+
+ class Italiano(loaderContext: MangaLoaderContext) : NineMangaRepository(
+ loaderContext,
+ MangaSource.NINEMANGA_IT,
+ "it.ninemanga.com",
+ )
+
+ class Francais(loaderContext: MangaLoaderContext) : NineMangaRepository(
+ loaderContext,
+ MangaSource.NINEMANGA_FR,
+ "fr.ninemanga.com",
+ )
+
+ private companion object {
+
+ const val PAGE_SIZE = 26
+
+ val PREDEFINED_HEADERS = Headers.Builder()
+ .add("Accept-Language", "en-US;q=0.7,en;q=0.3")
+ .build()
+ }
+}
\ 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 79d0d2581..17a3dd5d7 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
@@ -9,7 +9,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 RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@@ -18,18 +17,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
override val defaultDomain = "remanga.org"
override val sortOrders: Set = EnumSet.of(
+ SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.RATING,
- SortOrder.ALPHABETICAL,
- SortOrder.UPDATED,
SortOrder.NEWEST
)
- 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 urlBuilder = StringBuilder()
@@ -41,8 +39,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
@@ -162,7 +161,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
SortOrder.POPULARITY -> "-rating"
SortOrder.RATING -> "-votes"
SortOrder.NEWEST -> "-id"
- else -> "-rating"
+ else -> "-chapter_date"
}
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
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 2c2839576..a2f2fbc59 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
@@ -41,6 +41,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false)
+ val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true)
+
var gridSize by IntPreferenceDelegate(KEY_GRID_SIZE, defaultValue = 100)
val readerPageSwitch by StringSetPreferenceDelegate(
@@ -99,6 +101,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN)
+ val isSourcesSelected: Boolean
+ get() = KEY_SOURCES_HIDDEN in prefs
+
fun getStorageDir(context: Context): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
@@ -147,6 +152,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_HIDE_TOOLBAR = "hide_toolbar"
const val KEY_SOURCES_ORDER = "sources_order"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
@@ -160,8 +166,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACK_SOURCES = "track_sources"
- const val KEY_APP_UPDATE = "app_update"
- const val KEY_APP_UPDATE_AUTO = "app_update_auto"
+ const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
@@ -177,5 +182,14 @@ 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"
+
+ // 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/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
index bc8ec0a3d..7e3bd8622 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
@@ -2,13 +2,12 @@ package org.koitharu.kotatsu.details
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
-import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.details.ui.DetailsViewModel
val detailsModule
get() = module {
- viewModel { (intent: MangaIntent) ->
- DetailsViewModel(intent, get(), get(), get(), get(), get(), get())
+ viewModel { intent ->
+ DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get())
}
}
\ No newline at end of file
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 eacf132f9..84745b56d 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,9 +33,12 @@ 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(),
@@ -113,7 +116,7 @@ class DetailsActivity : BaseActivity(),
}
}
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_details, menu)
return super.onCreateOptionsMenu(menu)
}
@@ -228,6 +231,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 50a5d7302..a427f2b3e 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
@@ -13,7 +13,6 @@ import androidx.core.view.updatePadding
import coil.ImageLoader
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 +22,20 @@ 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.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.*
-import kotlin.math.roundToInt
+import kotlin.random.Random
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,
@@ -61,16 +60,43 @@ class DetailsFragment : BaseFragment(), View.OnClickList
.enqueueWith(coil)
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)
- if (manga.rating == Manga.NO_RATING) {
- ratingBar.isVisible = false
+ if (manga.chapters?.isNotEmpty() == true) {
+ chaptersContainer.isVisible = true
+ textViewChapters.text = manga.chapters.let {
+ resources.getQuantityString(
+ R.plurals.chapters,
+ it.size,
+ manga.chapters.size
+ )
+ }
} else {
- ratingBar.progress = (ratingBar.max * manga.rating).roundToInt()
- ratingBar.isVisible = true
+ chaptersContainer.isVisible = false
}
- imageViewFavourite.setOnClickListener(this@DetailsFragment)
+ if (manga.rating == Manga.NO_RATING) {
+ ratingContainer.isVisible = false
+ } else {
+ textViewRating.text = String.format("%.1f", manga.rating * 5)
+ ratingContainer.isVisible = true
+ }
+ val file = manga.url.toUri().toFileOrNull()
+ if (file != null) {
+ viewLifecycleScope.launch {
+ val size = withContext(Dispatchers.IO) {
+ file.length()
+ }
+ textViewSize.text = FileSizeUtils.formatBytes(requireContext(), size)
+ }
+ sizeContainer.isVisible = true
+ } else {
+ sizeContainer.isVisible = false
+ }
+ buttonFavorite.setOnClickListener(this@DetailsFragment)
buttonRead.setOnClickListener(this@DetailsFragment)
buttonRead.setOnLongClickListener(this@DetailsFragment)
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
@@ -91,33 +117,42 @@ class DetailsFragment : BaseFragment(), View.OnClickList
}
private fun onFavouriteChanged(isFavourite: Boolean) {
- binding.imageViewFavourite.setImageResource(
+ with(binding.buttonFavorite) {
if (isFavourite) {
- R.drawable.ic_heart
+ this.setIconResource(R.drawable.ic_heart)
} else {
- R.drawable.ic_heart_outline
+ this.setIconResource(R.drawable.ic_heart_outline)
}
- )
+ }
}
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.imageView_favourite -> {
- FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
+ R.id.button_favorite -> {
+ FavouriteCategoriesDialog.show(childFragmentManager, manga)
}
R.id.button_read -> {
- startActivity(
- ReaderActivity.newIntent(
- context ?: return,
- manga ?: return,
- null
+ 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
+ )
)
- )
+ }
}
}
}
@@ -160,37 +195,13 @@ class DetailsFragment : BaseFragment(), View.OnClickList
}
private fun bindTags(manga: Manga) {
- tagsJob?.cancel()
- tagsJob = viewLifecycleScope.launch {
- val tags = ArrayList(manga.tags.size + 2)
- if (manga.author != null) {
- tags += ChipsView.ChipModel(
- title = manga.author,
- icon = R.drawable.ic_chip_user
- )
- }
- for (tag in manga.tags) {
- tags += ChipsView.ChipModel(
+ binding.chipsTags.setChips(
+ manga.tags.map { tag ->
+ ChipsView.ChipModel(
title = tag.title,
- icon = R.drawable.ic_chip_tag
+ icon = 0
)
}
- val file = manga.url.toUri().toFileOrNull()
- if (file != null) {
- val size = withContext(Dispatchers.IO) {
- file.length()
- }
- tags += ChipsView.ChipModel(
- title = FileSizeUtils.formatBytes(requireContext(), size),
- icon = R.drawable.ic_chip_storage
- )
- } else {
- tags += ChipsView.ChipModel(
- title = manga.source.title,
- icon = R.drawable.ic_chip_web
- )
- }
- binding.chipsTags.setChips(tags)
- }
+ )
}
}
\ 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..ebd377a50 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
@@ -29,7 +33,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 +57,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 +101,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 {
@@ -121,6 +132,12 @@ class DetailsViewModel(
?.maxByOrNull { it.value.size }?.key
}
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,80 @@ 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 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
+ )
+ }
+ 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
+ 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
+ ) ?: chapter.toListItem(
+ extra = when {
+ i >= firstNewIndex -> ChapterExtra.NEW
+ i == currentIndex -> ChapterExtra.CURRENT
+ i < currentIndex -> ChapterExtra.READ
+ else -> ChapterExtra.UNREAD
+ },
+ isMissing = true
+ )
+ }
+ 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)
+ }
+ result.sortBy { it.chapter.number }
+ }
+ return result
+ }
}
\ 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..983f322a8 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,23 +3,22 @@ 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
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 ->
@@ -43,5 +42,7 @@ fun chapterListItemAD(
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
}
+ binding.textViewTitle.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..46dc930d1 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 {
@@ -38,7 +37,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..82f00decf 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,6 @@ import org.koitharu.kotatsu.history.domain.ChapterExtra
data class ChapterListItem(
val chapter: MangaChapter,
- val extra: ChapterExtra
+ val extra: ChapterExtra,
+ val isMissing: Boolean,
)
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..0a1609989 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
@@ -3,7 +3,11 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra
-fun MangaChapter.toListItem(extra: ChapterExtra) = ChapterListItem(
+fun MangaChapter.toListItem(
+ extra: ChapterExtra,
+ isMissing: Boolean,
+) = ChapterListItem(
chapter = this,
- extra = extra
+ extra = extra,
+ isMissing = isMissing,
)
\ 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..649b67342
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
@@ -0,0 +1,101 @@
+package org.koitharu.kotatsu.download.ui
+
+import androidx.core.view.isVisible
+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.format
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
+
+fun downloadItemAD(
+ scope: CoroutineScope,
+) = adapterDelegateViewBinding, JobStateFlow, ItemDownloadBinding>(
+ { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
+) {
+
+ var job: Job? = null
+
+ bind {
+ job?.cancel()
+ job = item.onEach { state ->
+ binding.textViewTitle.text = state.manga.title
+ binding.imageViewCover.setImageDrawable(
+ state.cover ?: getDrawable(R.drawable.ic_placeholder)
+ )
+ 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..5b39881ed
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
@@ -0,0 +1,58 @@
+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.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)
+ 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..e6998f894
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
@@ -0,0 +1,38 @@
+package org.koitharu.kotatsu.download.ui
+
+import androidx.recyclerview.widget.DiffUtil
+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,
+) : AsyncListDifferDelegationAdapter>(DiffCallback()) {
+
+ init {
+ delegatesManager.addDelegate(downloadItemAD(scope))
+ 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/FavouritesModule.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
index c0237f1cf..4db6c0c27 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
-import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
@@ -13,11 +12,11 @@ val favouritesModule
single { FavouritesRepository(get()) }
- viewModel { (categoryId: Long) ->
- FavouritesListViewModel(categoryId, get(), get())
+ viewModel { categoryId ->
+ FavouritesListViewModel(categoryId.get(), get(), get())
}
viewModel { FavouritesCategoriesViewModel(get()) }
- viewModel { (manga: Manga) ->
- MangaCategoriesViewModel(manga, get())
+ viewModel { manga ->
+ MangaCategoriesViewModel(manga.get(), get())
}
}
\ 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..f66c87fae 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,6 +4,7 @@ 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")
@@ -12,13 +13,15 @@ data class FavouriteCategoryEntity(
@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/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..399314311 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,
@@ -65,10 +68,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 +115,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 +148,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..17639f06f 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
@@ -44,6 +46,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 +63,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 +126,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 +169,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/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/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
index bdbd4991a..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,12 +23,18 @@ 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 {
list.isEmpty() -> listOf(
EmptyState(
+ R.drawable.ic_heart_outline,
+ R.string.text_empty_holder_primary,
if (categoryId == 0L) {
R.string.you_have_not_favourites_yet
} else {
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 081fdc0e0..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,
@@ -44,7 +43,7 @@ class HistoryListViewModel(
createListModeFlow()
) { list, grouped, mode ->
when {
- list.isEmpty() -> listOf(EmptyState(R.string.text_history_holder))
+ list.isEmpty() -> listOf(EmptyState(R.drawable.ic_history, R.string.text_history_holder_primary, R.string.text_history_holder_secondary))
else -> mapList(list, grouped, mode)
}
}.onFirst {
@@ -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/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index da163f722..1fa44344c 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
@@ -4,16 +4,15 @@ import android.os.Bundle
import android.view.*
import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu
+import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.drawerlayout.widget.DrawerLayout
-import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
@@ -37,11 +36,10 @@ 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.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,
@@ -73,7 +71,13 @@ 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
+ )
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
@@ -81,6 +85,10 @@ abstract class MangaListFragment : BaseFragment(),
addOnScrollListener(paginationListener!!)
}
with(binding.swipeRefreshLayout) {
+ setColorSchemeColors(
+ ContextCompat.getColor(context, R.color.color_primary),
+ ContextCompat.getColor(context, R.color.color_primary_variant)
+ )
setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled
}
@@ -215,22 +223,29 @@ abstract class MangaListFragment : BaseFragment(),
activity?.invalidateOptionsMenu()
}
- @CallSuper
- override fun onFilterChanged(filter: MangaFilter) {
- drawer?.closeDrawers()
- }
+ override fun onFilterChanged(filter: MangaFilter) = Unit
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) {
@@ -246,13 +261,9 @@ abstract class MangaListFragment : BaseFragment(),
when (mode) {
ListMode.LIST -> {
layoutManager = LinearLayoutManager(context)
- addItemDecoration(
- DividerItemDecoration(
- context,
- RecyclerView.VERTICAL
- )
- )
- updatePadding(left = 0, right = 0)
+ val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
+ addItemDecoration(SpacingItemDecoration(spacing))
+ updatePadding(left = spacing, right = spacing)
}
ListMode.DETAILED_LIST -> {
layoutManager = LinearLayoutManager(context)
@@ -282,7 +293,7 @@ abstract class MangaListFragment : BaseFragment(),
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)
+ FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres)
else -> null
}
}
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..3d94d1b65 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
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.ui.BaseViewModel
+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.ui.model.ListModel
@@ -36,6 +37,8 @@ abstract class MangaListViewModel(
}
}
+ open fun onRemoveFilterTag(tag: MangaTag) = Unit
+
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/EmptyStateListAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
index 77481c8c5..29ecc9b30 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
@@ -1,14 +1,23 @@
package org.koitharu.kotatsu.list.ui.adapter
-import android.widget.TextView
-import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
-import org.koitharu.kotatsu.R
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
-fun emptyStateListAD() = adapterDelegate(R.layout.item_empty_state) {
+fun emptyStateListAD() = adapterDelegateViewBinding(
+ { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }
+) {
bind {
- (itemView as TextView).setText(item.text)
+ with(binding.icon) {
+ setImageResource(item.icon)
+ }
+ with(binding.textPrimary) {
+ setText(item.textPrimary)
+ }
+ with(binding.textSecondary) {
+ setText(item.textSecondary)
+ }
}
}
\ 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..cc4c80967 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,6 +39,8 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD())
+ .addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
+ .addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
}
fun setItems(list: List, commitCallback: Runnable) {
@@ -77,5 +81,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
index b0b32096a..983fdf0f1 100644
--- 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
@@ -6,20 +6,15 @@ 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(),
+ private val sortOrders: List = emptyList(),
+ private val tags: List = emptyList(),
state: MangaFilter?,
private val listener: OnFilterChangedListener
) : RecyclerView.Adapter>() {
- private val sortOrders = ArrayList(sortOrders)
- private val tags = ArrayList(Collections.singletonList(null) + tags)
-
- private var currentState = state ?: MangaFilter(sortOrders.first(), null)
+ private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
@@ -29,7 +24,7 @@ class FilterAdapter(
}
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
itemView.setOnClickListener {
- setCheckedTag(boundData)
+ setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
}
}
else -> throw IllegalArgumentException("Unknown viewType $viewType")
@@ -45,7 +40,7 @@ class FilterAdapter(
}
is FilterTagHolder -> {
val item = tags[position - sortOrders.size]
- holder.bind(item, item == currentState.tag)
+ holder.bind(item, item in currentState.tags)
}
}
}
@@ -55,19 +50,25 @@ class FilterAdapter(
else -> VIEW_TYPE_TAG
}
- fun setCheckedTag(tag: MangaTag?) {
- if (tag != currentState.tag) {
- val oldItemPos = tags.indexOf(currentState.tag)
- val newItemPos = tags.indexOf(tag)
- currentState = currentState.copy(tag = tag)
- if (oldItemPos in tags.indices) {
- notifyItemChanged(sortOrders.size + oldItemPos)
+ fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
+ currentState = if (tag in currentState.tags) {
+ if (!isChecked) {
+ currentState.copy(tags = currentState.tags - tag)
+ } else {
+ return
}
- if (newItemPos in tags.indices) {
- notifyItemChanged(sortOrders.size + newItemPos)
+ } else {
+ if (isChecked) {
+ currentState.copy(tags = currentState.tags + tag)
+ } else {
+ return
}
- listener.onFilterChanged(currentState)
}
+ val index = tags.indexOf(tag)
+ if (index in tags.indices) {
+ notifyItemChanged(sortOrders.size + index)
+ }
+ listener.onFilterChanged(currentState)
}
fun setCheckedSort(sort: SortOrder) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt
index a182131b6..9275ae831 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt
@@ -12,7 +12,7 @@ class FilterSortHolder(parent: ViewGroup) :
) {
override fun onBind(data: SortOrder, extra: Boolean) {
- binding.radio.setText(data.titleRes)
- binding.radio.isChecked = extra
+ binding.root.setText(data.titleRes)
+ binding.root.isChecked = extra
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt
index f3bf89635..2054d4cb9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt
@@ -2,18 +2,20 @@ package org.koitharu.kotatsu.list.ui.filter
import android.view.LayoutInflater
import android.view.ViewGroup
-import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaTag
-import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
+import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
class FilterTagHolder(parent: ViewGroup) :
- BaseViewHolder(
- ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ BaseViewHolder(
+ ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) {
- override fun onBind(data: MangaTag?, extra: Boolean) {
- binding.radio.text = data?.title ?: context.getString(R.string.all)
- binding.radio.isChecked = extra
+ val isChecked: Boolean
+ get() = binding.root.isChecked
+
+ override fun onBind(data: MangaTag, extra: Boolean) {
+ binding.root.text = data.title
+ binding.root.isChecked = extra
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt
new file mode 100644
index 000000000..32cebb25c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt
@@ -0,0 +1,7 @@
+package org.koitharu.kotatsu.list.ui.model
+
+import org.koitharu.kotatsu.base.ui.widgets.ChipsView
+
+data class CurrentFilterModel(
+ val chips: Collection,
+) : ListModel
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt
index e23c5b59b..0613f2e24 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt
@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.list.ui.model
+import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class EmptyState(
- @StringRes val text: Int
+ @DrawableRes val icon: Int,
+ @StringRes val textPrimary: Int,
+ @StringRes val textSecondary: Int
) : ListModel
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt
new file mode 100644
index 000000000..209c7227f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt
@@ -0,0 +1,8 @@
+package org.koitharu.kotatsu.list.ui.model
+
+import androidx.annotation.StringRes
+
+data class ListHeader(
+ val text: CharSequence?,
+ @StringRes val textRes: Int,
+) : ListModel
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
index 9f3b2f561..b7a367591 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
@@ -6,7 +6,6 @@ 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.prefs.ListMode
-import kotlin.math.roundToInt
fun Manga.toListModel() = MangaListModel(
id = id,
@@ -20,7 +19,7 @@ fun Manga.toListDetailedModel() = MangaListDetailedModel(
id = id,
title = title,
subtitle = altTitle,
- rating = if (rating == Manga.NO_RATING) null else "${(rating * 10).roundToInt()}/10",
+ rating = if (rating == Manga.NO_RATING) null else String.format("%.1f", rating * 5),
tags = tags.joinToString(", ") { it.title },
coverUrl = coverUrl,
manga = this
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt
index b2a756cc1..dbe2d43e1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt
@@ -15,5 +15,5 @@ val localModule
single { LocalMangaRepository(androidContext()) }
factory(named(MangaSource.LOCAL)) { get() }
- viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) }
+ viewModel { LocalListViewModel(get(), get(), get(), get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
index f74c023e1..cd4f5ea83 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
@@ -20,19 +20,22 @@ class CbzFetcher : Fetcher {
pool: BitmapPool,
data: Uri,
size: Size,
- options: Options
+ options: Options,
): FetchResult {
val zip = ZipFile(data.schemeSpecificPart)
val entry = zip.getEntry(data.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
return SourceResult(
- source = zip.getInputStream(entry).source().buffer(),
+ source = ExtraCloseableBufferedSource(
+ zip.getInputStream(entry).source().buffer(),
+ zip,
+ ),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
dataSource = DataSource.DISK
)
}
- override fun key(data: Uri): String? = data.toString()
+ override fun key(data: Uri) = data.toString()
override fun handles(data: Uri) = data.scheme == "cbz"
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
index 98f4e73fc..106cbaacd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
@@ -7,7 +7,7 @@ import java.util.*
class CbzFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
- val ext = name.substringAfterLast('.', "").toLowerCase(Locale.ROOT)
+ val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt
new file mode 100644
index 000000000..69ba2565a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt
@@ -0,0 +1,18 @@
+package org.koitharu.kotatsu.local.data
+
+import okhttp3.internal.closeQuietly
+import okio.BufferedSource
+import okio.Closeable
+
+class ExtraCloseableBufferedSource(
+ private val delegate: BufferedSource,
+ vararg closeable: Closeable,
+) : BufferedSource by delegate {
+
+ private val extraCloseable = closeable
+
+ override fun close() {
+ delegate.close()
+ extraCloseable.forEach { x -> x.closeQuietly() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
index 66d1b2f4d..82e70a650 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
@@ -4,7 +4,7 @@ import android.content.Context
import com.tomclaw.cache.DiskLruCache
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.longHashCode
-import org.koitharu.kotatsu.utils.ext.sub
+import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
import java.io.InputStream
@@ -13,8 +13,10 @@ import java.io.OutputStream
class PagesCache(context: Context) {
private val cacheDir = context.externalCacheDir ?: context.cacheDir
- private val lruCache =
- DiskLruCache.create(cacheDir.sub(Cache.PAGES.dir), FileSizeUtils.mbToBytes(200))
+ private val lruCache = DiskLruCache.create(
+ cacheDir.subdir(Cache.PAGES.dir),
+ FileSizeUtils.mbToBytes(200)
+ )
operator fun get(url: String): File? {
return lruCache.get(url)?.takeIfReadable()
@@ -22,7 +24,7 @@ class PagesCache(context: Context) {
@Deprecated("Useless lambda")
fun put(url: String, writer: (OutputStream) -> Unit): File {
- val file = cacheDir.sub(url.longHashCode().toString())
+ val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use(writer)
val res = lruCache.put(url, file)
file.delete()
@@ -30,7 +32,7 @@ class PagesCache(context: Context) {
}
fun put(url: String, inputStream: InputStream): File {
- val file = cacheDir.sub(url.longHashCode().toString())
+ val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use { out ->
inputStream.copyTo(out)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
index 9f84ab52a..3d4c51571 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
@@ -15,9 +15,7 @@ import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.utils.AlphanumComparator
-import org.koitharu.kotatsu.utils.ext.longHashCode
-import org.koitharu.kotatsu.utils.ext.readText
-import org.koitharu.kotatsu.utils.ext.sub
+import org.koitharu.kotatsu.utils.ext.*
import java.io.File
import java.util.*
import java.util.zip.ZipEntry
@@ -27,17 +25,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
private val filenameFilter = CbzFilter()
- override suspend fun getList(
+ override suspend fun getList2(
offset: Int,
query: String?,
- sortOrder: SortOrder?,
- tag: MangaTag?
+ tags: Set?,
+ sortOrder: SortOrder?
): List {
require(offset == 0) {
"LocalMangaRepository does not support pagination"
}
- val files = getAvailableStorageDirs(context)
- .flatMap { x -> x.listFiles(filenameFilter)?.toList().orEmpty() }
+ val files = getAllFiles()
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
}
@@ -78,9 +75,9 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
}
}
- fun delete(manga: Manga): Boolean {
+ suspend fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile()
- return file.delete()
+ return file.deleteAwait()
}
@SuppressLint("DefaultLocale")
@@ -98,11 +95,14 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
entryName = index.getCoverEntry()
?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()
),
- chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
+ chapters = info.chapters?.map { c ->
+ c.copy(url = fileUri,
+ source = MangaSource.LOCAL)
+ }
)
}
// fallback
- val title = file.nameWithoutExtension.replace("_", " ").capitalize()
+ val title = file.nameWithoutExtension.replace("_", " ").toCamelCase()
val chapters = ArraySet()
for (x in zip.entries()) {
if (!x.isDirectory) {
@@ -120,7 +120,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
- name = if (s.isEmpty()) title else s,
+ name = s.ifEmpty { title },
number = i + 1,
source = MangaSource.LOCAL,
url = uriBuilder.fragment(s).build().toString()
@@ -134,13 +134,36 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null
return withContext(Dispatchers.IO) {
- val zip = ZipFile(file)
- val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
- val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
- index.getMangaInfo()
+ @Suppress("BlockingMethodInNonBlockingContext")
+ ZipFile(file).use { zip ->
+ val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
+ val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
+ index.getMangaInfo()
+ }
}
}
+ suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) {
+ val files = getAllFiles()
+ for (file in files) {
+ @Suppress("BlockingMethodInNonBlockingContext")
+ val index = ZipFile(file).use { zip ->
+ val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
+ entry?.let(zip::readText)?.let(::MangaIndex)
+ } ?: continue
+ val info = index.getMangaInfo() ?: continue
+ if (info.id == remoteManga.id) {
+ val fileUri = file.toUri().toString()
+ return@withContext info.copy(
+ source = MangaSource.LOCAL,
+ url = fileUri,
+ chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
+ )
+ }
+ }
+ null
+ }
+
private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString()
@@ -165,20 +188,26 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
override suspend fun getTags() = emptySet()
+ private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
+ dir.listFiles(filenameFilter)?.toList().orEmpty()
+ }
+
companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean {
- val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
+ val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
fun getAvailableStorageDirs(context: Context): List {
- val result = ArrayList(5)
- result += context.filesDir.sub(DIR_NAME)
+ val result = ArrayList(5)
+ result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
- return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
+ return result.filterNotNull()
+ .distinctBy { it.canonicalPath }
+ .filter { it.exists() || it.mkdir() }
}
fun getFallbackStorageDir(context: Context): File? {
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
index ba700ecff..319cf5619 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.local.ui
-import android.content.ActivityNotFoundException
+import android.content.*
import android.net.Uri
import android.os.Bundle
import android.view.Menu
@@ -15,6 +15,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize
@@ -25,12 +26,32 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback {
ActivityResultContracts.OpenDocument(),
this
)
+ private val downloadReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
+ viewModel.onRefresh()
+ }
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ context.registerReceiver(
+ downloadReceiver,
+ IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)
+ )
+ }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
}
+ override fun onDetach() {
+ requireContext().unregisterReceiver(downloadReceiver)
+ super.onDetach()
+ }
+
override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -65,7 +86,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback {
override fun onActivityResult(result: Uri?) {
if (result != null) {
- viewModel.importFile(result)
+ viewModel.importFile(context?.applicationContext ?: return, result)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
index 715b287b6..5c12ad115 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
@@ -14,15 +14,12 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
-import org.koitharu.kotatsu.list.ui.model.EmptyState
-import org.koitharu.kotatsu.list.ui.model.LoadingState
-import org.koitharu.kotatsu.list.ui.model.toErrorState
-import org.koitharu.kotatsu.list.ui.model.toUi
+import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
-import org.koitharu.kotatsu.utils.ext.sub
+import java.io.File
import java.io.IOException
class LocalListViewModel(
@@ -30,12 +27,12 @@ class LocalListViewModel(
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository,
- private val context: Context
) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent()
private val listError = MutableStateFlow(null)
private val mangaList = MutableStateFlow?>(null)
+ private val headerModel = ListHeader(null, R.string.local_storage)
override val content = combine(
mangaList,
@@ -45,8 +42,11 @@ class LocalListViewModel(
when {
error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
- list.isEmpty() -> listOf(EmptyState(R.string.text_local_holder))
- else -> list.toUi(mode)
+ list.isEmpty() -> listOf(EmptyState(R.drawable.ic_storage, R.string.text_local_holder_primary, R.string.text_local_holder_secondary))
+ else -> ArrayList(list.size + 1).apply {
+ add(headerModel)
+ list.toUi(this, mode)
+ }
}
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
@@ -61,7 +61,7 @@ class LocalListViewModel(
launchLoadingJob(Dispatchers.Default) {
try {
listError.value = null
- mangaList.value = repository.getList(0)
+ mangaList.value = repository.getList2(0)
} catch (e: Throwable) {
listError.value = e
}
@@ -70,7 +70,7 @@ class LocalListViewModel(
override fun onRetry() = onRefresh()
- fun importFile(uri: Uri) {
+ fun importFile(context: Context, uri: Uri) {
launchLoadingJob {
val contentResolver = context.contentResolver
withContext(Dispatchers.IO) {
@@ -79,8 +79,9 @@ class LocalListViewModel(
if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
- val dest = settings.getStorageDir(context)?.sub(name)
+ val dest = settings.getStorageDir(context)?.let { File(it, name) }
?: throw IOException("External files dir unavailable")
+ @Suppress("BlockingMethodInNonBlockingContext")
contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output ->
source.copyTo(output)
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt
new file mode 100644
index 000000000..d5a2de5bc
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt
@@ -0,0 +1,8 @@
+package org.koitharu.kotatsu.main.ui
+
+import com.google.android.material.appbar.AppBarLayout
+
+interface AppBarOwner {
+
+ val appBar: AppBarLayout
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
index 6abc5fae4..d333fddb7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -6,79 +6,130 @@ import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
-import android.view.Menu
import android.view.MenuItem
import android.view.View
-import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
import androidx.appcompat.app.ActionBarDrawerToggle
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.*
+import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentTransaction
+import androidx.fragment.app.commit
+import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
+import androidx.transition.TransitionManager
+import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
+import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
+import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ActivityMainBinding
+import org.koitharu.kotatsu.databinding.NavigationHeaderBinding
+import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
-import org.koitharu.kotatsu.search.ui.SearchHelper
+import org.koitharu.kotatsu.search.ui.SearchActivity
+import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
+import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
+import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
+import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
+import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.resolveDp
-import java.io.Closeable
+import org.koitharu.kotatsu.utils.ext.*
class MainActivity : BaseActivity(),
- NavigationView.OnNavigationItemSelectedListener,
- View.OnClickListener {
+ NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
+ View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener {
private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE)
+ private val searchSuggestionViewModel by viewModel(
+ mode = LazyThreadSafetyMode.NONE
+ )
+ private lateinit var navHeaderBinding: NavigationHeaderBinding
private lateinit var drawerToggle: ActionBarDrawerToggle
- private var closeable: Closeable? = null
+ private var searchViewElevation = 0f
+
+ override val appBar: AppBarLayout
+ get() = binding.appbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityMainBinding.inflate(layoutInflater))
- drawerToggle =
- ActionBarDrawerToggle(
- this,
- binding.drawer,
- binding.toolbar,
- R.string.open_menu,
- R.string.close_menu
- )
+ searchViewElevation = binding.toolbarCard.cardElevation
+ navHeaderBinding = NavigationHeaderBinding.inflate(layoutInflater)
+ drawerToggle = ActionBarDrawerToggle(
+ this,
+ binding.drawer,
+ binding.toolbar,
+ R.string.open_menu,
+ R.string.close_menu
+ )
+ drawerToggle.setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_back))
+ drawerToggle.setToolbarNavigationClickListener {
+ binding.searchView.hideKeyboard()
+ onBackPressed()
+ }
binding.drawer.addDrawerListener(drawerToggle)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- binding.navigationView.setNavigationItemSelectedListener(this)
+ if (get().isAmoledTheme && get().theme == AppCompatDelegate.MODE_NIGHT_YES) {
+ binding.appbar.setBackgroundColor(Color.BLACK)
+ binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background))
+ } else {
+ binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface))
+ }
+
+ with(binding.searchView) {
+ onFocusChangeListener = this@MainActivity
+ searchSuggestionListener = this@MainActivity
+ }
+
+ with(binding.navigationView) {
+ val menuView =
+ findViewById(com.google.android.material.R.id.design_navigation_view)
+ ViewCompat.setOnApplyWindowInsetsListener(navHeaderBinding.root) { v, insets ->
+ val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.updatePadding(top = systemWindowInsets.top)
+ // NavigationView doesn't dispatch insets to the menu view, so pad the bottom here.
+ menuView.updatePadding(bottom = systemWindowInsets.bottom)
+ insets
+ }
+ addHeaderView(navHeaderBinding.root)
+ itemBackground = navigationItemBackground(context)
+ setNavigationItemSelectedListener(this@MainActivity)
+ }
with(binding.fab) {
imageTintList = ColorStateList.valueOf(Color.WHITE)
setOnClickListener(this@MainActivity)
}
- supportFragmentManager.findFragmentById(R.id.container)?.let {
+ supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
binding.fab.isVisible = it is HistoryListFragment
} ?: run {
openDefaultSection()
}
if (savedInstanceState == null) {
- TrackWorker.setup(applicationContext)
- SuggestionsWorker.setup(applicationContext)
- AppUpdateChecker(this).launchIfNeeded()
+ onFirstStart()
}
viewModel.onOpenReader.observe(this, this::onOpenReader)
@@ -87,9 +138,10 @@ class MainActivity : BaseActivity(),
viewModel.remoteSources.observe(this, this::updateSideMenu)
}
- override fun onDestroy() {
- closeable?.close()
- super.onDestroy()
+ override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ super.onRestoreInstanceState(savedInstanceState)
+ drawerToggle.isDrawerIndicatorEnabled =
+ binding.drawer.getDrawerLockMode(GravityCompat.START) == DrawerLayout.LOCK_MODE_UNLOCKED
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -103,21 +155,20 @@ class MainActivity : BaseActivity(),
}
override fun onBackPressed() {
- if (binding.drawer.isDrawerOpen(binding.navigationView)) {
- binding.drawer.closeDrawer(binding.navigationView)
- } else {
- super.onBackPressed()
+ val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
+ binding.searchView.clearFocus()
+ when {
+ binding.drawer.isDrawerOpen(binding.navigationView) -> binding.drawer.closeDrawer(
+ binding.navigationView)
+ fragment != null -> supportFragmentManager.commit {
+ remove(fragment)
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ runOnCommit { onSearchClosed() }
+ }
+ else -> super.onBackPressed()
}
}
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.opt_main, menu)
- menu.findItem(R.id.action_search)?.let { menuItem ->
- closeable = SearchHelper.setupSearchView(menuItem)
- }
- return super.onCreateOptionsMenu(menu)
- }
-
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return drawerToggle.onOptionsItemSelected(item) || when (item.itemId) {
else -> super.onOptionsItemSelected(item)
@@ -134,48 +185,99 @@ class MainActivity : BaseActivity(),
if (item.groupId == R.id.group_remote_sources) {
val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source))
- } else when (item.itemId) {
- R.id.nav_history -> {
- viewModel.defaultSection = AppSection.HISTORY
- setPrimaryFragment(HistoryListFragment.newInstance())
+ searchSuggestionViewModel.onSourceChanged(source)
+ } else {
+ searchSuggestionViewModel.onSourceChanged(null)
+ when (item.itemId) {
+ R.id.nav_history -> {
+ viewModel.defaultSection = AppSection.HISTORY
+ setPrimaryFragment(HistoryListFragment.newInstance())
+ }
+ R.id.nav_favourites -> {
+ viewModel.defaultSection = AppSection.FAVOURITES
+ setPrimaryFragment(FavouritesContainerFragment.newInstance())
+ }
+ R.id.nav_local_storage -> {
+ viewModel.defaultSection = AppSection.LOCAL
+ setPrimaryFragment(LocalListFragment.newInstance())
+ }
+ R.id.nav_suggestions -> {
+ viewModel.defaultSection = AppSection.SUGGESTIONS
+ setPrimaryFragment(SuggestionsFragment.newInstance())
+ }
+ R.id.nav_feed -> {
+ viewModel.defaultSection = AppSection.FEED
+ setPrimaryFragment(FeedFragment.newInstance())
+ }
+ R.id.nav_action_settings -> {
+ startActivity(SettingsActivity.newIntent(this))
+ return true
+ }
+ else -> return false
}
- R.id.nav_favourites -> {
- viewModel.defaultSection = AppSection.FAVOURITES
- setPrimaryFragment(FavouritesContainerFragment.newInstance())
- }
- R.id.nav_local_storage -> {
- viewModel.defaultSection = AppSection.LOCAL
- setPrimaryFragment(LocalListFragment.newInstance())
- }
- R.id.nav_suggestions -> {
- viewModel.defaultSection = AppSection.SUGGESTIONS
- setPrimaryFragment(SuggestionsFragment.newInstance())
- }
- R.id.nav_feed -> {
- viewModel.defaultSection = AppSection.FEED
- setPrimaryFragment(FeedFragment.newInstance())
- }
- R.id.nav_action_settings -> {
- startActivity(SettingsActivity.newIntent(this))
- return true
- }
- else -> return false
}
binding.drawer.closeDrawers()
return true
}
override fun onWindowInsetsChanged(insets: Insets) {
- binding.toolbar.updatePadding(
- top = insets.top,
- left = insets.left,
- right = insets.right
- )
- binding.fab.updateLayoutParams {
+ binding.toolbarCard.updateLayoutParams {
+ topMargin = insets.top + resources.resolveDp(8)
+ }
+ binding.fab.updateLayoutParams {
bottomMargin = insets.bottom + topMargin
leftMargin = insets.left + topMargin
rightMargin = insets.right + topMargin
}
+ binding.container.updateLayoutParams {
+ topMargin = -(binding.appbar.measureHeight())
+ }
+ }
+
+ override fun onFocusChange(v: View?, hasFocus: Boolean) {
+ val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
+ if (v?.id == R.id.searchView && hasFocus) {
+ if (fragment == null) {
+ supportFragmentManager.commit {
+ add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
+ setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ runOnCommit { onSearchOpened() }
+ }
+ }
+ }
+ }
+
+ override fun onMangaClick(manga: Manga) {
+ startActivity(DetailsActivity.newIntent(this, manga))
+ }
+
+ override fun onQueryClick(query: String, submit: Boolean) {
+ binding.searchView.query = query
+ if (submit) {
+ if (query.isNotEmpty()) {
+ val source = searchSuggestionViewModel.getLocalSearchSource()
+ if (source != null) {
+ startActivity(SearchActivity.newIntent(this, source, query))
+ } else {
+ startActivity(GlobalSearchActivity.newIntent(this, query))
+ }
+ searchSuggestionViewModel.saveQuery(query)
+ }
+ }
+ }
+
+ override fun onQueryChanged(query: String) {
+ searchSuggestionViewModel.onQueryChanged(query)
+ }
+
+ override fun onClearSearchHistory() {
+ AlertDialog.Builder(this)
+ .setTitle(R.string.clear_search_history)
+ .setMessage(R.string.text_clear_search_history_prompt)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.clear) { _, _ ->
+ searchSuggestionViewModel.clearSearchHistory()
+ }.show()
}
private fun onOpenReader(manga: Manga) {
@@ -245,8 +347,62 @@ class MainActivity : BaseActivity(),
private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
- .replace(R.id.container, fragment)
+ .replace(R.id.container, fragment, TAG_PRIMARY)
.commit()
binding.fab.isVisible = fragment is HistoryListFragment
}
+
+ private fun onSearchOpened() {
+ binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
+ drawerToggle.isDrawerIndicatorEnabled = false
+ TransitionManager.beginDelayedTransition(binding.appbar)
+ // Avoiding shadows on the sides if the color is transparent, so we make the AppBarLayout white/grey/black
+ if (isDarkAmoledTheme()) {
+ binding.toolbar.setBackgroundColor(Color.BLACK)
+ } else {
+ binding.appbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface))
+ }
+ binding.toolbarCard.apply {
+ cardElevation = 0f
+ // Remove margin
+ updateLayoutParams {
+ leftMargin = 0
+ rightMargin = 0
+ }
+
+ }
+ binding.appbar.elevation = searchViewElevation
+ }
+
+ private fun onSearchClosed() {
+ binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
+ drawerToggle.isDrawerIndicatorEnabled = true
+ if (isDarkAmoledTheme()) {
+ binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background))
+ }
+ TransitionManager.beginDelayedTransition(binding.appbar)
+ // Returning transparent color
+ binding.appbar.setBackgroundColor(Color.TRANSPARENT)
+ binding.appbar.elevation = 0f
+ binding.toolbarCard.apply {
+ cardElevation = searchViewElevation
+ updateLayoutParams {
+ leftMargin = resources.resolveDp(16)
+ rightMargin = resources.resolveDp(16)
+ }
+ }
+ }
+
+ private fun onFirstStart() {
+ TrackWorker.setup(applicationContext)
+ SuggestionsWorker.setup(applicationContext)
+ AppUpdateChecker(this@MainActivity).launchIfNeeded()
+ OnboardDialogFragment.showWelcome(get(), supportFragmentManager)
+ }
+
+ private companion object {
+
+ const val TAG_PRIMARY = "primary"
+ const val TAG_SEARCH = "search"
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
index d031b9aec..74ea5b618 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
@@ -3,9 +3,7 @@ package org.koitharu.kotatsu.reader
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository
-import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.local.data.PagesCache
-import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule
@@ -14,7 +12,7 @@ val readerModule
single { MangaDataRepository(get()) }
single { PagesCache(get()) }
- viewModel { (intent: MangaIntent, state: ReaderState?) ->
- ReaderViewModel(intent, state, get(), get(), get(), get())
+ viewModel { params ->
+ ReaderViewModel(params[0], params[1], get(), get(), get(), get())
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt
index 667d7113a..78d713fff 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt
@@ -15,16 +15,17 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.databinding.DialogChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.ChapterExtra
import org.koitharu.kotatsu.utils.ext.withArgs
class ChaptersDialog : AlertDialogFragment(),
- OnListItemClickListener {
+ OnListItemClickListener {
override fun onInflateView(
inflater: LayoutInflater,
- container: ViewGroup?
+ container: ViewGroup?,
) = DialogChaptersBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: AlertDialog.Builder) {
@@ -51,7 +52,8 @@ class ChaptersDialog : AlertDialogFragment(),
index < currentPosition -> ChapterExtra.READ
index == currentPosition -> ChapterExtra.CURRENT
else -> ChapterExtra.UNREAD
- }
+ },
+ isMissing = false
)
}) {
if (currentPosition >= 0) {
@@ -66,11 +68,11 @@ class ChaptersDialog : AlertDialogFragment(),
}
}
- override fun onItemClick(item: MangaChapter, view: View) {
+ override fun onItemClick(item: ChapterListItem, view: View) {
((parentFragment as? OnChapterChangeListener)
?: (activity as? OnChapterChangeListener))?.let {
dismiss()
- it.onChapterChanged(item)
+ it.onChapterChanged(item.chapter)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
index 5b53b41ff..b8b7bcb19 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
@@ -38,7 +38,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
-import org.koitharu.kotatsu.reader.ui.pager.wetoon.WebtoonReaderFragment
+import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.utils.GridTouchHelper
@@ -124,7 +124,7 @@ class ReaderActivity : BaseFullscreenActivity