Koharu: fix DTO & bypass Cloudflare (#8175)

* Koharu - \r\n → \n

* SchaleNetwork: fix loading & support related-manga

* (SchaleNetwok/Koharu): Fix DTO & bypass Cloudflare (#128)

* Fix DTO
* Bypass CloudFlare Turnstile
* Add tags filter select box which supports click on 'tag' to search
* Allow permanent excluded tags

* Revert fork specific Koharu changes

* Bump version + lint

---------

Co-authored-by: Cuong-Tran <cuongtran.tm@gmail.com>
This commit is contained in:
Vetle Ledaal 2025-03-23 16:05:54 +01:00 committed by Draff
parent 414b6b8670
commit 3fb70c10de
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
8 changed files with 4146 additions and 550 deletions

View File

@ -1,27 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.koharu.KoharuUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:pathPattern="/g/..*/..*"/>
<data android:host="koharu.to" />
<data android:host="schale.network" />
<data android:host="gehenna.jp" />
<data android:host="niyaniya.moe" />
<data android:host="seia.to" />
<data android:host="shupogaki.moe" />
<data android:host="hoshino.one" />
</intent-filter>
</activity>
</application>
</manifest>
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.koharu.KoharuUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:pathPattern="/g/..*/..*"/>
<data android:host="koharu.to" />
<data android:host="schale.network" />
<data android:host="gehenna.jp" />
<data android:host="niyaniya.moe" />
<data android:host="seia.to" />
<data android:host="shupogaki.moe" />
<data android:host="hoshino.one" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,7 +1,7 @@
ext {
extName = 'SchaleNetwork'
extClass = '.KoharuFactory'
extVersionCode = 12
extVersionCode = 13
isNsfw = true
}

View File

@ -1,378 +1,441 @@
package eu.kanade.tachiyomi.extension.all.koharu
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.getPreferencesLazy
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class Koharu(
override val lang: String = "all",
private val searchLang: String = "",
) : HttpSource(), ConfigurableSource {
override val name = "SchaleNetwork"
override val baseUrl = "https://schale.network"
override val id = if (lang == "en") 1484902275639232927 else super.id
private val apiUrl = baseUrl.replace("://", "://api.")
private val apiBooksUrl = "$apiUrl/books"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(1)
.build()
private val json: Json by injectLazy()
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
private fun String.shortenTitle() = replace(shortenTitleRegex, "").trim()
private val preferences: SharedPreferences by getPreferencesLazy()
private fun quality() = preferences.getString(PREF_IMAGERES, "1280")!!
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
private fun getDomain(): String {
try {
val noRedirectClient = client.newBuilder().followRedirects(false).build()
val host = noRedirectClient.newCall(GET(baseUrl, headers)).execute()
.headers["Location"]?.toHttpUrlOrNull()?.host
?: return baseUrl
return "https://$host"
} catch (_: Exception) {
return baseUrl
}
}
private val lazyHeaders by lazy {
val domain = getDomain()
headersBuilder()
.set("Referer", "$domain/")
.set("Origin", domain)
.build()
}
private fun getManga(book: Entry) = SManga.create().apply {
setUrlWithoutDomain("${book.id}/${book.public_key}")
title = if (remadd()) book.title.shortenTitle() else book.title
thumbnail_url = book.thumbnail.path
}
private fun getImagesByMangaEntry(entry: MangaEntry): Pair<ImagesInfo, String> {
val data = entry.data
fun getIPK(
ori: DataKey?,
alt1: DataKey?,
alt2: DataKey?,
alt3: DataKey?,
alt4: DataKey?,
): Pair<Int?, String?> {
return Pair(
ori?.id ?: alt1?.id ?: alt2?.id ?: alt3?.id ?: alt4?.id,
ori?.public_key ?: alt1?.public_key ?: alt2?.public_key ?: alt3?.public_key ?: alt4?.public_key,
)
}
val (id, public_key) = when (quality()) {
"1600" -> getIPK(data.`1600`, data.`1280`, data.`0`, data.`980`, data.`780`)
"1280" -> getIPK(data.`1280`, data.`1600`, data.`0`, data.`980`, data.`780`)
"980" -> getIPK(data.`980`, data.`1280`, data.`0`, data.`1600`, data.`780`)
"780" -> getIPK(data.`780`, data.`980`, data.`0`, data.`1280`, data.`1600`)
else -> getIPK(data.`0`, data.`1600`, data.`1280`, data.`980`, data.`780`)
}
if (id == null || public_key == null) {
throw Exception("No Images Found")
}
val realQuality = when (id) {
data.`1600`?.id -> "1600"
data.`1280`?.id -> "1280"
data.`980`?.id -> "980"
data.`780`?.id -> "780"
else -> "0"
}
val imagesResponse = client.newCall(GET("$apiBooksUrl/data/${entry.id}/${entry.public_key}/$id/$public_key?v=${entry.updated_at ?: entry.created_at}&w=$realQuality", lazyHeaders)).execute()
val images = imagesResponse.parseAs<ImagesInfo>() to realQuality
return images
}
// Latest
override fun latestUpdatesRequest(page: Int) = GET("$apiBooksUrl?page=$page" + if (searchLang.isNotBlank()) "&s=language!:\"$searchLang\"" else "", lazyHeaders)
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
// Popular
override fun popularMangaRequest(page: Int) = GET("$apiBooksUrl?sort=8&page=$page" + if (searchLang.isNotBlank()) "&s=language!:\"$searchLang\"" else "", lazyHeaders)
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<Books>()
return MangasPage(data.entries.map(::getManga), data.page * data.limit < data.total)
}
// Search
override fun getFilterList(): FilterList = getFilters()
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when {
query.startsWith(PREFIX_ID_KEY_SEARCH) -> {
val ipk = query.removePrefix(PREFIX_ID_KEY_SEARCH)
val response = client.newCall(GET("$apiBooksUrl/detail/$ipk", lazyHeaders)).execute()
Observable.just(searchMangaParse2(response))
}
else -> super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = apiBooksUrl.toHttpUrl().newBuilder().apply {
val terms: MutableList<String> = mutableListOf()
if (lang != "all") terms += "language!:\"$searchLang\""
filters.forEach { filter ->
when (filter) {
is SortFilter -> addQueryParameter("sort", filter.getValue())
is CategoryFilter -> {
val activeFilter = filter.state.filter { it.state }
if (activeFilter.isNotEmpty()) {
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
}
}
is TextFilter -> {
if (filter.state.isNotEmpty()) {
val tags = filter.state.split(",").filter(String::isNotBlank).joinToString(",")
if (tags.isNotBlank()) {
terms += "${filter.type}!:" + if (filter.type == "pages") tags else '"' + tags + '"'
}
}
}
else -> {}
}
}
if (query.isNotEmpty()) terms.add("title:\"$query\"")
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
addQueryParameter("page", page.toString())
}.build()
return GET(url, lazyHeaders)
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
private fun searchMangaParse2(response: Response): MangasPage {
val entry = response.parseAs<MangaEntry>()
return MangasPage(
listOf(
SManga.create().apply {
setUrlWithoutDomain("${entry.id}/${entry.public_key}")
title = if (remadd()) entry.title.shortenTitle() else entry.title
thumbnail_url = entry.thumbnails.base + entry.thumbnails.main.path
},
),
false,
)
}
// Details
override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$apiBooksUrl/detail/${manga.url}", lazyHeaders)
}
override fun mangaDetailsParse(response: Response): SManga {
return response.parseAs<MangaEntry>().toSManga()
}
private val dateReformat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
private fun MangaEntry.toSManga() = SManga.create().apply {
val artists = mutableListOf<String>()
val circles = mutableListOf<String>()
val parodies = mutableListOf<String>()
val magazines = mutableListOf<String>()
val characters = mutableListOf<String>()
val cosplayers = mutableListOf<String>()
val females = mutableListOf<String>()
val males = mutableListOf<String>()
val mixed = mutableListOf<String>()
val other = mutableListOf<String>()
val uploaders = mutableListOf<String>()
val tags = mutableListOf<String>()
for (tag in this@toSManga.tags) {
when (tag.namespace) {
1 -> artists.add(tag.name)
2 -> circles.add(tag.name)
3 -> parodies.add(tag.name)
4 -> magazines.add(tag.name)
5 -> characters.add(tag.name)
6 -> cosplayers.add(tag.name)
7 -> tag.name.takeIf { it != "anonymous" }?.let { uploaders.add(it) }
8 -> males.add(tag.name + "")
9 -> females.add(tag.name + "")
10 -> mixed.add(tag.name)
12 -> other.add(tag.name)
else -> tags.add(tag.name)
}
}
var appended = false
fun List<String>.joinAndCapitalizeEach(): String? = this.emptyToNull()?.joinToString { it.capitalizeEach() }?.apply { appended = true }
title = if (remadd()) this@toSManga.title.shortenTitle() else this@toSManga.title
author = (circles.emptyToNull() ?: artists).joinToString { it.capitalizeEach() }
artist = artists.joinToString { it.capitalizeEach() }
genre = (tags + males + females + mixed + other).joinToString { it.capitalizeEach() }
description = buildString {
circles.joinAndCapitalizeEach()?.let {
append("Circles: ", it, "\n")
}
uploaders.joinAndCapitalizeEach()?.let {
append("Uploaders: ", it, "\n")
}
magazines.joinAndCapitalizeEach()?.let {
append("Magazines: ", it, "\n")
}
cosplayers.joinAndCapitalizeEach()?.let {
append("Cosplayers: ", it, "\n")
}
parodies.joinAndCapitalizeEach()?.let {
append("Parodies: ", it, "\n")
}
characters.joinAndCapitalizeEach()?.let {
append("Characters: ", it, "\n")
}
if (appended) append("\n")
try {
append("Posted: ", dateReformat.format(created_at), "\n")
} catch (_: Exception) {}
val dataKey = when (quality()) {
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
"980" -> data.`980` ?: data.`1280` ?: data.`0`
"780" -> data.`780` ?: data.`980` ?: data.`0`
else -> data.`0`
}
append("Size: ", dataKey.readableSize(), "\n\n")
append("Pages: ", thumbnails.entries.size, "\n\n")
}
status = SManga.COMPLETED
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
initialized = true
}
private fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
s.replaceFirstChar { sr ->
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
}
}
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
return this.ifEmpty { null }
}
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
// Chapter
override fun chapterListRequest(manga: SManga): Request {
return GET("$apiBooksUrl/detail/${manga.url}", lazyHeaders)
}
override fun chapterListParse(response: Response): List<SChapter> {
val manga = response.parseAs<MangaEntry>()
return listOf(
SChapter.create().apply {
name = "Chapter"
url = "${manga.id}/${manga.public_key}"
date_upload = (manga.updated_at ?: manga.created_at)
},
)
}
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/g/${chapter.url}"
// Page List
override fun pageListRequest(chapter: SChapter): Request {
return GET("$apiBooksUrl/detail/${chapter.url}", lazyHeaders)
}
override fun pageListParse(response: Response): List<Page> {
val mangaEntry = response.parseAs<MangaEntry>()
val imagesInfo = getImagesByMangaEntry(mangaEntry)
return imagesInfo.first.entries.mapIndexed { index, image ->
Page(index, imageUrl = "${imagesInfo.first.base}/${image.path}?w=${imagesInfo.second}")
}
}
override fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, lazyHeaders)
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
// Settings
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_IMAGERES
title = "Image Resolution"
entries = arrayOf("780x", "980x", "1280x", "1600x", "Original")
entryValues = arrayOf("780", "980", "1280", "1600", "0")
summary = "%s"
setDefaultValue("1280")
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_REM_ADD
title = "Remove additional information in title"
summary = "Remove anything in brackets from manga titles.\n" +
"Reload manga to apply changes to loaded manga."
setDefaultValue(false)
}.also(screen::addPreference)
}
private inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
companion object {
const val PREFIX_ID_KEY_SEARCH = "id:"
private const val PREF_IMAGERES = "pref_image_quality"
private const val PREF_REM_ADD = "pref_remove_additional"
}
}
package eu.kanade.tachiyomi.extension.all.koharu
import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.artistList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.circleList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.femaleList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.genreList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.getFilters
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.maleList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.mixedList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.otherList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.parodyList
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.tagsFetchAttempts
import eu.kanade.tachiyomi.extension.all.koharu.KoharuFilters.tagsFetched
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.getPreferencesLazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class Koharu(
override val lang: String = "all",
private val searchLang: String = "",
) : HttpSource(), ConfigurableSource {
override val name = "SchaleNetwork"
override val baseUrl = "https://schale.network"
override val id = if (lang == "en") 1484902275639232927 else super.id
private val apiUrl = baseUrl.replace("://", "://api.")
private val apiBooksUrl = "$apiUrl/books"
private val authUrl = "${baseUrl.replace("://", "://auth.")}/clearance"
override val supportsLatest = true
private val json: Json by injectLazy()
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
private fun String.shortenTitle() = replace(shortenTitleRegex, "").trim()
private val preferences: SharedPreferences by getPreferencesLazy()
private fun quality() = preferences.getString(PREF_IMAGERES, "1280")!!
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
private fun alwaysExcludeTags() = preferences.getString(PREF_EXCLUDE_TAGS, "")
private var _domainUrl: String? = null
internal val domainUrl: String
get() {
return _domainUrl ?: run {
val domain = getDomain()
_domainUrl = domain
domain
}
}
private fun getDomain(): String {
try {
val noRedirectClient = client.newBuilder().followRedirects(false).build()
val host = noRedirectClient.newCall(GET(baseUrl, headers)).execute()
.headers["Location"]?.toHttpUrlOrNull()?.host
?: return baseUrl
return "https://$host"
} catch (_: Exception) {
return baseUrl
}
}
private val lazyHeaders by lazy {
headersBuilder()
.set("Referer", "$domainUrl/")
.set("Origin", domainUrl)
.build()
}
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(3)
.build()
private val interceptedClient: OkHttpClient
get() = network.cloudflareClient.newBuilder()
.addInterceptor(TurnstileInterceptor(client, domainUrl, authUrl, lazyHeaders["User-Agent"]))
.rateLimit(3)
.build()
private fun getManga(book: Entry) = SManga.create().apply {
setUrlWithoutDomain("${book.id}/${book.key}")
title = if (remadd()) book.title.shortenTitle() else book.title
thumbnail_url = book.thumbnail.path
}
private fun getImagesByMangaData(entry: MangaData, entryId: String, entryKey: String): Pair<ImagesInfo, String> {
val data = entry.data
fun getIPK(
ori: DataKey?,
alt1: DataKey?,
alt2: DataKey?,
alt3: DataKey?,
alt4: DataKey?,
): Pair<Int?, String?> {
return Pair(
ori?.id ?: alt1?.id ?: alt2?.id ?: alt3?.id ?: alt4?.id,
ori?.key ?: alt1?.key ?: alt2?.key ?: alt3?.key ?: alt4?.key,
)
}
val (id, public_key) = when (quality()) {
"1600" -> getIPK(data.`1600`, data.`1280`, data.`0`, data.`980`, data.`780`)
"1280" -> getIPK(data.`1280`, data.`1600`, data.`0`, data.`980`, data.`780`)
"980" -> getIPK(data.`980`, data.`1280`, data.`0`, data.`1600`, data.`780`)
"780" -> getIPK(data.`780`, data.`980`, data.`0`, data.`1280`, data.`1600`)
else -> getIPK(data.`0`, data.`1600`, data.`1280`, data.`980`, data.`780`)
}
if (id == null || public_key == null) {
throw Exception("No Images Found")
}
val realQuality = when (id) {
data.`1600`?.id -> "1600"
data.`1280`?.id -> "1280"
data.`980`?.id -> "980"
data.`780`?.id -> "780"
else -> "0"
}
val imagesResponse = interceptedClient.newCall(GET("$apiBooksUrl/data/$entryId/$entryKey/$id/$public_key/$realQuality?crt=$token", lazyHeaders)).execute()
val images = imagesResponse.parseAs<ImagesInfo>() to realQuality
return images
}
// Latest
override fun latestUpdatesRequest(page: Int) = GET(
apiBooksUrl.toHttpUrl().newBuilder().apply {
addQueryParameter("page", page.toString())
val terms: MutableList<String> = mutableListOf()
if (lang != "all") terms += "language:\"^$searchLang$\""
val alwaysExcludeTags = alwaysExcludeTags()?.split(",")
?.map { it.trim() }?.filter(String::isNotBlank) ?: emptyList()
if (alwaysExcludeTags.isNotEmpty()) {
terms += "tag:\"${alwaysExcludeTags.joinToString(",") { "-$it" }}\""
}
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
}.build(),
lazyHeaders,
)
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
// Popular
override fun popularMangaRequest(page: Int) = GET(
apiBooksUrl.toHttpUrl().newBuilder().apply {
addQueryParameter("sort", "8")
addQueryParameter("page", page.toString())
val terms: MutableList<String> = mutableListOf()
if (lang != "all") terms += "language:\"^$searchLang$\""
val alwaysExcludeTags = alwaysExcludeTags()?.split(",")
?.map { it.trim() }?.filter(String::isNotBlank) ?: emptyList()
if (alwaysExcludeTags.isNotEmpty()) {
terms += "tag:\"${alwaysExcludeTags.joinToString(",") { "-$it" }}\""
}
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
}.build(),
lazyHeaders,
)
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<Books>()
return MangasPage(data.entries.map(::getManga), data.page * data.limit < data.total)
}
// Search
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when {
query.startsWith(PREFIX_ID_KEY_SEARCH) -> {
val ipk = query.removePrefix(PREFIX_ID_KEY_SEARCH)
val response = client.newCall(GET("$apiBooksUrl/detail/$ipk", lazyHeaders)).execute()
Observable.just(
MangasPage(listOf(mangaDetailsParse(response)), false),
)
}
else -> super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = apiBooksUrl.toHttpUrl().newBuilder().apply {
val terms: MutableList<String> = mutableListOf()
val includedTags: MutableList<Int> = mutableListOf()
val excludedTags: MutableList<Int> = mutableListOf()
if (lang != "all") terms += "language:\"^$searchLang$\""
val alwaysExcludeTags = alwaysExcludeTags()?.split(",")
?.map { it.trim() }?.filter(String::isNotBlank) ?: emptyList()
if (alwaysExcludeTags.isNotEmpty()) {
terms += "tag:\"${alwaysExcludeTags.joinToString(",") { "-$it" }}\""
}
filters.forEach { filter ->
when (filter) {
is KoharuFilters.SortFilter -> addQueryParameter("sort", filter.getValue())
is KoharuFilters.CategoryFilter -> {
val activeFilter = filter.state.filter { it.state }
if (activeFilter.isNotEmpty()) {
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
}
}
is KoharuFilters.TagFilter -> {
includedTags += filter.state
.filter { it.isIncluded() }
.map { it.id }
excludedTags += filter.state
.filter { it.isExcluded() }
.map { it.id }
}
is KoharuFilters.GenreConditionFilter -> {
if (filter.state > 0) {
addQueryParameter(filter.param, filter.toUriPart())
}
}
is KoharuFilters.TextFilter -> {
if (filter.state.isNotEmpty()) {
val tags = filter.state.split(",").filter(String::isNotBlank).joinToString(",")
if (tags.isNotBlank()) {
terms += "${filter.type}:" + if (filter.type == "pages") tags else "\"$tags\""
}
}
}
else -> {}
}
}
if (includedTags.isNotEmpty()) {
addQueryParameter("include", includedTags.joinToString(","))
}
if (excludedTags.isNotEmpty()) {
addQueryParameter("exclude", excludedTags.joinToString(","))
}
if (query.isNotEmpty()) terms.add("title:\"$query\"")
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
addQueryParameter("page", page.toString())
}.build()
return GET(url, lazyHeaders)
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
override fun getFilterList(): FilterList {
launchIO { fetchTags() }
return getFilters()
}
private val scope = CoroutineScope(Dispatchers.IO)
private fun launchIO(block: () -> Unit) = scope.launch { block() }
/**
* Fetch the genres from the source to be used in the filters.
*/
private fun fetchTags() {
if (tagsFetchAttempts < 3 && !tagsFetched) {
try {
client.newCall(
GET("$apiBooksUrl/tags/filters", lazyHeaders),
).execute()
.use { it.parseAs<List<Filter>>() }
.also {
tagsFetched = true
}
.takeIf { it.isNotEmpty() }
?.map { it.toTag() }
?.also { tags ->
genreList = tags.filterIsInstance<KoharuFilters.Genre>()
femaleList = tags.filterIsInstance<KoharuFilters.Female>()
maleList = tags.filterIsInstance<KoharuFilters.Male>()
artistList = tags.filterIsInstance<KoharuFilters.Artist>()
circleList = tags.filterIsInstance<KoharuFilters.Circle>()
parodyList = tags.filterIsInstance<KoharuFilters.Parody>()
mixedList = tags.filterIsInstance<KoharuFilters.Mixed>()
otherList = tags.filterIsInstance<KoharuFilters.Other>()
}
} catch (_: Exception) {
} finally {
tagsFetchAttempts++
}
}
}
// Details
override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$apiBooksUrl/detail/${manga.url}", lazyHeaders)
}
override fun mangaDetailsParse(response: Response): SManga {
val mangaDetail = response.parseAs<MangaDetail>()
return mangaDetail.toSManga().apply {
setUrlWithoutDomain("${mangaDetail.id}/${mangaDetail.key}")
title = if (remadd()) mangaDetail.title.shortenTitle() else mangaDetail.title
}
}
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
// Chapter
override fun chapterListRequest(manga: SManga): Request {
return GET("$apiBooksUrl/detail/${manga.url}", lazyHeaders)
}
override fun chapterListParse(response: Response): List<SChapter> {
val manga = response.parseAs<MangaDetail>()
return listOf(
SChapter.create().apply {
name = "Chapter"
url = "${manga.id}/${manga.key}"
date_upload = (manga.updated_at ?: manga.created_at)
},
)
}
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/g/${chapter.url}"
// Page List
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return interceptedClient.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
pageListParse(response)
}
}
override fun pageListRequest(chapter: SChapter): Request {
return POST("$apiBooksUrl/detail/${chapter.url}?crt=$token", lazyHeaders)
}
override fun pageListParse(response: Response): List<Page> {
val mangaData = response.parseAs<MangaData>()
val url = response.request.url.toString()
val matches = Regex("""/detail/(\d+)/([a-z\d]+)""").find(url)
if (matches == null || matches.groupValues.size < 3) return emptyList()
val imagesInfo = getImagesByMangaData(mangaData, matches.groupValues[1], matches.groupValues[2])
return imagesInfo.first.entries.mapIndexed { index, image ->
Page(index, imageUrl = "${imagesInfo.first.base}/${image.path}?w=${imagesInfo.second}")
}
}
override fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, lazyHeaders)
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
// Settings
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_IMAGERES
title = "Image Resolution"
entries = arrayOf("780x", "980x", "1280x", "1600x", "Original")
entryValues = arrayOf("780", "980", "1280", "1600", "0")
summary = "%s"
setDefaultValue("1280")
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_REM_ADD
title = "Remove additional information in title"
summary = "Remove anything in brackets from manga titles.\n" +
"Reload manga to apply changes to loaded manga."
setDefaultValue(false)
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
key = PREF_EXCLUDE_TAGS
title = "Tags to exclude from browse/search"
summary = "Separate tags with commas (,).\n" +
"Excluding: ${alwaysExcludeTags()}"
}.also(screen::addPreference)
}
private inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
companion object {
const val PREFIX_ID_KEY_SEARCH = "id:"
private const val PREF_IMAGERES = "pref_image_quality"
private const val PREF_REM_ADD = "pref_remove_additional"
private const val PREF_EXCLUDE_TAGS = "pref_exclude_tags"
internal var token: String? = null
internal var authorization: String? = null
internal val dateReformat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
}
}

