Merge branch 'devel' into feature/suggestions
commit
22eebe89e7
@ -1,51 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DesignSurface">
|
|
||||||
<option name="filePathToZoomLevelMap">
|
|
||||||
<map>
|
|
||||||
<entry key="../../../../../../layout/custom_preview.xml" value="0.1" />
|
|
||||||
<entry key="../../../../../../opt/usr/android-sdk/platforms/android-30/data/res/drawable/list_divider_material.xml" value="0.28512820512820514" />
|
|
||||||
<entry key="../../../../../../opt/usr/android-sdk/platforms/android-30/data/res/layout/simple_dropdown_item_1line.xml" value="0.24739583333333334" />
|
|
||||||
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_alert_dialog_material.xml" value="0.25885416666666666" />
|
|
||||||
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_select_dialog_material.xml" value="0.25885416666666666" />
|
|
||||||
<entry key="../../../../.gradle/caches/transforms-3/688e95ad986d2d0286c79f787589b7cb/transformed/material-1.3.0/res/layout/mtrl_alert_dialog.xml" value="0.25885416666666666" />
|
|
||||||
<entry key="app/src/main/res/drawable/ic_storage.xml" value="0.275" />
|
|
||||||
<entry key="app/src/main/res/drawable/ic_suggestion.xml" value="0.275" />
|
|
||||||
<entry key="app/src/main/res/drawable/tab_indicator.xml" value="0.28512820512820514" />
|
|
||||||
<entry key="app/src/main/res/drawable/tabs_background.xml" value="0.28512820512820514" />
|
|
||||||
<entry key="app/src/main/res/layout-w600dp/activity_details.xml" value="0.18072916666666666" />
|
|
||||||
<entry key="app/src/main/res/layout-w600dp/fragment_details.xml" value="0.14583333333333334" />
|
|
||||||
<entry key="app/src/main/res/layout-w600dp/fragment_list.xml" value="0.14635416666666667" />
|
|
||||||
<entry key="app/src/main/res/layout/activity_protect.xml" value="0.26927083333333335" />
|
|
||||||
<entry key="app/src/main/res/layout/activity_setup_protect.xml" value="0.26927083333333335" />
|
|
||||||
<entry key="app/src/main/res/layout/dialog_favorite_categories.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/dialog_list_mode.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/fragment_chapters.xml" value="0.24739583333333334" />
|
|
||||||
<entry key="app/src/main/res/layout/fragment_details.xml" value="0.26145833333333335" />
|
|
||||||
<entry key="app/src/main/res/layout/fragment_favourites.xml" value="0.26296296296296295" />
|
|
||||||
<entry key="app/src/main/res/layout/fragment_feed.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/fragment_list.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/item_branch.xml" value="0.24739583333333334" />
|
|
||||||
<entry key="app/src/main/res/layout/item_branch_dropdown.xml" value="0.25743589743589745" />
|
|
||||||
<entry key="app/src/main/res/layout/item_category_checkable.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/item_manga_grid.xml" value="0.26042632066728455" />
|
|
||||||
<entry key="app/src/main/res/layout/item_manga_list_details.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/item_page_thumb.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/item_page_webtoon.xml" value="0.13095238095238096" />
|
|
||||||
<entry key="app/src/main/res/layout/item_recent.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/layout/item_source_config.xml" value="0.25885416666666666" />
|
|
||||||
<entry key="app/src/main/res/layout/sheet_pages.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/menu/nav_drawer.xml" value="0.25885416666666666" />
|
|
||||||
<entry key="app/src/main/res/menu/opt_protect.xml" value="0.26927083333333335" />
|
|
||||||
<entry key="app/src/main/res/menu/popup_category.xml" value="0.2601851851851852" />
|
|
||||||
<entry key="app/src/main/res/xml/pref_main.xml" value="0.26927083333333335" />
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectType">
|
|
||||||
<option name="id" value="Android" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
|
import androidx.room.testing.MigrationTestHelper
|
||||||
|
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.*
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MangaDatabaseTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val helper: MigrationTestHelper = MigrationTestHelper(
|
||||||
|
InstrumentationRegistry.getInstrumentation(),
|
||||||
|
MangaDatabase::class.java.canonicalName,
|
||||||
|
FrameworkSQLiteOpenHelperFactory()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun migrateAll() {
|
||||||
|
helper.createDatabase(TEST_DB, 1).apply {
|
||||||
|
// TODO execSQL("")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
for (migration in migrations) {
|
||||||
|
helper.runMigrationsAndValidate(
|
||||||
|
TEST_DB,
|
||||||
|
migration.endVersion,
|
||||||
|
true,
|
||||||
|
migration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val TEST_DB = "test-db"
|
||||||
|
|
||||||
|
val migrations = arrayOf(
|
||||||
|
Migration1To2(),
|
||||||
|
Migration2To3(),
|
||||||
|
Migration3To4(),
|
||||||
|
Migration4To5(),
|
||||||
|
Migration5To6(),
|
||||||
|
Migration6To7(),
|
||||||
|
Migration7To8(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import com.google.android.material.R
|
||||||
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
|
||||||
|
class AnimatedToolbar @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = R.attr.toolbarStyle,
|
||||||
|
) : MaterialToolbar(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private var navButtonView: View? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) {
|
||||||
|
runCatching {
|
||||||
|
field = navButtonViewField?.get(this) as? View
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setNavigationIcon(icon: Drawable?) {
|
||||||
|
super.setNavigationIcon(icon)
|
||||||
|
navButtonView?.isGone = (icon == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
val navButtonViewField: Field? = runCatching {
|
||||||
|
Toolbar::class.java.getDeclaredField("mNavButtonView")
|
||||||
|
.also { it.isAccessible = true }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
|
|
||||||
|
class Migration8To9 : Migration(8, 9) {
|
||||||
|
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
class CurlLoggingInterceptor(
|
||||||
|
private val extraCurlOptions: String? = null,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request: Request = chain.request()
|
||||||
|
var compressed = false
|
||||||
|
val curlCmd = StringBuilder("curl")
|
||||||
|
if (extraCurlOptions != null) {
|
||||||
|
curlCmd.append(" ").append(extraCurlOptions)
|
||||||
|
}
|
||||||
|
curlCmd.append(" -X ").append(request.method)
|
||||||
|
val headers = request.headers
|
||||||
|
var i = 0
|
||||||
|
val count = headers.size
|
||||||
|
while (i < count) {
|
||||||
|
val name = headers.name(i)
|
||||||
|
val value = headers.value(i)
|
||||||
|
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
|
||||||
|
ignoreCase = true)
|
||||||
|
) {
|
||||||
|
compressed = true
|
||||||
|
}
|
||||||
|
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
val requestBody = request.body
|
||||||
|
if (requestBody != null) {
|
||||||
|
val buffer = Buffer()
|
||||||
|
requestBody.writeTo(buffer)
|
||||||
|
val contentType = requestBody.contentType()
|
||||||
|
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||||
|
curlCmd.append(" --data $'")
|
||||||
|
.append(buffer.readString(charset).replace("\n", "\\n"))
|
||||||
|
.append("'")
|
||||||
|
}
|
||||||
|
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
|
||||||
|
Log.d(TAG, "╭--- cURL (" + request.url + ")")
|
||||||
|
Log.d(TAG, curlCmd.toString())
|
||||||
|
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val TAG = "CURL"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
interface MangaRepositoryAuthProvider {
|
||||||
|
|
||||||
|
val authUrl: String
|
||||||
|
|
||||||
|
fun isAuthorized(): Boolean
|
||||||
|
}
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.core.model.*
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||||
|
|
||||||
|
override val source = MangaSource.ANIBEL
|
||||||
|
|
||||||
|
override val defaultDomain = "anibel.net"
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.NEWEST
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList2(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder?
|
||||||
|
): List<Manga> {
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
return if (offset == 0) search(query) else emptyList()
|
||||||
|
}
|
||||||
|
val page = (offset / 12f).toIntUp().inc()
|
||||||
|
val link = when {
|
||||||
|
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
|
||||||
|
else -> tags.joinToString(
|
||||||
|
prefix = "/manga?",
|
||||||
|
postfix = "&page=$page",
|
||||||
|
separator = "&",
|
||||||
|
) { tag -> "genre[]=${tag.key}" }.withDomain()
|
||||||
|
}
|
||||||
|
val doc = loaderContext.httpGet(link).parseHtml()
|
||||||
|
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
|
||||||
|
val items = root.select("div.anime-card")
|
||||||
|
return items.mapNotNull { card ->
|
||||||
|
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
|
||||||
|
val status = card.select("tr")[2].text()
|
||||||
|
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||||
|
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||||
|
val titleParts = fullTitle.splitTwoParts('/')
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = titleParts?.first?.trim() ?: fullTitle,
|
||||||
|
coverUrl = card.selectFirst("img")?.attr("data-src")
|
||||||
|
?.withDomain().orEmpty(),
|
||||||
|
altTitle = titleParts?.second?.trim(),
|
||||||
|
author = null,
|
||||||
|
rating = Manga.NO_RATING,
|
||||||
|
url = href,
|
||||||
|
publicUrl = href.withDomain(),
|
||||||
|
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||||
|
MangaTag(
|
||||||
|
title = x.text(),
|
||||||
|
key = x.attr("href").ifEmpty {
|
||||||
|
return@mapNotNull null
|
||||||
|
}.substringAfterLast("="),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
},
|
||||||
|
state = when (status) {
|
||||||
|
"выпускаецца" -> MangaState.ONGOING
|
||||||
|
"завершанае" -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
||||||
|
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
|
||||||
|
return manga.copy(
|
||||||
|
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
|
||||||
|
chapters = root.select("ul.series").flatMap { table ->
|
||||||
|
table.select("li")
|
||||||
|
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
|
||||||
|
val href = a?.select("a")?.first()?.attr("href")
|
||||||
|
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = a.selectFirst("a")?.text().orEmpty(),
|
||||||
|
number = i + 1,
|
||||||
|
url = href,
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val fullUrl = chapter.url.withDomain()
|
||||||
|
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||||
|
val scripts = doc.select("script")
|
||||||
|
for (script in scripts) {
|
||||||
|
val data = script.html()
|
||||||
|
val pos = data.indexOf("dataSource")
|
||||||
|
if (pos == -1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val json = data.substring(pos).substringAfter('[').substringBefore(']')
|
||||||
|
val domain = getDomain()
|
||||||
|
return json.split(",").mapNotNull {
|
||||||
|
it.trim()
|
||||||
|
.removeSurrounding('"', '\'')
|
||||||
|
.toRelativeUrl(domain)
|
||||||
|
.takeUnless(String::isBlank)
|
||||||
|
}.map { url ->
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
referer = fullUrl,
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
|
||||||
|
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
|
||||||
|
return root.select("p.menu-tags.tupe").mapToSet { p ->
|
||||||
|
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
||||||
|
MangaTag(
|
||||||
|
title = a.text().toCamelCase(),
|
||||||
|
key = a.attr("data-name"),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun search(query: String): List<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml()
|
||||||
|
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root")
|
||||||
|
val items = root.select("div.anime-card")
|
||||||
|
return items.mapNotNull { card ->
|
||||||
|
val href = card.select("a").attr("href")
|
||||||
|
val status = card.select("tr")[2].text()
|
||||||
|
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||||
|
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||||
|
val titleParts = fullTitle.splitTwoParts('/')
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = titleParts?.first?.trim() ?: fullTitle,
|
||||||
|
coverUrl = card.selectFirst("img")?.attr("src")
|
||||||
|
?.withDomain().orEmpty(),
|
||||||
|
altTitle = titleParts?.second?.trim(),
|
||||||
|
author = null,
|
||||||
|
rating = Manga.NO_RATING,
|
||||||
|
url = href,
|
||||||
|
publicUrl = href.withDomain(),
|
||||||
|
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||||
|
MangaTag(
|
||||||
|
title = x.text(),
|
||||||
|
key = x.attr("href").ifEmpty {
|
||||||
|
return@mapNotNull null
|
||||||
|
}.substringAfterLast("="),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
},
|
||||||
|
state = when (status) {
|
||||||
|
"выпускаецца" -> MangaState.ONGOING
|
||||||
|
"завершанае" -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,258 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.core.model.*
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
private const val DOMAIN_UNAUTHORIZED = "e-hentai.org"
|
||||||
|
private const val DOMAIN_AUTHORIZED = "exhentai.org"
|
||||||
|
|
||||||
|
class ExHentaiRepository(
|
||||||
|
loaderContext: MangaLoaderContext,
|
||||||
|
) : RemoteMangaRepository(loaderContext), MangaRepositoryAuthProvider {
|
||||||
|
|
||||||
|
override val source = MangaSource.EXHENTAI
|
||||||
|
|
||||||
|
override val defaultDomain: String
|
||||||
|
get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED
|
||||||
|
|
||||||
|
override val authUrl: String
|
||||||
|
get() = "https://${getDomain()}/bounce_login.php"
|
||||||
|
|
||||||
|
private val ratingPattern = Regex("-?[0-9]+px")
|
||||||
|
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
|
||||||
|
private var updateDm = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
|
||||||
|
loaderContext.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getList2(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder?,
|
||||||
|
): List<Manga> {
|
||||||
|
val page = (offset / 25f).toIntUp()
|
||||||
|
var search = query?.urlEncoded().orEmpty()
|
||||||
|
val url = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(getDomain())
|
||||||
|
append("/?page=")
|
||||||
|
append(page)
|
||||||
|
if (!tags.isNullOrEmpty()) {
|
||||||
|
var fCats = 0
|
||||||
|
for (tag in tags) {
|
||||||
|
tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run {
|
||||||
|
search += tag.key + " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fCats != 0) {
|
||||||
|
append("&f_cats=")
|
||||||
|
append(1023 - fCats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (search.isNotEmpty()) {
|
||||||
|
append("&f_search=")
|
||||||
|
append(search.trim().replace(' ', '+'))
|
||||||
|
}
|
||||||
|
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
|
||||||
|
if (updateDm) {
|
||||||
|
append("&inline_set=dm_e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = loaderContext.httpGet(url).parseHtml().body()
|
||||||
|
val root = body.selectFirst("table.itg")
|
||||||
|
?.selectFirst("tbody")
|
||||||
|
?: if (updateDm) {
|
||||||
|
parseFailed("Cannot find root")
|
||||||
|
} else {
|
||||||
|
updateDm = true
|
||||||
|
return getList2(offset, query, tags, sortOrder)
|
||||||
|
}
|
||||||
|
updateDm = false
|
||||||
|
return root.children().mapNotNull { tr ->
|
||||||
|
if (tr.childrenSize() != 2) return@mapNotNull null
|
||||||
|
val (td1, td2) = tr.children()
|
||||||
|
val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found")
|
||||||
|
val a = glink.parents().select("a").first() ?: parseFailed("link not found")
|
||||||
|
val href = a.relUrl("href")
|
||||||
|
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
|
||||||
|
val mainTag = td2.selectFirst("div.cn")?.let { div ->
|
||||||
|
MangaTag(
|
||||||
|
title = div.text(),
|
||||||
|
key = tagIdByClass(div.classNames()) ?: return@let null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = glink.text().cleanupTitle(),
|
||||||
|
altTitle = null,
|
||||||
|
url = href,
|
||||||
|
publicUrl = a.absUrl("href"),
|
||||||
|
rating = td2.selectFirst("div.ir")?.parseRating() ?: Manga.NO_RATING,
|
||||||
|
isNsfw = true,
|
||||||
|
coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
|
||||||
|
tags = setOfNotNull(mainTag),
|
||||||
|
state = null,
|
||||||
|
author = tagsDiv.getElementsContainingOwnText("artist:").first()
|
||||||
|
?.nextElementSibling()?.text(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
|
||||||
|
val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root")
|
||||||
|
val cover = root.getElementById("gd1")?.children()?.first()
|
||||||
|
val title = root.getElementById("gd2")
|
||||||
|
val taglist = root.getElementById("taglist")
|
||||||
|
val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
|
||||||
|
return manga.copy(
|
||||||
|
title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
|
||||||
|
altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
|
||||||
|
publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
|
||||||
|
rating = root.getElementById("rating_label")?.text()
|
||||||
|
?.substringAfterLast(' ')
|
||||||
|
?.toFloatOrNull()
|
||||||
|
?.div(5f) ?: manga.rating,
|
||||||
|
largeCoverUrl = cover?.css("background")?.cssUrl(),
|
||||||
|
description = taglist?.select("tr")?.joinToString("<br>") { tr ->
|
||||||
|
val (tc, td) = tr.children()
|
||||||
|
val subtags = td.select("a").joinToString { it.html() }
|
||||||
|
"<b>${tc.html()}</b> $subtags"
|
||||||
|
},
|
||||||
|
chapters = tabs?.select("a")?.findLast { a ->
|
||||||
|
a.text().toIntOrNull() != null
|
||||||
|
}?.let { a ->
|
||||||
|
val count = a.text().toInt()
|
||||||
|
val chapters = ArrayList<MangaChapter>(count)
|
||||||
|
for (i in 1..count) {
|
||||||
|
val url = "${manga.url}?p=$i"
|
||||||
|
chapters += MangaChapter(
|
||||||
|
id = generateUid(url),
|
||||||
|
name = "${manga.title} #$i",
|
||||||
|
number = i,
|
||||||
|
url = url,
|
||||||
|
branch = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
chapters
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val doc = loaderContext.httpGet(chapter.url.withDomain()).parseHtml()
|
||||||
|
val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found")
|
||||||
|
return root.select("a").mapNotNull { a ->
|
||||||
|
val url = a.relUrl("href")
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
referer = a.absUrl("href"),
|
||||||
|
preview = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
|
val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml()
|
||||||
|
return doc.body().getElementById("img")?.absUrl("src")
|
||||||
|
?: parseFailed("Image not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = loaderContext.httpGet("https://${getDomain()}").parseHtml()
|
||||||
|
val root = doc.body().getElementById("searchbox")?.selectFirst("table")
|
||||||
|
?: parseFailed("Root not found")
|
||||||
|
return root.select("div.cs").mapNotNullToSet { div ->
|
||||||
|
val id = div.id().substringAfterLast('_').toIntOrNull()
|
||||||
|
?: return@mapNotNullToSet null
|
||||||
|
MangaTag(
|
||||||
|
title = div.text(),
|
||||||
|
key = id.toString(),
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAuthorized(): Boolean {
|
||||||
|
val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
|
||||||
|
if (authorized) {
|
||||||
|
if (!isAuthorized(DOMAIN_AUTHORIZED)) {
|
||||||
|
loaderContext.cookieJar.copyCookies(
|
||||||
|
DOMAIN_UNAUTHORIZED,
|
||||||
|
DOMAIN_AUTHORIZED,
|
||||||
|
authCookies,
|
||||||
|
)
|
||||||
|
loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAuthorized(domain: String): Boolean {
|
||||||
|
val cookies = loaderContext.cookieJar.getCookies(domain).mapToSet { x -> x.name }
|
||||||
|
return authCookies.all { it in cookies }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.parseRating(): Float {
|
||||||
|
return runCatching {
|
||||||
|
val style = requireNotNull(attr("style"))
|
||||||
|
val (v1, v2) = ratingPattern.find(style)!!.destructured
|
||||||
|
var p1 = v1.dropLast(2).toInt()
|
||||||
|
val p2 = v2.dropLast(2).toInt()
|
||||||
|
if (p2 != -1) {
|
||||||
|
p1 += 8
|
||||||
|
}
|
||||||
|
(80 - p1) / 80f
|
||||||
|
}.getOrDefault(Manga.NO_RATING)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.cleanupTitle(): String {
|
||||||
|
val result = StringBuilder(length)
|
||||||
|
var skip = false
|
||||||
|
for (c in this) {
|
||||||
|
when {
|
||||||
|
c == '[' -> skip = true
|
||||||
|
c == ']' -> skip = false
|
||||||
|
c.isWhitespace() && result.isEmpty() -> continue
|
||||||
|
!skip -> result.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (result.lastOrNull()?.isWhitespace() == true) {
|
||||||
|
result.deleteCharAt(result.lastIndex)
|
||||||
|
}
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.cssUrl(): String? {
|
||||||
|
val fromIndex = indexOf("url(")
|
||||||
|
if (fromIndex == -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val toIndex = indexOf(')', startIndex = fromIndex)
|
||||||
|
return if (toIndex == -1) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
substring(fromIndex + 4, toIndex).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tagIdByClass(classNames: Collection<String>): String? {
|
||||||
|
val className = classNames.find { x -> x.startsWith("ct") } ?: return null
|
||||||
|
val num = className.drop(2).toIntOrNull(16) ?: return null
|
||||||
|
return 2.0.pow(num).toInt().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList2(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder?
|
||||||
|
): List<Manga> {
|
||||||
|
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("</b>"),
|
||||||
|
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<MangaPage> {
|
||||||
|
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<MangaTag> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<OkHttpClient>()
|
|
||||||
private val cache by inject<PagesCache>()
|
|
||||||
private val settings by inject<AppSettings>()
|
|
||||||
private val imageLoader by inject<ImageLoader>()
|
|
||||||
private val jobs = HashMap<Int, Job>()
|
|
||||||
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<Manga>(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<Long>?, 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<LocalMangaRepository>().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<Long>? = 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<AppSettings>()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<Long>?, startId: Int) = flow<State> {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<DownloadManager.State>, JobStateFlow<DownloadManager.State>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ActivityDownloadsBinding>() {
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<JobStateFlow<DownloadManager.State>>(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<JobStateFlow<DownloadManager.State>>() {
|
||||||
|
|
||||||
|
override fun areItemsTheSame(
|
||||||
|
oldItem: JobStateFlow<DownloadManager.State>,
|
||||||
|
newItem: JobStateFlow<DownloadManager.State>,
|
||||||
|
): Boolean {
|
||||||
|
return oldItem.value.startId == newItem.value.startId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(
|
||||||
|
oldItem: JobStateFlow<DownloadManager.State>,
|
||||||
|
newItem: JobStateFlow<DownloadManager.State>,
|
||||||
|
): Boolean {
|
||||||
|
return oldItem.value == newItem.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Int, JobStateFlow<DownloadManager.State>>()
|
||||||
|
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<Manga>(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<Long>?,
|
||||||
|
): JobStateFlow<DownloadManager.State> {
|
||||||
|
val initialState = DownloadManager.State.Queued(startId, manga, null)
|
||||||
|
val stateFlow = MutableStateFlow<DownloadManager.State>(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<Collection<JobStateFlow<DownloadManager.State>>>
|
||||||
|
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<Long>? = 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<AppSettings>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<CurrentFilterModel, ListModel, ItemCurrentFilterBinding>(
|
||||||
|
{ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,23 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.adapter
|
package org.koitharu.kotatsu.list.ui.adapter
|
||||||
|
|
||||||
import android.widget.TextView
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
fun emptyStateListAD() = adapterDelegate<EmptyState, ListModel>(R.layout.item_empty_state) {
|
fun emptyStateListAD() = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
|
||||||
|
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }
|
||||||
|
) {
|
||||||
|
|
||||||
bind {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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<ListHeader, ListModel>(R.layout.item_header) {
|
||||||
|
|
||||||
|
bind {
|
||||||
|
val textView = (itemView as TextView)
|
||||||
|
if (item.text != null) {
|
||||||
|
textView.text = item.text
|
||||||
|
} else {
|
||||||
|
textView.setText(item.textRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ChipsView.ChipModel>,
|
||||||
|
) : ListModel
|
||||||
@ -1,7 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.model
|
package org.koitharu.kotatsu.list.ui.model
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
data class EmptyState(
|
data class EmptyState(
|
||||||
@StringRes val text: Int
|
@DrawableRes val icon: Int,
|
||||||
|
@StringRes val textPrimary: Int,
|
||||||
|
@StringRes val textSecondary: Int
|
||||||
) : ListModel
|
) : ListModel
|
||||||
@ -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
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.main.ui
|
||||||
|
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
|
||||||
|
interface AppBarOwner {
|
||||||
|
|
||||||
|
val appBar: AppBarLayout
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue