Compare commits

...

11 Commits
v2.0 ... v2.0.2

Author SHA1 Message Date
Koitharu
8f2cf8141a Mark HenChan manga as nsfw 2021-12-10 18:10:12 +02:00
Koitharu
eefd1129f7 Update dependencies, setup FragmentStrictMode 2021-12-04 12:09:13 +02:00
Koitharu
5ed0f8b5a6 Check if category name is not empty 2021-12-03 21:11:50 +02:00
Koitharu
9b4aa4fd64 Migrate AniBel parser to graphql 2021-12-03 20:30:20 +02:00
Koitharu
bbb226791b Merge branch 'hotifx/2.0.1' into devel 2021-11-22 08:48:11 +02:00
Koitharu
30ac4435d4 Update version 2021-11-22 08:41:35 +02:00
Koitharu
1b9dfe1901 Temporary change anibel domain 2021-11-21 17:41:32 +02:00
Zakhar Timoshenko
808a6efd8f [Source] [MangaOwl] Fix not loading chapter list 2021-11-21 17:13:39 +02:00
Zakhar Timoshenko
66ed19ed5a [Source] [MangaOwl] Fix not loading chapter list 2021-11-21 17:12:04 +02:00
Koitharu
527a3cbd09 Option to exclude NSFW content from history 2021-11-20 16:49:30 +02:00
Koitharu
f22963b315 Use DownloadManager for pages saving 2021-11-18 20:06:44 +02:00
29 changed files with 439 additions and 294 deletions

View File

@@ -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'

View File

@@ -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()
} }
} }

View File

@@ -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)
} }

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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")
}

View File

@@ -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"
} }

View File

@@ -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()) }
} }

View File

@@ -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
}
} }

View File

@@ -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
}
} }
} }

View File

@@ -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(

View File

@@ -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"

View File

@@ -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()
} }

View File

@@ -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()) }
} }

View File

@@ -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)

View File

@@ -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")

View File

@@ -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())
} }
} }

View File

@@ -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()
} }
} }

View File

@@ -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) {

View File

@@ -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()
}
} }

View File

@@ -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)
}
}
}

View File

@@ -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('/')
}

View File

@@ -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
} }

View File

@@ -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> {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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()