View File

@ -1,83 +1,209 @@
package eu.kanade.tachiyomi.extension.all.koharu
package eu.kanade.tachiyomi.extension.all.koharu
import kotlinx.serialization.Serializable
@Serializable
class Tag(
var name: String,
var namespace: Int = 0,
)
@Serializable
class Books(
val entries: List<Entry> = emptyList(),
val total: Int = 0,
val limit: Int = 0,
val page: Int,
)
@Serializable
class Entry(
val id: Int,
val public_key: String,
val title: String,
val thumbnail: Thumbnail,
)
@Serializable
class MangaEntry(
val id: Int,
val title: String,
val public_key: String,
val created_at: Long = 0L,
val updated_at: Long?,
val thumbnails: Thumbnails,
val tags: List<Tag> = emptyList(),
val data: Data,
)
@Serializable
class Thumbnails(
val base: String,
val main: Thumbnail,
val entries: List<Thumbnail>,
)
@Serializable
class Thumbnail(
val path: String,
)
@Serializable
class Data(
val `0`: DataKey,
val `780`: DataKey? = null,
val `980`: DataKey? = null,
val `1280`: DataKey? = null,
val `1600`: DataKey? = null,
)
@Serializable
class DataKey(
val id: Int? = null,
val size: Double = 0.0,
val public_key: String? = null,
) {
fun readableSize() = when {
size >= 300 * 1000 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0 * 1000.0))} GB"
size >= 100 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0))} MB"
size >= 1000 -> "${"%.2f".format(size / (1000.0))} kB"
else -> "$size B"
}
}
@Serializable
class ImagesInfo(
val base: String,
val entries: List<ImagePath>,
)
@Serializable
class ImagePath(
val path: String,
)
import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.dateReformat
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable
import java.util.Locale
@Serializable
class Tag(
val name: String,
val namespace: Int = 0,
)
@Serializable
class Filter(
private val id: Int,
private val name: String,
private val namespace: Int = 0,
) {
fun toTag() = when (namespace) {
0 -> KoharuFilters.Genre(id, name)
1 -> KoharuFilters.Artist(id, name)
2 -> KoharuFilters.Circle(id, name)
3 -> KoharuFilters.Parody(id, name)
8 -> KoharuFilters.Male(id, name)
9 -> KoharuFilters.Female(id, name)
10 -> KoharuFilters.Mixed(id, name)
12 -> KoharuFilters.Other(id, name)
else -> KoharuFilters.Tag(id, name, namespace)
}
}
@Serializable
class Books(
val entries: List<Entry> = emptyList(),
val total: Int = 0,
val limit: Int = 0,
val page: Int,
)
@Serializable
class Entry(
val id: Int,
val key: String,
val title: String,
val thumbnail: Thumbnail,
)
@Serializable
class MangaDetail(
val id: Int,
val title: String,
val key: String,
val created_at: Long = 0L,
val updated_at: Long?,
val thumbnails: Thumbnails,
val tags: List<Tag> = emptyList(),
) {
fun toSManga() = SManga.create().apply {
val artists = mutableListOf<String>()
val circles = mutableListOf<String>()
val parodies = mutableListOf<String>()
val magazines = mutableListOf<String>()
val characters = mutableListOf<String>()
val cosplayers = mutableListOf<String>()
val females = mutableListOf<String>()
val males = mutableListOf<String>()
val mixed = mutableListOf<String>()
val language = mutableListOf<String>()
val other = mutableListOf<String>()
val uploaders = mutableListOf<String>()
val tags = mutableListOf<String>()
this@MangaDetail.tags.forEach { tag ->
when (tag.namespace) {
1 -> artists.add(tag.name)
2 -> circles.add(tag.name)
3 -> parodies.add(tag.name)
4 -> magazines.add(tag.name)
5 -> characters.add(tag.name)
6 -> cosplayers.add(tag.name)
7 -> tag.name.takeIf { it != "anonymous" }?.let { uploaders.add(it) }
8 -> males.add(tag.name + "")
9 -> females.add(tag.name + "")
10 -> mixed.add(tag.name)
11 -> language.add(tag.name)
12 -> other.add(tag.name)
else -> tags.add(tag.name)
}
}
var appended = false
fun List<String>.joinAndCapitalizeEach(): String? = this.emptyToNull()?.joinToString { it.capitalizeEach() }?.apply { appended = true }
thumbnail_url = thumbnails.base + thumbnails.main.path
author = (circles.emptyToNull() ?: artists).joinToString { it.capitalizeEach() }
artist = artists.joinToString { it.capitalizeEach() }
genre = (artists + circles + parodies + magazines + characters + cosplayers + tags + females + males + mixed + other).joinToString { it.capitalizeEach() }
description = buildString {
circles.joinAndCapitalizeEach()?.let {
append("Circles: ", it, "\n")
}
uploaders.joinAndCapitalizeEach()?.let {
append("Uploaders: ", it, "\n")
}
magazines.joinAndCapitalizeEach()?.let {
append("Magazines: ", it, "\n")
}
cosplayers.joinAndCapitalizeEach()?.let {
append("Cosplayers: ", it, "\n")
}
parodies.joinAndCapitalizeEach()?.let {
append("Parodies: ", it, "\n")
}
characters.joinAndCapitalizeEach()?.let {
append("Characters: ", it, "\n")
}
if (appended) append("\n")
try {
append("Posted: ", dateReformat.format(created_at), "\n")
} catch (_: Exception) {}
append("Pages: ", thumbnails.entries.size, "\n\n")
}
status = SManga.COMPLETED
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
initialized = true
}
private fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
s.replaceFirstChar { sr ->
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
}
}
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
return this.ifEmpty { null }
}
}
@Serializable
class MangaData(
val data: Data,
val similar: List<Entry> = emptyList(),
) {
/**
* Return human-readable size of chapter.
* @param quality The quality set in PREF_IMAGERES
*/
fun size(quality: String): String {
val dataKey = when (quality) {
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
"980" -> data.`980` ?: data.`1280` ?: data.`0`
"780" -> data.`780` ?: data.`980` ?: data.`0`
else -> data.`0`
}
return dataKey.readableSize()
}
}
@Serializable
class Thumbnails(
val base: String,
val main: Thumbnail,
val entries: List<Thumbnail>,
)
@Serializable
class Thumbnail(
val path: String,
)
@Serializable
class Data(
val `0`: DataKey,
val `780`: DataKey? = null,
val `980`: DataKey? = null,
val `1280`: DataKey? = null,
val `1600`: DataKey? = null,
)
@Serializable
class DataKey(
val id: Int? = null,
val size: Double = 0.0,
val key: String? = null,
) {
fun readableSize() = when {
size >= 300 * 1000 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0 * 1000.0))} GB"
size >= 100 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0))} MB"
size >= 1000 -> "${"%.2f".format(size / (1000.0))} kB"
else -> "$size B"
}
}
@Serializable
class ImagesInfo(
val base: String,
val entries: List<ImagePath>,
)
@Serializable
class ImagePath(
val path: String,
)

