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

View File

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

View File

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