Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2cf8141a | ||
|
|
eefd1129f7 | ||
|
|
5ed0f8b5a6 | ||
|
|
9b4aa4fd64 | ||
|
|
bbb226791b | ||
|
|
30ac4435d4 | ||
|
|
1b9dfe1901 | ||
|
|
808a6efd8f | ||
|
|
66ed19ed5a | ||
|
|
527a3cbd09 | ||
|
|
f22963b315 |
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 31
|
targetSdkVersion 31
|
||||||
versionCode 372
|
versionCode 374
|
||||||
versionName '2.0'
|
versionName '2.0.2'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -69,18 +69,18 @@ dependencies {
|
|||||||
|
|
||||||
implementation 'androidx.core:core-ktx:1.7.0'
|
implementation 'androidx.core:core-ktx:1.7.0'
|
||||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
implementation 'androidx.fragment:fragment-ktx:1.4.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.4.0'
|
implementation 'androidx.lifecycle:lifecycle-service:2.4.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
|
implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.7.0'
|
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||||
implementation 'com.google.android.material:material:1.4.0'
|
implementation 'com.google.android.material:material:1.4.0'
|
||||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0'
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0'
|
||||||
@@ -96,7 +96,7 @@ dependencies {
|
|||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
|
||||||
|
|
||||||
implementation 'io.insert-koin:koin-android:3.1.3'
|
implementation 'io.insert-koin:koin-android:3.1.4'
|
||||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
implementation 'io.coil-kt:coil-base:1.4.0'
|
||||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.3'
|
implementation 'com.github.solkin:disk-lru-cache:1.3'
|
||||||
@@ -107,7 +107,7 @@ dependencies {
|
|||||||
testImplementation 'com.google.truth:truth:1.1.3'
|
testImplementation 'com.google.truth:truth:1.1.3'
|
||||||
testImplementation 'org.json:json:20210307'
|
testImplementation 'org.json:json:20210307'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
|
||||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.3'
|
testImplementation 'io.insert-koin:koin-test-junit4:3.1.4'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
@@ -65,7 +66,7 @@ class KotatsuApp : Application() {
|
|||||||
trackerModule,
|
trackerModule,
|
||||||
settingsModule,
|
settingsModule,
|
||||||
readerModule,
|
readerModule,
|
||||||
appWidgetModule
|
appWidgetModule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,5 +87,13 @@ class KotatsuApp : Application() {
|
|||||||
.penaltyLog()
|
.penaltyLog()
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||||
|
.penaltyDeath()
|
||||||
|
.detectFragmentReuse()
|
||||||
|
.detectWrongFragmentContainer()
|
||||||
|
.detectRetainInstanceUsage()
|
||||||
|
.detectTargetFragmentUsage()
|
||||||
|
.detectSetUserVisibleHint()
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
package org.koitharu.kotatsu.base.domain
|
package org.koitharu.kotatsu.base.domain
|
||||||
|
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.GraphQLException
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.utils.ext.await
|
import org.koitharu.kotatsu.utils.ext.await
|
||||||
|
import org.koitharu.kotatsu.utils.ext.parseJson
|
||||||
|
|
||||||
|
|
||||||
open class MangaLoaderContext(
|
open class MangaLoaderContext(
|
||||||
private val okHttp: OkHttpClient,
|
private val okHttp: OkHttpClient,
|
||||||
val cookieJar: CookieJar
|
val cookieJar: CookieJar,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
||||||
@@ -24,7 +30,7 @@ open class MangaLoaderContext(
|
|||||||
|
|
||||||
suspend fun httpPost(
|
suspend fun httpPost(
|
||||||
url: String,
|
url: String,
|
||||||
form: Map<String, String>
|
form: Map<String, String>,
|
||||||
): Response {
|
): Response {
|
||||||
val body = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
form.forEach { (k, v) ->
|
form.forEach { (k, v) ->
|
||||||
@@ -38,7 +44,7 @@ open class MangaLoaderContext(
|
|||||||
|
|
||||||
suspend fun httpPost(
|
suspend fun httpPost(
|
||||||
url: String,
|
url: String,
|
||||||
payload: String
|
payload: String,
|
||||||
): Response {
|
): Response {
|
||||||
val body = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
payload.split('&').forEach {
|
payload.split('&').forEach {
|
||||||
@@ -55,10 +61,24 @@ open class MangaLoaderContext(
|
|||||||
return okHttp.newCall(request.build()).await()
|
return okHttp.newCall(request.build()).await()
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
|
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
|
||||||
|
val body = JSONObject()
|
||||||
private companion object {
|
body.put("operationName", null)
|
||||||
|
body.put("variables", JSONObject())
|
||||||
private const val SCHEME_HTTP = "http"
|
body.put("query", "{${query}}")
|
||||||
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
|
val requestBody = body.toString().toRequestBody(mediaType)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.post(requestBody)
|
||||||
|
.url(endpoint)
|
||||||
|
val json = okHttp.newCall(request.build()).await().parseJson()
|
||||||
|
json.optJSONArray("errors")?.let {
|
||||||
|
if (it.length() != 0) {
|
||||||
|
throw GraphQLException(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ import android.view.ViewGroup
|
|||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import android.text.InputFilter
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import org.koitharu.kotatsu.databinding.DialogInputBinding
|
import org.koitharu.kotatsu.databinding.DialogInputBinding
|
||||||
|
|
||||||
class TextInputDialog private constructor(
|
class TextInputDialog private constructor(
|
||||||
private val delegate: AlertDialog
|
private val delegate: AlertDialog,
|
||||||
) : DialogInterface by delegate {
|
) : DialogInterface by delegate {
|
||||||
|
|
||||||
fun show() = delegate.show()
|
fun show() = delegate.show()
|
||||||
@@ -33,7 +32,7 @@ class TextInputDialog private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setHint(@StringRes hintResId: Int): Builder {
|
fun setHint(@StringRes hintResId: Int): Builder {
|
||||||
binding.inputLayout.hint = binding.root.context.getString(hintResId)
|
binding.inputEdit.hint = binding.root.context.getString(hintResId)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ class TextInputDialog private constructor(
|
|||||||
listener: (DialogInterface, String) -> Unit
|
listener: (DialogInterface, String) -> Unit
|
||||||
): Builder {
|
): Builder {
|
||||||
delegate.setPositiveButton(textId) { dialog, _ ->
|
delegate.setPositiveButton(textId) { dialog, _ ->
|
||||||
listener(dialog, binding.inputEdit.text.toString().orEmpty())
|
listener(dialog, binding.inputEdit.text?.toString().orEmpty())
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.koitharu.kotatsu.utils.ext.map
|
||||||
|
|
||||||
|
class GraphQLException(private val errors: JSONArray) : RuntimeException() {
|
||||||
|
|
||||||
|
val messages = errors.map {
|
||||||
|
it.getString("message")
|
||||||
|
}
|
||||||
|
|
||||||
|
override val message: String
|
||||||
|
get() = messages.joinToString("\n")
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ object CommonHeaders {
|
|||||||
const val USER_AGENT = "User-Agent"
|
const val USER_AGENT = "User-Agent"
|
||||||
const val ACCEPT = "Accept"
|
const val ACCEPT = "Accept"
|
||||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||||
|
const val COOKIE = "Cookie"
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.koin.dsl.bind
|
|||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.utils.CacheUtils
|
import org.koitharu.kotatsu.utils.CacheUtils
|
||||||
|
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
val networkModule
|
val networkModule
|
||||||
@@ -28,4 +29,5 @@ val networkModule
|
|||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
factory { DownloadManagerHelper(get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.core.parser.site
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.core.model.*
|
import org.koitharu.kotatsu.core.model.*
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.map
|
||||||
|
import org.koitharu.kotatsu.utils.ext.mapIndexed
|
||||||
|
import org.koitharu.kotatsu.utils.ext.stringIterator
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||||
@@ -20,76 +25,119 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
if (!query.isNullOrEmpty()) {
|
if (!query.isNullOrEmpty()) {
|
||||||
return if (offset == 0) search(query) else emptyList()
|
return if (offset == 0) {
|
||||||
|
search(query)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val page = (offset / 12f).toIntUp().inc()
|
val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
|
||||||
val link = when {
|
separator = ",",
|
||||||
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
|
prefix = "genres: [",
|
||||||
else -> tags.joinToString(
|
postfix = "]"
|
||||||
prefix = "/manga?",
|
) { "\"it.key\"" }.orEmpty()
|
||||||
postfix = "&page=$page",
|
val array = apiCall(
|
||||||
separator = "&",
|
"""
|
||||||
) { tag -> "genre[]=${tag.key}" }.withDomain()
|
getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
|
||||||
}
|
docs {
|
||||||
val doc = loaderContext.httpGet(link).parseHtml()
|
mediaId
|
||||||
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
|
title {
|
||||||
val items = root.select("div.anime-card")
|
be
|
||||||
return items.mapNotNull { card ->
|
alt
|
||||||
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
|
}
|
||||||
val status = card.select("tr")[2].text()
|
rating
|
||||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
poster
|
||||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
genres
|
||||||
val titleParts = fullTitle.splitTwoParts('/')
|
slug
|
||||||
|
mediaType
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONObject("getMediaList").getJSONArray("docs")
|
||||||
|
return array.map { jo ->
|
||||||
|
val mediaId = jo.getString("mediaId")
|
||||||
|
val title = jo.getJSONObject("title")
|
||||||
|
val href = "${jo.getString("mediaType")}/${jo.getString("slug")}"
|
||||||
Manga(
|
Manga(
|
||||||
id = generateUid(href),
|
id = generateUid(mediaId),
|
||||||
title = titleParts?.first?.trim() ?: fullTitle,
|
title = title.getString("be"),
|
||||||
coverUrl = card.selectFirst("img")?.attr("data-src")
|
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||||
?.withDomain().orEmpty(),
|
.withDomain("cdn") + "?width=200&height=280",
|
||||||
altTitle = titleParts?.second?.trim(),
|
altTitle = title.getString("alt").takeUnless(String::isEmpty),
|
||||||
author = null,
|
author = null,
|
||||||
rating = Manga.NO_RATING,
|
rating = jo.getDouble("rating").toFloat() / 10f,
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = href.withDomain(),
|
publicUrl = "https://${getDomain()}/${href}",
|
||||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
tags = jo.getJSONArray("genres").mapToTags(),
|
||||||
MangaTag(
|
state = when (jo.getString("status")) {
|
||||||
title = x.text(),
|
"ongoing" -> MangaState.ONGOING
|
||||||
key = x.attr("href").ifEmpty {
|
"finished" -> MangaState.FINISHED
|
||||||
return@mapNotNull null
|
|
||||||
}.substringAfterLast("="),
|
|
||||||
source = source
|
|
||||||
)
|
|
||||||
},
|
|
||||||
state = when (status) {
|
|
||||||
"выпускаецца" -> MangaState.ONGOING
|
|
||||||
"завершанае" -> MangaState.FINISHED
|
|
||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
val (type, slug) = manga.url.split('/')
|
||||||
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
|
val details = apiCall(
|
||||||
|
"""
|
||||||
|
media(mediaType: $type, slug: "$slug") {
|
||||||
|
mediaId
|
||||||
|
title {
|
||||||
|
be
|
||||||
|
alt
|
||||||
|
}
|
||||||
|
description {
|
||||||
|
be
|
||||||
|
}
|
||||||
|
status
|
||||||
|
poster
|
||||||
|
rating
|
||||||
|
genres
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONObject("media")
|
||||||
|
val title = details.getJSONObject("title")
|
||||||
|
val poster = details.getString("poster").removePrefix("/cdn")
|
||||||
|
.withDomain("cdn")
|
||||||
|
val chapters = apiCall(
|
||||||
|
"""
|
||||||
|
chapters(mediaId: "${details.getString("mediaId")}") {
|
||||||
|
id
|
||||||
|
chapter
|
||||||
|
released
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONArray("chapters")
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
|
title = title.getString("be"),
|
||||||
chapters = root.select("ul.series").flatMap { table ->
|
altTitle = title.getString("alt"),
|
||||||
table.select("li")
|
coverUrl = "$poster?width=200&height=280",
|
||||||
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
|
largeCoverUrl = poster,
|
||||||
val href = a?.select("a")?.first()?.attr("href")
|
description = details.getJSONObject("description").getString("be"),
|
||||||
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
|
rating = details.getDouble("rating").toFloat() / 10f,
|
||||||
|
tags = details.getJSONArray("genres").mapToTags(),
|
||||||
|
state = when (details.getString("status")) {
|
||||||
|
"ongoing" -> MangaState.ONGOING
|
||||||
|
"finished" -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
chapters = chapters.map { jo ->
|
||||||
|
val number = jo.getInt("chapter")
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(href),
|
id = generateUid(jo.getString("id")),
|
||||||
name = "Глава " + a.selectFirst("a")?.text().orEmpty(),
|
name = "Глава $number",
|
||||||
number = i + 1,
|
number = number,
|
||||||
url = href,
|
url = "${manga.url}/read/$number",
|
||||||
scanlator = null,
|
scanlator = null,
|
||||||
|
uploadDate = jo.getLong("released"),
|
||||||
branch = null,
|
branch = null,
|
||||||
uploadDate = 0L,
|
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -97,86 +145,115 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.withDomain()
|
val (_, slug, _, number) = chapter.url.split('/')
|
||||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
val chapterJson = apiCall(
|
||||||
val scripts = doc.select("script")
|
"""
|
||||||
for (script in scripts) {
|
chapter(slug: "$slug", chapter: $number) {
|
||||||
val data = script.html()
|
id
|
||||||
val pos = data.indexOf("dataSource")
|
images {
|
||||||
if (pos == -1) {
|
large
|
||||||
continue
|
thumbnail
|
||||||
}
|
}
|
||||||
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,
|
|
||||||
preview = null,
|
|
||||||
referer = fullUrl,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONObject("chapter")
|
||||||
|
val pages = chapterJson.getJSONArray("images")
|
||||||
|
val chapterUrl = "https://${getDomain()}/${chapter.url}"
|
||||||
|
return pages.mapIndexed { i, jo ->
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid("${chapter.url}/$i"),
|
||||||
|
url = jo.getString("large"),
|
||||||
|
referer = chapterUrl,
|
||||||
|
preview = jo.getString("thumbnail"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
|
val json = apiCall(
|
||||||
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
|
"""
|
||||||
return root.select("p.menu-tags.tupe").mapToSet { p ->
|
getFilters(mediaType: manga) {
|
||||||
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
genres
|
||||||
MangaTag(
|
}
|
||||||
title = a.text().toCamelCase(),
|
""".trimIndent()
|
||||||
key = a.attr("data-name"),
|
)
|
||||||
source = source
|
val array = json.getJSONObject("getFilters").getJSONArray("genres")
|
||||||
)
|
return array.mapToTags()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun search(query: String): List<Manga> {
|
private suspend fun search(query: String): List<Manga> {
|
||||||
val domain = getDomain()
|
val json = apiCall(
|
||||||
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")
|
search(query: "$query", limit: 40) {
|
||||||
val items = root.select("div.anime-card")
|
id
|
||||||
return items.mapNotNull { card ->
|
title {
|
||||||
val href = card.select("a").attr("href")
|
be
|
||||||
val status = card.select("tr")[2].text()
|
en
|
||||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
}
|
||||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
poster
|
||||||
val titleParts = fullTitle.splitTwoParts('/')
|
url
|
||||||
|
type
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
val array = json.getJSONArray("search")
|
||||||
|
return array.map { jo ->
|
||||||
|
val mediaId = jo.getString("id")
|
||||||
|
val title = jo.getJSONObject("title")
|
||||||
|
val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}"
|
||||||
Manga(
|
Manga(
|
||||||
id = generateUid(href),
|
id = generateUid(mediaId),
|
||||||
title = titleParts?.first?.trim() ?: fullTitle,
|
title = title.getString("be"),
|
||||||
coverUrl = card.selectFirst("img")?.attr("src")
|
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||||
?.withDomain().orEmpty(),
|
.withDomain("cdn") + "?width=200&height=280",
|
||||||
altTitle = titleParts?.second?.trim(),
|
altTitle = title.getString("en").takeUnless(String::isEmpty),
|
||||||
author = null,
|
author = null,
|
||||||
rating = Manga.NO_RATING,
|
rating = Manga.NO_RATING,
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = href.withDomain(),
|
publicUrl = "https://${getDomain()}/${href}",
|
||||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
tags = emptySet(),
|
||||||
MangaTag(
|
state = null,
|
||||||
title = x.text(),
|
source = source,
|
||||||
key = x.attr("href").ifEmpty {
|
|
||||||
return@mapNotNull null
|
|
||||||
}.substringAfterLast("="),
|
|
||||||
source = source
|
|
||||||
)
|
|
||||||
},
|
|
||||||
state = when (status) {
|
|
||||||
"выпускаецца" -> MangaState.ONGOING
|
|
||||||
"завершанае" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
source = source
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun apiCall(request: String): JSONObject {
|
||||||
|
return loaderContext.graphQLQuery("https://api.${getDomain()}/", request)
|
||||||
|
.getJSONObject("data")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONArray.mapToTags(): Set<MangaTag> {
|
||||||
|
|
||||||
|
fun toTitle(slug: String): String {
|
||||||
|
val builder = StringBuilder(slug)
|
||||||
|
var capitalize = true
|
||||||
|
for ((i, c) in builder.withIndex()) {
|
||||||
|
when {
|
||||||
|
c == '-' -> {
|
||||||
|
builder.setCharAt(i, ' ')
|
||||||
|
capitalize = true
|
||||||
|
}
|
||||||
|
capitalize -> {
|
||||||
|
builder.setCharAt(i, c.uppercaseChar())
|
||||||
|
capitalize = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = ArraySet<MangaTag>(length())
|
||||||
|
stringIterator().forEach {
|
||||||
|
result.add(
|
||||||
|
MangaTag(
|
||||||
|
title = toTitle(it),
|
||||||
|
key = it,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,12 +18,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
|||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
return super.getList2(offset, query, tags, sortOrder).map {
|
return super.getList2(offset, query, tags, sortOrder).map {
|
||||||
val cover = it.coverUrl
|
it.copy(
|
||||||
if (cover.contains("_blur")) {
|
coverUrl = it.coverUrl.replace("_blur", ""),
|
||||||
it.copy(coverUrl = cover.replace("_blur", ""))
|
isNsfw = true,
|
||||||
} else {
|
)
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
|
|||||||
},
|
},
|
||||||
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
|
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
|
||||||
val a = li.select("a")
|
val a = li.select("a")
|
||||||
val href = a.attr("href").ifEmpty {
|
val href = a.attr("data-href").ifEmpty {
|
||||||
parseFailed("Link is missing")
|
parseFailed("Link is missing")
|
||||||
}
|
}
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
|||||||
|
|
||||||
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
|
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
|
||||||
|
|
||||||
|
var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false)
|
||||||
|
|
||||||
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
|
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
|
||||||
|
|
||||||
val zoomMode by EnumPreferenceDelegate(
|
val zoomMode by EnumPreferenceDelegate(
|
||||||
@@ -192,6 +194,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
|||||||
const val KEY_RESTORE = "restore"
|
const val KEY_RESTORE = "restore"
|
||||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||||
|
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
||||||
@@ -32,7 +33,12 @@ class CategoriesEditDelegate(
|
|||||||
.setNegativeButton(android.R.string.cancel)
|
.setNegativeButton(android.R.string.cancel)
|
||||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||||
.setPositiveButton(R.string.rename) { _, name ->
|
.setPositiveButton(R.string.rename) { _, name ->
|
||||||
callback.onRenameCategory(category, name)
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
callback.onRenameCategory(category, name)
|
||||||
|
}
|
||||||
}.create()
|
}.create()
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
@@ -45,7 +51,12 @@ class CategoriesEditDelegate(
|
|||||||
.setNegativeButton(android.R.string.cancel)
|
.setNegativeButton(android.R.string.cancel)
|
||||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||||
.setPositiveButton(R.string.add) { _, name ->
|
.setPositiveButton(R.string.add) { _, name ->
|
||||||
callback.onCreateCategory(name)
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
callback.onCreateCategory(trimmed)
|
||||||
|
}
|
||||||
}.create()
|
}.create()
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
|
|||||||
val historyModule
|
val historyModule
|
||||||
get() = module {
|
get() = module {
|
||||||
|
|
||||||
single { HistoryRepository(get(), get()) }
|
single { HistoryRepository(get(), get(), get()) }
|
||||||
viewModel { HistoryListViewModel(get(), get(), get()) }
|
viewModel { HistoryListViewModel(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
import org.koitharu.kotatsu.core.model.Manga
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||||
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet
|
|||||||
class HistoryRepository(
|
class HistoryRepository(
|
||||||
private val db: MangaDatabase,
|
private val db: MangaDatabase,
|
||||||
private val trackingRepository: TrackingRepository,
|
private val trackingRepository: TrackingRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
||||||
@@ -45,6 +47,9 @@ class HistoryRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
||||||
|
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
|
|||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.*
|
import org.koitharu.kotatsu.list.ui.model.*
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
|
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ class LocalListViewModel(
|
|||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val name = MediaStoreCompat(contentResolver).getName(uri)
|
val name = contentResolver.resolveName(uri)
|
||||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||||
throw UnsupportedFileException("Unsupported file on $uri")
|
throw UnsupportedFileException("Unsupported file on $uri")
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ val readerModule
|
|||||||
single { PagesCache(get()) }
|
single { PagesCache(get()) }
|
||||||
|
|
||||||
viewModel { params ->
|
viewModel { params ->
|
||||||
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
|
ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
|
|||||||
override fun onActivityResult(result: Boolean) {
|
override fun onActivityResult(result: Boolean) {
|
||||||
if (result) {
|
if (result) {
|
||||||
viewModel.saveCurrentState(reader?.getCurrentState())
|
viewModel.saveCurrentState(reader?.getCurrentState())
|
||||||
viewModel.saveCurrentPage(contentResolver)
|
viewModel.saveCurrentPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.LongSparseArray
|
import android.util.LongSparseArray
|
||||||
import android.webkit.URLUtil
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -23,10 +22,9 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
|
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
@@ -38,7 +36,8 @@ class ReaderViewModel(
|
|||||||
private val dataRepository: MangaDataRepository,
|
private val dataRepository: MangaDataRepository,
|
||||||
private val historyRepository: HistoryRepository,
|
private val historyRepository: HistoryRepository,
|
||||||
private val shortcutsRepository: ShortcutsRepository,
|
private val shortcutsRepository: ShortcutsRepository,
|
||||||
private val settings: AppSettings
|
private val settings: AppSettings,
|
||||||
|
private val downloadManagerHelper: DownloadManagerHelper,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
@@ -150,7 +149,7 @@ class ReaderViewModel(
|
|||||||
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentPage(resolver: ContentResolver) {
|
fun saveCurrentPage() {
|
||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
val state = currentState.value ?: error("Undefined state")
|
val state = currentState.value ?: error("Undefined state")
|
||||||
@@ -159,13 +158,8 @@ class ReaderViewModel(
|
|||||||
}?.toMangaPage() ?: error("Page not found")
|
}?.toMangaPage() ?: error("Page not found")
|
||||||
val repo = MangaRepository(page.source)
|
val repo = MangaRepository(page.source)
|
||||||
val pageUrl = repo.getPageUrl(page)
|
val pageUrl = repo.getPageUrl(page)
|
||||||
val file = get<PagesCache>()[pageUrl] ?: error("Page not found in cache")
|
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
|
||||||
val uri = file.inputStream().use { input ->
|
val uri = downloadManagerHelper.awaitDownload(downloadId)
|
||||||
val fileName = URLUtil.guessFileName(pageUrl, null, null)
|
|
||||||
MediaStoreCompat(resolver).insertImage(fileName) {
|
|
||||||
input.copyTo(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPageSaved.postCall(uri)
|
onPageSaved.postCall(uri)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.preference.*
|
import androidx.preference.*
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||||
|
import org.koitharu.kotatsu.utils.ext.names
|
||||||
|
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -134,53 +132,4 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
|||||||
settings.setStorageDir(context ?: return, file)
|
settings.setStorageDir(context ?: return, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableAppProtection(preference: SwitchPreference) {
|
|
||||||
val ctx = preference.context ?: return
|
|
||||||
val cancelListener =
|
|
||||||
object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener {
|
|
||||||
|
|
||||||
override fun onCancel(dialog: DialogInterface?) {
|
|
||||||
settings.appPassword = null
|
|
||||||
preference.isChecked = false
|
|
||||||
preference.isEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog)
|
|
||||||
}
|
|
||||||
preference.isEnabled = false
|
|
||||||
TextInputDialog.Builder(ctx)
|
|
||||||
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
|
|
||||||
.setHint(R.string.enter_password)
|
|
||||||
.setNegativeButton(android.R.string.cancel, cancelListener)
|
|
||||||
.setOnCancelListener(cancelListener)
|
|
||||||
.setPositiveButton(android.R.string.ok) { d, password ->
|
|
||||||
if (password.isBlank()) {
|
|
||||||
cancelListener.onCancel(d)
|
|
||||||
return@setPositiveButton
|
|
||||||
}
|
|
||||||
TextInputDialog.Builder(ctx)
|
|
||||||
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
|
|
||||||
.setHint(R.string.repeat_password)
|
|
||||||
.setNegativeButton(android.R.string.cancel, cancelListener)
|
|
||||||
.setOnCancelListener(cancelListener)
|
|
||||||
.setPositiveButton(android.R.string.ok) { d2, password2 ->
|
|
||||||
if (password == password2) {
|
|
||||||
settings.appPassword = password.md5()
|
|
||||||
preference.isChecked = true
|
|
||||||
preference.isEnabled = true
|
|
||||||
} else {
|
|
||||||
cancelListener.onCancel(d2)
|
|
||||||
Snackbar.make(
|
|
||||||
listView,
|
|
||||||
R.string.passwords_mismatch,
|
|
||||||
Snackbar.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}.setTitle(preference.title)
|
|
||||||
.create()
|
|
||||||
.show()
|
|
||||||
}.setTitle(preference.title)
|
|
||||||
.create()
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package org.koitharu.kotatsu.utils
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.app.DownloadManager.Request.*
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toFileNameSafe
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class DownloadManagerHelper(
|
||||||
|
private val context: Context,
|
||||||
|
private val cookieJar: CookieJar,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
private val subDir = context.getString(R.string.app_name).toFileNameSafe()
|
||||||
|
|
||||||
|
fun downloadPage(page: MangaPage, fullUrl: String): Long {
|
||||||
|
val uri = fullUrl.toUri()
|
||||||
|
val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl())
|
||||||
|
val dest = subDir + File.separator + uri.lastPathSegment
|
||||||
|
val request = DownloadManager.Request(uri)
|
||||||
|
.addRequestHeader(CommonHeaders.REFERER, page.referer)
|
||||||
|
.addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies))
|
||||||
|
.setAllowedOverMetered(true)
|
||||||
|
.setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE)
|
||||||
|
.setNotificationVisibility(VISIBILITY_VISIBLE)
|
||||||
|
.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest)
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
request.allowScanningByMediaScanner()
|
||||||
|
}
|
||||||
|
return manager.enqueue(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun awaitDownload(id: Long): Uri {
|
||||||
|
getUriForDownloadedFile(id)?.let { return it } // fast path
|
||||||
|
suspendCancellableCoroutine<Unit> { cont ->
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (
|
||||||
|
intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE &&
|
||||||
|
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id
|
||||||
|
) {
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
cont.resume(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||||
|
)
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
context.unregisterReceiver(receiver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return checkNotNull(getUriForDownloadedFile(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) {
|
||||||
|
manager.getUriForDownloadedFile(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cookieHeader(cookies: List<Cookie>): String = buildString {
|
||||||
|
cookies.forEachIndexed { index, cookie ->
|
||||||
|
if (index > 0) append("; ")
|
||||||
|
append(cookie.name).append('=').append(cookie.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.utils
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.core.database.getStringOrNull
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
class MediaStoreCompat(private val contentResolver: ContentResolver) {
|
|
||||||
|
|
||||||
fun insertImage(
|
|
||||||
fileName: String,
|
|
||||||
block: (OutputStream) -> Unit
|
|
||||||
): Uri? {
|
|
||||||
val name = fileName.substringBeforeLast('.')
|
|
||||||
val cv = ContentValues(7)
|
|
||||||
cv.put(MediaStore.Images.Media.DISPLAY_NAME, name)
|
|
||||||
cv.put(MediaStore.Images.Media.TITLE, name)
|
|
||||||
cv.put(
|
|
||||||
MediaStore.Images.Media.MIME_TYPE,
|
|
||||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.'))
|
|
||||||
)
|
|
||||||
cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1_000)
|
|
||||||
cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis())
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
cv.put(MediaStore.Images.Media.IS_PENDING, 1)
|
|
||||||
}
|
|
||||||
var uri: Uri? = null
|
|
||||||
try {
|
|
||||||
uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv)
|
|
||||||
contentResolver.openOutputStream(uri!!)?.use(block)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
cv.clear()
|
|
||||||
cv.put(MediaStore.Images.Media.IS_PENDING, 0)
|
|
||||||
contentResolver.update(uri, cv, null, null)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
uri?.let {
|
|
||||||
contentResolver.delete(it, null, null)
|
|
||||||
}
|
|
||||||
uri = null
|
|
||||||
}
|
|
||||||
return uri
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getName(uri: Uri): String? =
|
|
||||||
(if (uri.scheme == "content") {
|
|
||||||
contentResolver.query(uri, null, null, null, null)?.use {
|
|
||||||
if (it.moveToFirst()) {
|
|
||||||
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}) ?: uri.path?.substringAfterLast('/')
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.utils.ext
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.core.database.getStringOrNull
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -60,4 +63,19 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
|
|||||||
|
|
||||||
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
|
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
|
||||||
delete()
|
delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ContentResolver.resolveName(uri: Uri): String? {
|
||||||
|
val fallback = uri.lastPathSegment
|
||||||
|
if (uri.scheme != "content") {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
query(uri, null, null, null, null)?.use {
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name ->
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,8 @@ fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long = opt(na
|
|||||||
|
|
||||||
operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
|
operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
|
||||||
|
|
||||||
|
fun JSONArray.stringIterator(): Iterator<String> = JSONStringIterator(this)
|
||||||
|
|
||||||
private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
|
private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
|
||||||
|
|
||||||
private val total = array.length()
|
private val total = array.length()
|
||||||
@@ -52,7 +54,16 @@ private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject>
|
|||||||
override fun hasNext() = index < total - 1
|
override fun hasNext() = index < total - 1
|
||||||
|
|
||||||
override fun next(): JSONObject = array.getJSONObject(index++)
|
override fun next(): JSONObject = array.getJSONObject(index++)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
|
||||||
|
|
||||||
|
private val total = array.length()
|
||||||
|
private var index = 0
|
||||||
|
|
||||||
|
override fun hasNext() = index < total - 1
|
||||||
|
|
||||||
|
override fun next(): String = array.getString(index++)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
|
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/inputLayout"
|
android:id="@+id/inputLayout"
|
||||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
app:boxBackgroundMode="filled"
|
||||||
|
app:boxBackgroundColor="@android:color/transparent"
|
||||||
|
app:hintEnabled="false"
|
||||||
|
app:expandedHintEnabled="true"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:imeOptions="actionDone"
|
android:imeOptions="actionDone"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
tools:text="@tools:sample/lorem[2]" />
|
tools:hint="@tools:sample/lorem[2]" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -243,4 +243,6 @@
|
|||||||
<string name="state_ongoing">Онгоинг</string>
|
<string name="state_ongoing">Онгоинг</string>
|
||||||
<string name="date_format">Формат даты</string>
|
<string name="date_format">Формат даты</string>
|
||||||
<string name="system_default">По умолчанию</string>
|
<string name="system_default">По умолчанию</string>
|
||||||
|
<string name="exclude_nsfw_from_history">Исключить NSFW мангу из истории</string>
|
||||||
|
<string name="error_empty_name">Имя не может быть пустым</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -244,4 +244,6 @@
|
|||||||
<string name="state_ongoing">Ongoing</string>
|
<string name="state_ongoing">Ongoing</string>
|
||||||
<string name="date_format">Date format</string>
|
<string name="date_format">Date format</string>
|
||||||
<string name="system_default">Default</string>
|
<string name="system_default">Default</string>
|
||||||
|
<string name="exclude_nsfw_from_history">Exclude NSFW manga from history</string>
|
||||||
|
<string name="error_empty_name">Name sould not be empty</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -15,6 +15,11 @@
|
|||||||
android:title="@string/clear_updates_feed"
|
android:title="@string/clear_updates_feed"
|
||||||
app:iconSpaceReserved="false" />
|
app:iconSpaceReserved="false" />
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:key="history_exclude_nsfw"
|
||||||
|
android:title="@string/exclude_nsfw_from_history"
|
||||||
|
app:iconSpaceReserved="false" />
|
||||||
|
|
||||||
<PreferenceCategory
|
<PreferenceCategory
|
||||||
android:title="@string/cache"
|
android:title="@string/cache"
|
||||||
app:iconSpaceReserved="false">
|
app:iconSpaceReserved="false">
|
||||||
|
|||||||
@@ -70,13 +70,14 @@ class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
|
|||||||
@Test
|
@Test
|
||||||
fun details() = coroutineTestRule.runBlockingTest {
|
fun details() = coroutineTestRule.runBlockingTest {
|
||||||
val list = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
val list = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
val item = list.first()
|
val manga = list.first()
|
||||||
val details = repo.getDetails(item)
|
println(manga.title + ": " + manga.url)
|
||||||
|
val details = repo.getDetails(manga)
|
||||||
|
|
||||||
Truth.assertThat(details.chapters).isNotEmpty()
|
Truth.assertThat(details.chapters).isNotEmpty()
|
||||||
Truth.assertThat(details.publicUrl).isAbsoluteUrl()
|
Truth.assertThat(details.publicUrl).isAbsoluteUrl()
|
||||||
Truth.assertThat(details.description).isNotNull()
|
Truth.assertThat(details.description).isNotNull()
|
||||||
Truth.assertThat(details.title).startsWith(item.title)
|
Truth.assertThat(details.title).startsWith(manga.title)
|
||||||
Truth.assertThat(details.source).isEqualTo(source)
|
Truth.assertThat(details.source).isEqualTo(source)
|
||||||
|
|
||||||
Truth.assertThat(details.chapters?.map { it.id }).containsNoDuplicates()
|
Truth.assertThat(details.chapters?.map { it.id }).containsNoDuplicates()
|
||||||
@@ -88,8 +89,9 @@ class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
|
|||||||
@Test
|
@Test
|
||||||
fun pages() = coroutineTestRule.runBlockingTest {
|
fun pages() = coroutineTestRule.runBlockingTest {
|
||||||
val list = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
val list = repo.getList2(20, query = null, sortOrder = SortOrder.POPULARITY, tags = null)
|
||||||
val chapter =
|
val manga = list.first()
|
||||||
repo.getDetails(list.first()).chapters?.firstOrNull() ?: error("Chapter is null")
|
println(manga.title + ": " + manga.url)
|
||||||
|
val chapter = repo.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null")
|
||||||
val pages = repo.getPages(chapter)
|
val pages = repo.getPages(chapter)
|
||||||
|
|
||||||
Truth.assertThat(pages).isNotEmpty()
|
Truth.assertThat(pages).isNotEmpty()
|
||||||
|
|||||||
Reference in New Issue
Block a user