View File

@ -1,13 +1,13 @@
package eu.kanade.tachiyomi.extension.all.koharu
package eu.kanade.tachiyomi.extension.all.koharu
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class KoharuFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
Koharu(),
Koharu("en", "english"),
Koharu("ja", "japanese"),
Koharu("zh", "chinese"),
)
}
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class KoharuFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
Koharu(),
Koharu("en", "english"),
Koharu("ja", "japanese"),
Koharu("zh", "chinese"),
)
}

View File

@ -1,51 +1,129 @@
package eu.kanade.tachiyomi.extension.all.koharu
package eu.kanade.tachiyomi.extension.all.koharu
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
fun getFilters(): FilterList {
return FilterList(
SortFilter("Sort by", getSortsList),
CategoryFilter("Category"),
Filter.Separator(),
Filter.Header("Separate tags with commas (,)"),
Filter.Header("Prepend with dash (-) to exclude"),
TextFilter("Artists", "artist"),
TextFilter("Magazines", "magazine"),
TextFilter("Publishers", "publisher"),
TextFilter("Characters", "character"),
TextFilter("Cosplayers", "cosplayer"),
TextFilter("Parodies", "parody"),
TextFilter("Circles", "circle"),
TextFilter("Male Tags", "male"),
TextFilter("Female Tags", "female"),
TextFilter("Tags ( Universal )", "tag"),
Filter.Header("Filter by pages, for example: (>20)"),
TextFilter("Pages", "pages"),
)
}
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
internal open class SortFilter(name: String, private val vals: List<Pair<String, String>>, state: Int = 0) :
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
fun getValue() = vals[state].second
}
internal class CategoryFilter(name: String) :
Filter.Group<CheckBoxFilter>(
name,
listOf(
Pair("Manga", 2),
Pair("Doujinshi", 4),
Pair("Illustration", 8),
).map { CheckBoxFilter(it.first, it.second, true) },
)
internal open class CheckBoxFilter(name: String, val value: Int, state: Boolean) : Filter.CheckBox(name, state)
private val getSortsList: List<Pair<String, String>> = listOf(
Pair("Recently Posted", "4"),
Pair("Title", "2"),
Pair("Pages", "3"),
Pair("Most Viewed", "8"),
Pair("Most Favorited", "9"),
)
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
object KoharuFilters {
var genreList: List<Genre> = KoharuTags.genreList
var femaleList: List<Female> = KoharuTags.femaleList
var maleList: List<Male> = KoharuTags.maleList
var artistList: List<Artist> = KoharuTags.artistList
var circleList: List<Circle> = KoharuTags.circleList
var parodyList: List<Parody> = KoharuTags.parodyList
var mixedList: List<Mixed> = KoharuTags.mixedList
var otherList: List<Other> = KoharuTags.otherList
/**
* Whether tags have been fetched
*/
internal var tagsFetched: Boolean = false
/**
* Inner variable to control how much tries the tags request was called.
*/
internal var tagsFetchAttempts: Int = 0
fun getFilters(): FilterList {
return FilterList(
SortFilter("Sort by", getSortsList),
CategoryFilter("Category"),
Filter.Separator(),
TagFilter("Tags", genreList),
TagFilter("Female Tags", femaleList),
TagFilter("Male Tags", maleList),
TagFilter("Artists", artistList),
TagFilter("Circles", circleList),
TagFilter("Parodies", parodyList),
TagFilter("Mixed", mixedList),
TagFilter("Other", otherList),
GenreConditionFilter("Include condition", tagsConditionIncludeFilterOptions, "i"),
GenreConditionFilter("Exclude condition", tagsConditionExcludeFilterOptions, "e"),
Filter.Separator(),
Filter.Header("Separate tags with commas (,)"),
Filter.Header("Prepend with dash (-) to exclude"),
TextFilter("Magazines", "magazine"),
TextFilter("Publishers", "publisher"),
TextFilter("Characters", "character"),
TextFilter("Cosplayers", "cosplayer"),
Filter.Header("Filter by pages, for example: (>20)"),
TextFilter("Pages", "pages"),
)
}
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
internal open class SortFilter(
name: String,
private val vals: List<Pair<String, String>>,
state: Int = 0,
) :
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
fun getValue() = vals[state].second
}
internal class CategoryFilter(name: String) :
Filter.Group<CheckBoxFilter>(
name,
listOf(
Pair("Manga", 2),
Pair("Doujinshi", 4),
Pair("Illustration", 8),
).map { CheckBoxFilter(it.first, it.second, true) },
)
internal open class CheckBoxFilter(name: String, val value: Int, state: Boolean) :
Filter.CheckBox(name, state)
private val getSortsList: List<Pair<String, String>> = listOf(
Pair("Recently Posted", "4"),
Pair("Title", "2"),
Pair("Pages", "3"),
Pair("Most Viewed", "8"),
Pair("Most Favorited", "9"),
)
internal class GenreConditionFilter(
title: String,
options: List<Pair<String, String>>,
val param: String,
) : UriPartFilter(
title,
options.toTypedArray(),
)
open class Tag(val id: Int, val name: String, val namespace: Int)
class Genre(id: Int, name: String) : Tag(id, name, namespace = 0)
class Artist(id: Int, name: String) : Tag(id, name, namespace = 1)
class Circle(id: Int, name: String) : Tag(id, name, namespace = 2)
class Parody(id: Int, name: String) : Tag(id, name, namespace = 3)
class Male(id: Int, name: String) : Tag(id, name, namespace = 8)
class Female(id: Int, name: String) : Tag(id, name, namespace = 9)
class Mixed(id: Int, name: String) : Tag(id, name, namespace = 10)
class Other(id: Int, name: String) : Tag(id, name, namespace = 12)
internal class TagFilter(title: String, tags: List<Tag>) :
Filter.Group<TagTriState>(title, tags.map { TagTriState(it.name, it.id) })
internal class TagTriState(name: String, val id: Int) : Filter.TriState(name)
open class UriPartFilter(
displayName: String,
private val vals: Array<Pair<String, String>>,
state: Int = 0,
) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
fun toUriPart() = vals[state].second
}
// https://api.schale.network/books?include=<id>,<id>&i=1&exclude=<id>,<id>&e=1
private val tagsConditionIncludeFilterOptions: List<Pair<String, String>> =
listOf(
"AND" to "",
"OR" to "1",
)
private val tagsConditionExcludeFilterOptions: List<Pair<String, String>> =
listOf(
"OR" to "",
"AND" to "1",
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,242 @@
package eu.kanade.tachiyomi.extension.all.koharu
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.authorization
import eu.kanade.tachiyomi.extension.all.koharu.Koharu.Companion.token
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** Cloudflare Turnstile interceptor */
class TurnstileInterceptor(
private val client: OkHttpClient,
private val domainUrl: String,
private val authUrl: String,
private val userAgent: String?,
) : Interceptor {
private val context: Application by injectLazy()
private val handler by lazy { Handler(Looper.getMainLooper()) }
private val lazyHeaders by lazy {
Headers.Builder().apply {
set("User-Agent", userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36")
set("Referer", "$domainUrl/")
set("Origin", domainUrl)
}.build()
}
private fun authHeaders(authorization: String) =
Headers.Builder().apply {
set("User-Agent", userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36")
set("Referer", "$domainUrl/")
set("Origin", domainUrl)
set("Authorization", authorization)
}.build()
private val authorizedRequestRegex by lazy { Regex("""(.+\?crt=)(.*)""") }
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url.toString()
val matchResult = authorizedRequestRegex.find(url) ?: return chain.proceed(request)
if (matchResult.groupValues.size == 3) {
val requestingUrl = matchResult.groupValues[1]
val crt = matchResult.groupValues[2]
var newResponse: Response
if (crt.isNotBlank() && crt != "null") {
// Token already set in URL, just make the request
newResponse = chain.proceed(request)
if (newResponse.code !in listOf(400, 403)) return newResponse
} else {
// Token doesn't include, add token then make request
if (token.isNullOrBlank()) resolveInWebview()
val newRequest = if (request.method == "POST") {
POST("${requestingUrl}$token", lazyHeaders)
} else {
GET("${requestingUrl}$token", lazyHeaders)
}
newResponse = chain.proceed(newRequest)
if (newResponse.code !in listOf(400, 403)) return newResponse
}
newResponse.close()
// Request failed, refresh token then try again
clearToken()
token = null
resolveInWebview()
val newRequest = if (request.method == "POST") {
POST("${requestingUrl}$token", lazyHeaders)
} else {
GET("${requestingUrl}$token", lazyHeaders)
}
newResponse = chain.proceed(newRequest)
if (newResponse.code !in listOf(400, 403)) return newResponse
throw IOException("Open webview once to refresh token (${newResponse.code})")
}
return chain.proceed(request)
}
@SuppressLint("SetJavaScriptEnabled")
fun resolveInWebview(): Pair<String?, String?> {
val latch = CountDownLatch(1)
var webView: WebView? = null
handler.post {
val webview = WebView(context)
webView = webview
with(webview.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
useWideViewPort = false
loadWithOverviewMode = false
}
webview.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
val authHeader = request?.requestHeaders?.get("Authorization")
if (request?.url.toString().contains(authUrl) && authHeader != null) {
authorization = authHeader
if (request.method == "POST") {
// Authorize & requesting a new token.
// `authorization` here should be in format: Bearer <authorization>
try {
val noRedirectClient = client.newBuilder().followRedirects(false).build()
val authHeaders = authHeaders(authHeader)
val response = noRedirectClient.newCall(POST(authUrl, authHeaders)).execute()
response.use {
if (response.isSuccessful) {
with(response) {
token = body.string()
.removeSurrounding("\"")
}
}
}
} catch (_: IOException) {
} finally {
latch.countDown()
}
}
if (request.method == "GET") {
// Site is trying to recheck old token validation here.
// If it fails then site will request a new one using POST method.
// But we will check it ourselves.
// Normally this might not occur because old token should already be acquired & rechecked via onPageFinished.
// `authorization` here should be in format: Bearer <token>
val oldToken = authorization
?.substringAfterLast(" ")
if (oldToken != null && recheckTokenValid(oldToken)) {
token = oldToken
latch.countDown()
}
}
}
return super.shouldInterceptRequest(view, request)
}
/**
* Read the saved token in localStorage and use it.
* This token might already expired. Normally site will check token for expiration with a GET request.
* Here will will recheck it ourselves.
*/
override fun onPageFinished(view: WebView?, url: String?) {
if (view == null) return
val script = "javascript:localStorage['clearance']"
view.evaluateJavascript(script) {
// Avoid overwrite newly requested token
if (!it.isNullOrBlank() && it != "null" && token.isNullOrBlank()) {
val oldToken = it
.removeSurrounding("\"")
if (recheckTokenValid(oldToken)) {
token = oldToken
latch.countDown()
}
}
}
}
private fun recheckTokenValid(token: String): Boolean {
try {
val noRedirectClient = client.newBuilder().followRedirects(false).build()
val authHeaders = authHeaders("Bearer $token")
val response = noRedirectClient.newCall(GET(authUrl, authHeaders)).execute()
response.use {
if (response.isSuccessful) {
return true
}
}
} catch (_: IOException) {
}
return false
}
}
webview.loadUrl("$domainUrl/")
}
latch.await(20, TimeUnit.SECONDS)
handler.post {
// One last try to read the token from localStorage, in case it got updated last minute.
if (token.isNullOrBlank()) {
val script = "javascript:localStorage['clearance']"
webView?.evaluateJavascript(script) {
if (!it.isNullOrBlank() && it != "null") {
token = it
.removeSurrounding("\"")
}
}
}
webView?.stopLoading()
webView?.destroy()
webView = null
}
return token to authorization
}
@SuppressLint("SetJavaScriptEnabled")
private fun clearToken() {
val latch = CountDownLatch(1)
handler.post {
val webView = WebView(context)
with(webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (view == null) return
val script = "javascript:localStorage.clear()"
view.evaluateJavascript(script) {
token = null
view.stopLoading()
view.destroy()
latch.countDown()
}
}
}
webView.loadUrl(domainUrl)
}
latch.await(20, TimeUnit.SECONDS)
}
}