MangaReader.to: refactor, fixes and improvements (#13300)
* MangaReader.to: refactor, fixes and improvements * rewrite image descrambling interceptor * add changelog * use contains to check dupe number instead of endsWith * memoize calculation result instead of using lookup table * make SeedRandom private * add some line breaks to better align diffs * fix media type * use the same buffer to reduce memory allocations * Review changes and inline stuff * get rid of the hassle of calculating piece count
This commit is contained in:
parent
ab9984d3f4
commit
75605af7a9
|
@ -0,0 +1,31 @@
|
||||||
|
## 1.3.3
|
||||||
|
|
||||||
|
- Appended `.to` to extension name
|
||||||
|
- Replaced dependencies
|
||||||
|
- `android.net.Uri` → `okhttp3.HttpUrl`
|
||||||
|
- `org.json` → `kotlinx.serialization`
|
||||||
|
- Refactored some code to separate files
|
||||||
|
- Image quality preference: added prompt to summary and made it take effect without restart, fixes [#12504](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/12504)
|
||||||
|
- Added preference to show additional entries in volumes in list results and added code to support volumes, fixes [#12573](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/12573)
|
||||||
|
- Improved parsing
|
||||||
|
- Added code to parse authors and artists
|
||||||
|
- Improved chapter list parsing
|
||||||
|
- Other improvements
|
||||||
|
- Performance boosts in selectors
|
||||||
|
- Added French, Korean and Chinese languages
|
||||||
|
- Corrected filter note type (Text → Header)
|
||||||
|
- Rewrote image descrambler
|
||||||
|
- Used fragment in URL instead of appending error-prone query parameter, hopefully fixes [#12722](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/12722)
|
||||||
|
- Made interceptor singleton to be shared across languages
|
||||||
|
- Simplified code logic to make it a lot more readable, thanks to Vetle in [#9325 (comment)](https://github.com/tachiyomiorg/tachiyomi-extensions/pull/9325#issuecomment-1100950110) for code reference
|
||||||
|
- Used `javax.crypto.Cipher` for ARC4
|
||||||
|
- Memoize permutation result to reduce calculation
|
||||||
|
- Save as compressed JPG instead of PNG to avoid size bloat (original image is already compressed)
|
||||||
|
|
||||||
|
## 1.2.2
|
||||||
|
|
||||||
|
- Fixes filters causing manga list to fail to load.
|
||||||
|
|
||||||
|
## 1.2.1
|
||||||
|
|
||||||
|
- Builds on original PR and unscrambles the images.
|
|
@ -1,11 +1,12 @@
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
extName = 'MangaReader'
|
extName = 'MangaReader.to'
|
||||||
pkgNameSuffix = 'all.mangareaderto'
|
pkgNameSuffix = 'all.mangareaderto'
|
||||||
extClass = '.MangaReaderFactory'
|
extClass = '.MangaReaderFactory'
|
||||||
extVersionCode = 2
|
extVersionCode = 3
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,26 @@
|
||||||
package eu.kanade.tachiyomi.extension.all.mangareaderto
|
package eu.kanade.tachiyomi.extension.all.mangareaderto
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
|
||||||
import androidx.preference.ListPreference
|
|
||||||
import androidx.preference.PreferenceScreen
|
import androidx.preference.PreferenceScreen
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
|
||||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
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.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import kotlinx.serialization.json.Json
|
||||||
import org.json.JSONObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
|
import org.jsoup.nodes.TextNode
|
||||||
|
import org.jsoup.select.Evaluator
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
@ -30,9 +34,29 @@ open class MangaReader(
|
||||||
override val supportsLatest = true
|
override val supportsLatest = true
|
||||||
|
|
||||||
override val client = network.client.newBuilder()
|
override val client = network.client.newBuilder()
|
||||||
.addInterceptor(MangaReaderImageInterceptor())
|
.addInterceptor(MangaReaderImageInterceptor)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
private fun MangasPage.insertVolumeEntries(): MangasPage {
|
||||||
|
if (preferences.showVolume.not()) return this
|
||||||
|
val list = mangas.ifEmpty { return this }
|
||||||
|
val newList = ArrayList<SManga>(list.size * 2)
|
||||||
|
for (manga in list) {
|
||||||
|
val volume = SManga.create().apply {
|
||||||
|
url = manga.url + VOLUME_URL_SUFFIX
|
||||||
|
title = VOLUME_TITLE_PREFIX + manga.title
|
||||||
|
thumbnail_url = manga.thumbnail_url
|
||||||
|
}
|
||||||
|
newList.add(manga)
|
||||||
|
newList.add(volume)
|
||||||
|
}
|
||||||
|
return MangasPage(newList, hasNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response) = super.latestUpdatesParse(response).insertVolumeEntries()
|
||||||
|
override fun popularMangaParse(response: Response) = super.popularMangaParse(response).insertVolumeEntries()
|
||||||
|
override fun searchMangaParse(response: Response) = super.searchMangaParse(response).insertVolumeEntries()
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int) =
|
override fun latestUpdatesRequest(page: Int) =
|
||||||
GET("$baseUrl/filter?sort=latest-updated&language=$lang&page=$page", headers)
|
GET("$baseUrl/filter?sort=latest-updated&language=$lang&page=$page", headers)
|
||||||
|
|
||||||
|
@ -53,37 +77,37 @@ open class MangaReader(
|
||||||
override fun popularMangaFromElement(element: Element) =
|
override fun popularMangaFromElement(element: Element) =
|
||||||
searchMangaFromElement(element)
|
searchMangaFromElement(element)
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val urlBuilder = baseUrl.toHttpUrl().newBuilder()
|
||||||
if (query.isNotBlank()) {
|
if (query.isNotBlank()) {
|
||||||
Uri.parse("$baseUrl/search").buildUpon().run {
|
urlBuilder.addPathSegment("search").apply {
|
||||||
appendQueryParameter("keyword", query)
|
addQueryParameter("keyword", query)
|
||||||
appendQueryParameter("page", page.toString())
|
addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
GET(toString(), headers)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Uri.parse("$baseUrl/filter").buildUpon().run {
|
urlBuilder.addPathSegment("filter").apply {
|
||||||
appendQueryParameter("language", lang)
|
addQueryParameter("language", lang)
|
||||||
appendQueryParameter("page", page.toString())
|
addQueryParameter("page", page.toString())
|
||||||
filters.ifEmpty(::getFilterList).forEach { filter ->
|
filters.ifEmpty(::getFilterList).forEach { filter ->
|
||||||
when (filter) {
|
when (filter) {
|
||||||
is Select -> {
|
is Select -> {
|
||||||
appendQueryParameter(filter.param, filter.selection)
|
addQueryParameter(filter.param, filter.selection)
|
||||||
}
|
}
|
||||||
is DateFilter -> {
|
is DateFilter -> {
|
||||||
filter.state.forEach {
|
filter.state.forEach {
|
||||||
appendQueryParameter(it.param, it.selection)
|
addQueryParameter(it.param, it.selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is GenresFilter -> {
|
is GenresFilter -> {
|
||||||
appendQueryParameter(filter.param, filter.selection)
|
addQueryParameter(filter.param, filter.selection)
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GET(toString(), headers)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Request.Builder().url(urlBuilder.build()).headers(headers).build()
|
||||||
|
}
|
||||||
|
|
||||||
override fun searchMangaSelector() = ".manga_list-sbs .manga-poster"
|
override fun searchMangaSelector() = ".manga_list-sbs .manga-poster"
|
||||||
|
|
||||||
|
@ -92,66 +116,115 @@ open class MangaReader(
|
||||||
override fun searchMangaFromElement(element: Element) =
|
override fun searchMangaFromElement(element: Element) =
|
||||||
SManga.create().apply {
|
SManga.create().apply {
|
||||||
url = element.attr("href")
|
url = element.attr("href")
|
||||||
element.selectFirst(".manga-poster-img").let {
|
element.selectFirst(Evaluator.Tag("img")).let {
|
||||||
title = it.attr("alt")
|
title = it.attr("alt")
|
||||||
thumbnail_url = it.attr("src")
|
thumbnail_url = it.attr("src")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val authorSelector = ".item-head:containsOwn(Authors) ~ a"
|
private fun Element.parseAuthorsTo(manga: SManga) {
|
||||||
|
val authors = select(Evaluator.Tag("a"))
|
||||||
private val statusSelector = ".item-head:containsOwn(Status) + .name"
|
val text = authors.map { it.ownText().replace(",", "") }
|
||||||
|
val count = authors.size
|
||||||
override fun mangaDetailsParse(document: Document) =
|
when (count) {
|
||||||
SManga.create().apply {
|
0 -> return
|
||||||
setUrlWithoutDomain(document.location())
|
1 -> {
|
||||||
document.getElementById("ani_detail").let { el ->
|
manga.author = text[0]
|
||||||
title = el.selectFirst(".manga-name").text().trim()
|
return
|
||||||
description = el.selectFirst(".description")?.text()?.trim()
|
|
||||||
thumbnail_url = el.selectFirst(".manga-poster-img").attr("src")
|
|
||||||
genre = el.select(".genres > a")?.joinToString { it.text() }
|
|
||||||
author = el.select(authorSelector)?.joinToString {
|
|
||||||
it.text().replace(",", "")
|
|
||||||
}
|
}
|
||||||
artist = author // TODO: separate authors and artists
|
}
|
||||||
status = when (el.selectFirst(statusSelector)?.text()) {
|
val nodes = childNodes()
|
||||||
|
val authorList = ArrayList<String>(count)
|
||||||
|
val artistList = ArrayList<String>(count)
|
||||||
|
for ((index, author) in authors.withIndex()) {
|
||||||
|
val nodeIndex = nodes.indexOf(author)
|
||||||
|
val textNode = nodes.getOrNull(nodeIndex + 1) as? TextNode
|
||||||
|
val list = if (textNode != null && "(Art)" in textNode.wholeText) artistList else authorList
|
||||||
|
list.add(text[index])
|
||||||
|
}
|
||||||
|
if (authorList.isEmpty().not()) manga.author = authorList.joinToString()
|
||||||
|
if (artistList.isEmpty().not()) manga.artist = artistList.joinToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||||
|
url = document.location().removePrefix(baseUrl)
|
||||||
|
val root = document.selectFirst(Evaluator.Id("ani_detail"))
|
||||||
|
val mangaTitle = root.selectFirst(Evaluator.Tag("h2")).ownText()
|
||||||
|
title = if (url.endsWith(VOLUME_URL_SUFFIX)) VOLUME_TITLE_PREFIX + mangaTitle else mangaTitle
|
||||||
|
description = root.run {
|
||||||
|
val description = selectFirst(Evaluator.Class("description")).ownText()
|
||||||
|
when (val altTitle = selectFirst(Evaluator.Class("manga-name-or")).ownText()) {
|
||||||
|
"", mangaTitle -> description
|
||||||
|
else -> "$description\n\nAlternative Title: $altTitle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thumbnail_url = root.selectFirst(Evaluator.Tag("img")).attr("src")
|
||||||
|
genre = root.selectFirst(Evaluator.Class("genres")).children().joinToString { it.ownText() }
|
||||||
|
for (item in root.selectFirst(Evaluator.Class("anisc-info")).children()) {
|
||||||
|
if (item.hasClass("item").not()) continue
|
||||||
|
when (item.selectFirst(Evaluator.Class("item-head")).ownText()) {
|
||||||
|
"Authors:" -> item.parseAuthorsTo(this)
|
||||||
|
"Status:" -> status = when (item.selectFirst(Evaluator.Class("name")).ownText()) {
|
||||||
"Finished" -> SManga.COMPLETED
|
"Finished" -> SManga.COMPLETED
|
||||||
"Publishing" -> SManga.ONGOING
|
"Publishing" -> SManga.ONGOING
|
||||||
else -> SManga.UNKNOWN
|
else -> SManga.UNKNOWN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
val url = manga.url
|
||||||
|
val id = url.removeSuffix(VOLUME_URL_SUFFIX).substringAfterLast('-')
|
||||||
|
val type = if (url.endsWith(VOLUME_URL_SUFFIX)) "vol" else "chap"
|
||||||
|
return GET("$baseUrl/ajax/manga/reading-list/$id?readingBy=$type", headers)
|
||||||
|
}
|
||||||
|
|
||||||
override fun chapterListSelector() = "#$lang-chapters .item"
|
override fun chapterListSelector() = "#$lang-chapters .item"
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val isVolume = response.request.url.queryParameter("readingBy") == "vol"
|
||||||
|
val container = response.parseHtmlProperty().run {
|
||||||
|
val type = if (isVolume) "volumes" else "chapters"
|
||||||
|
selectFirst(Evaluator.Id("$lang-$type")) ?: return emptyList()
|
||||||
|
}
|
||||||
|
val abbrPrefix = if (isVolume) "Vol" else "Chap"
|
||||||
|
val fullPrefix = if (isVolume) "Volume" else "Chapter"
|
||||||
|
return container.children().map { chapterFromElement(it, abbrPrefix, fullPrefix) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element) =
|
override fun chapterFromElement(element: Element) =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
private fun chapterFromElement(element: Element, abbrPrefix: String, fullPrefix: String) =
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
chapter_number = element.attr("data-number").toFloatOrNull() ?: -1f
|
val number = element.attr("data-number")
|
||||||
element.selectFirst(".item-link").let {
|
chapter_number = number.toFloatOrNull() ?: -1f
|
||||||
|
element.selectFirst(Evaluator.Tag("a")).let {
|
||||||
url = it.attr("href")
|
url = it.attr("href")
|
||||||
name = it.attr("title")
|
name = run {
|
||||||
|
val name = it.attr("title")
|
||||||
|
val prefix = "$abbrPrefix $number: "
|
||||||
|
if (name.startsWith(prefix).not()) return@run name
|
||||||
|
val realName = name.removePrefix(prefix)
|
||||||
|
if (realName.contains(number)) realName else "$fullPrefix $number: $realName"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pageListRequest(id: String) =
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
GET("$baseUrl/ajax/image/list/chap/$id?quality=$quality", headers)
|
val ajaxUrl = document.selectFirst(Evaluator.Id("wrapper")).run {
|
||||||
|
val readingBy = attr("data-reading-by")
|
||||||
override fun fetchPageList(chapter: SChapter) =
|
val readingId = attr("data-reading-id")
|
||||||
client.newCall(pageListRequest(chapter)).asObservableSuccess().map { res ->
|
"$baseUrl/ajax/image/list/$readingBy/$readingId?quality=${preferences.quality}"
|
||||||
res.asJsoup().getElementById("wrapper").attr("data-reading-id").let {
|
|
||||||
val call = client.newCall(pageListRequest(it))
|
|
||||||
val json = JSONObject(call.execute().body!!.string())
|
|
||||||
pageListParse(Jsoup.parse(json.getString("html")))
|
|
||||||
}
|
}
|
||||||
}!!
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> =
|
val pageDocument = client.newCall(GET(ajaxUrl, headers)).execute().parseHtmlProperty()
|
||||||
document.getElementsByClass("iv-card").mapIndexed { idx, img ->
|
|
||||||
|
return pageDocument.getElementsByClass("iv-card").mapIndexed { index, img ->
|
||||||
val url = img.attr("data-url")
|
val url = img.attr("data-url")
|
||||||
if (img.hasClass("shuffled")) {
|
val imageUrl = if (img.hasClass("shuffled")) "$url#${MangaReaderImageInterceptor.SCRAMBLED}" else url
|
||||||
Page(idx, "", "$url&shuffled=true")
|
Page(index, imageUrl = imageUrl)
|
||||||
} else {
|
|
||||||
Page(idx, "", url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,23 +235,8 @@ open class MangaReader(
|
||||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
|
||||||
}
|
}
|
||||||
|
|
||||||
private val quality by lazy {
|
|
||||||
preferences.getString("quality", "medium")!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
ListPreference(screen.context).apply {
|
getPreferences(screen.context).forEach(screen::addPreference)
|
||||||
key = "quality"
|
|
||||||
title = "Quality"
|
|
||||||
summary = "%s"
|
|
||||||
entries = arrayOf("Low", "Medium", "High")
|
|
||||||
entryValues = arrayOf("low", "medium", "high")
|
|
||||||
setDefaultValue("medium")
|
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
|
||||||
preferences.edit().putString("quality", newValue as String).commit()
|
|
||||||
}
|
|
||||||
}.let(screen::addPreference)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getFilterList() =
|
override fun getFilterList() =
|
||||||
|
@ -193,4 +251,9 @@ open class MangaReader(
|
||||||
SortFilter(),
|
SortFilter(),
|
||||||
GenresFilter()
|
GenresFilter()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun Response.parseHtmlProperty(): Document {
|
||||||
|
val html = Json.parseToJsonElement(body!!.string()).jsonObject["html"]!!.jsonPrimitive.content
|
||||||
|
return Jsoup.parseBodyFragment(html)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,5 +4,5 @@ import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
|
||||||
class MangaReaderFactory : SourceFactory {
|
class MangaReaderFactory : SourceFactory {
|
||||||
override fun createSources() =
|
override fun createSources() =
|
||||||
listOf(MangaReader("en"), MangaReader("ja"))
|
arrayOf("en", "fr", "ja", "ko", "zh").map(::MangaReader)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.extension.all.mangareaderto
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
object Note : Filter.Text("NOTE: Ignored if using text search!")
|
object Note : Filter.Header("NOTE: Ignored if using text search!")
|
||||||
|
|
||||||
sealed class Select(
|
sealed class Select(
|
||||||
name: String,
|
name: String,
|
||||||
|
|
|
@ -5,188 +5,122 @@ import android.graphics.BitmapFactory
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import kotlin.math.ceil
|
import javax.crypto.Cipher
|
||||||
import kotlin.math.floor
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
class MangaReaderImageInterceptor : Interceptor {
|
object MangaReaderImageInterceptor : Interceptor {
|
||||||
|
|
||||||
private var s = IntArray(256)
|
private val memo = hashMapOf<Int, IntArray>()
|
||||||
private var arc4i = 0
|
|
||||||
private var arc4j = 0
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val response = chain.proceed(chain.request())
|
val request = chain.request()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
// shuffled page requests should have shuffled=true query parameter
|
val url = request.url
|
||||||
if (chain.request().url.queryParameter("shuffled") != "true")
|
// TODO: remove the query parameter check (legacy) in later versions
|
||||||
return response
|
if (url.fragment != SCRAMBLED && url.queryParameter("shuffled") == null) return response
|
||||||
|
|
||||||
val image = unscrambleImage(response.body!!.byteStream())
|
val image = descramble(response.body!!.byteStream())
|
||||||
val body = image.toResponseBody("image/png".toMediaTypeOrNull())
|
val body = image.toResponseBody("image/jpeg".toMediaType())
|
||||||
return response.newBuilder()
|
return response.newBuilder()
|
||||||
.body(body)
|
.body(body)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unscrambleImage(image: InputStream): ByteArray {
|
private fun descramble(image: InputStream): ByteArray {
|
||||||
// obfuscated code (imgReverser function): https://mangareader.to/js/read.min.js
|
// obfuscated code (imgReverser function): https://mangareader.to/js/read.min.js
|
||||||
// essentially, it shuffles arrays of the image slices using the key 'stay'
|
// essentially, it shuffles arrays of the image slices using the key 'stay'
|
||||||
|
|
||||||
val bitmap = BitmapFactory.decodeStream(image)
|
val bitmap = BitmapFactory.decodeStream(image)
|
||||||
|
val width = bitmap.width
|
||||||
|
val height = bitmap.height
|
||||||
|
|
||||||
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
|
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(result)
|
val canvas = Canvas(result)
|
||||||
|
|
||||||
val horizontalParts = ceil(bitmap.width / SLICE_SIZE.toDouble()).toInt()
|
val pieces = ArrayList<Piece>()
|
||||||
val totalParts = horizontalParts * ceil(bitmap.height / SLICE_SIZE.toDouble()).toInt()
|
for (y in 0 until height step PIECE_SIZE) {
|
||||||
|
for (x in 0 until width step PIECE_SIZE) {
|
||||||
// calculate slices
|
val w = min(PIECE_SIZE, width - x)
|
||||||
val slices: HashMap<Int, MutableList<Rect>> = hashMapOf()
|
val h = min(PIECE_SIZE, height - y)
|
||||||
|
pieces.add(Piece(x, y, w, h))
|
||||||
for (i in 0 until totalParts) {
|
|
||||||
val row = floor(i / horizontalParts.toDouble()).toInt()
|
|
||||||
|
|
||||||
val x = (i - row * horizontalParts) * SLICE_SIZE
|
|
||||||
val y = row * SLICE_SIZE
|
|
||||||
val width = if (x + SLICE_SIZE <= bitmap.width) SLICE_SIZE else bitmap.width - x
|
|
||||||
val height = if (y + SLICE_SIZE <= bitmap.height) SLICE_SIZE else bitmap.height - y
|
|
||||||
|
|
||||||
val srcRect = Rect(x, y, width, height)
|
|
||||||
val key = width - height
|
|
||||||
if (!slices.containsKey(key)) {
|
|
||||||
slices[key] = mutableListOf()
|
|
||||||
}
|
}
|
||||||
slices[key]?.add(srcRect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle groups of slices
|
val groups = pieces.groupBy { it.w shl 16 or it.h }
|
||||||
for (sliceEntry in slices) {
|
|
||||||
// reset random number generator for every un-shuffle
|
|
||||||
resetRng()
|
|
||||||
|
|
||||||
val currentSlices = sliceEntry.value
|
for (group in groups.values) {
|
||||||
val sliceCount = currentSlices.count()
|
val size = group.size
|
||||||
|
|
||||||
// un-shuffle slice indices
|
val permutation = memo.getOrPut(size) {
|
||||||
val orderedSlices = IntArray(sliceCount)
|
// The key is actually "stay", but it's padded here in case the code is run in
|
||||||
val keys = MutableList(sliceCount) { it }
|
// Oracle's JDK, where RC4 key is required to be at least 5 bytes
|
||||||
|
val random = SeedRandom("staystay")
|
||||||
|
|
||||||
for (i in currentSlices.indices) {
|
// https://github.com/webcaetano/shuffle-seed
|
||||||
val r = floor(prng() * keys.count()).toInt()
|
val indices = (0 until size).toMutableList()
|
||||||
val g = keys[r]
|
IntArray(size) { indices.removeAt((random.nextDouble() * indices.size).toInt()) }
|
||||||
keys.removeAt(r)
|
|
||||||
orderedSlices[g] = i
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw slices
|
for ((i, original) in permutation.withIndex()) {
|
||||||
val cols = getColumnCount(currentSlices)
|
val src = group[i]
|
||||||
|
val dst = group[original]
|
||||||
|
|
||||||
val groupX = currentSlices[0].left
|
val srcRect = Rect(src.x, src.y, src.x + src.w, src.y + src.h)
|
||||||
val groupY = currentSlices[0].top
|
val dstRect = Rect(dst.x, dst.y, dst.x + dst.w, dst.y + dst.h)
|
||||||
|
|
||||||
for ((i, orderedIndex) in orderedSlices.withIndex()) {
|
|
||||||
val slice = currentSlices[i]
|
|
||||||
|
|
||||||
val row = floor((orderedIndex / cols).toDouble()).toInt()
|
|
||||||
val col = orderedIndex - row * cols
|
|
||||||
|
|
||||||
val width = slice.right
|
|
||||||
val height = slice.bottom
|
|
||||||
|
|
||||||
val x = groupX + col * width
|
|
||||||
val y = groupY + row * height
|
|
||||||
|
|
||||||
val srcRect = Rect(x, y, x + width, y + height)
|
|
||||||
val dstRect = Rect(
|
|
||||||
slice.left,
|
|
||||||
slice.top,
|
|
||||||
slice.left + width,
|
|
||||||
slice.top + height
|
|
||||||
)
|
|
||||||
|
|
||||||
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
|
canvas.drawBitmap(bitmap, srcRect, dstRect, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
result.compress(Bitmap.CompressFormat.PNG, 100, output)
|
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||||
return output.toByteArray()
|
return output.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getColumnCount(slices: List<Rect>): Int {
|
private class Piece(val x: Int, val y: Int, val w: Int, val h: Int)
|
||||||
if (slices.count() == 1) return 1
|
|
||||||
var t: Int? = null
|
// https://github.com/davidbau/seedrandom
|
||||||
for (i in slices.indices) {
|
private class SeedRandom(key: String) {
|
||||||
if (t == null) t = slices[i].top
|
private val input = ByteArray(RC4_WIDTH)
|
||||||
if (t != slices[i].top) {
|
private val buffer = ByteArray(RC4_WIDTH)
|
||||||
return i
|
private var pos = RC4_WIDTH
|
||||||
}
|
|
||||||
}
|
private val rc4 = Cipher.getInstance("RC4").apply {
|
||||||
return slices.count()
|
init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.toByteArray(), "RC4"))
|
||||||
|
update(input, 0, RC4_WIDTH, buffer) // RC4-drop[256]
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetRng() {
|
fun nextDouble(): Double {
|
||||||
arc4i = 0
|
var num = nextByte()
|
||||||
arc4j = 0
|
var exp = 8
|
||||||
initializeS()
|
while (num < 1L shl 52) {
|
||||||
arc4(256) // RC4-drop[256]
|
num = num shl 8 or nextByte()
|
||||||
|
exp += 8
|
||||||
|
}
|
||||||
|
while (num >= 1L shl 53) {
|
||||||
|
num = num ushr 1
|
||||||
|
exp--
|
||||||
|
}
|
||||||
|
return Math.scalb(num.toDouble(), -exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeS() {
|
private fun nextByte(): Long {
|
||||||
val t = IntArray(256)
|
if (pos == RC4_WIDTH) {
|
||||||
for (i in 0..255) {
|
rc4.update(input, 0, RC4_WIDTH, buffer)
|
||||||
s[i] = i
|
pos = 0
|
||||||
t[i] = KEY[i % KEY.size]
|
|
||||||
}
|
}
|
||||||
var j = 0
|
return buffer[pos++].toLong() and 0xFF
|
||||||
var tmp: Int
|
|
||||||
for (i in 0..255) {
|
|
||||||
j = (j + s[i] + t[i]) and 0xFF
|
|
||||||
tmp = s[j]
|
|
||||||
s[j] = s[i]
|
|
||||||
s[i] = tmp
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun prng(): Double {
|
private const val RC4_WIDTH = 256
|
||||||
var n = arc4(6)
|
private const val PIECE_SIZE = 200
|
||||||
var d = 281474976710656.0 // 256^6 (start with 6 chunks in n)
|
const val SCRAMBLED = "scrambled"
|
||||||
var x = 0L
|
|
||||||
while (n < 4503599627370496) { // 2^52 (52 significant digits in a double)
|
|
||||||
n = (n + x) * 256
|
|
||||||
d *= 256
|
|
||||||
x = arc4(1)
|
|
||||||
if (n < 0) break // overflow
|
|
||||||
}
|
|
||||||
return (n + x) / d
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun arc4(count: Int): Long {
|
|
||||||
var t: Int
|
|
||||||
var tmp: Int
|
|
||||||
var r: Long = 0
|
|
||||||
|
|
||||||
repeat(count) {
|
|
||||||
arc4i = (arc4i + 1) and 0xFF
|
|
||||||
arc4j = (arc4j + s[arc4i]) and 0xFF
|
|
||||||
tmp = s[arc4j]
|
|
||||||
s[arc4j] = s[arc4i]
|
|
||||||
s[arc4i] = tmp
|
|
||||||
t = (s[arc4i] + s[arc4j]) and 0xFF
|
|
||||||
|
|
||||||
r = r * 256 + s[t]
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val KEY = "stay".map { it.toByte().toInt() }
|
|
||||||
private const val SLICE_SIZE = 200
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.mangareaderto
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
|
||||||
|
fun getPreferences(context: Context) = arrayOf(
|
||||||
|
|
||||||
|
ListPreference(context).apply {
|
||||||
|
key = QUALITY_PREF
|
||||||
|
title = "Image quality"
|
||||||
|
summary = "Selected: %s\n" +
|
||||||
|
"Changes will not be applied to chapters that are already loaded or read " +
|
||||||
|
"until you clear the chapter cache."
|
||||||
|
entries = arrayOf("Low", "Medium", "High")
|
||||||
|
entryValues = arrayOf("low", QUALITY_MEDIUM, "high")
|
||||||
|
setDefaultValue(QUALITY_MEDIUM)
|
||||||
|
},
|
||||||
|
|
||||||
|
SwitchPreferenceCompat(context).apply {
|
||||||
|
key = SHOW_VOLUME_PREF
|
||||||
|
title = "Show manga in volumes in search result"
|
||||||
|
setDefaultValue(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val SharedPreferences.quality
|
||||||
|
get() =
|
||||||
|
getString(QUALITY_PREF, QUALITY_MEDIUM)!!
|
||||||
|
|
||||||
|
val SharedPreferences.showVolume
|
||||||
|
get() =
|
||||||
|
getBoolean(SHOW_VOLUME_PREF, false)
|
||||||
|
|
||||||
|
private const val QUALITY_PREF = "quality"
|
||||||
|
private const val QUALITY_MEDIUM = "medium"
|
||||||
|
private const val SHOW_VOLUME_PREF = "show_volume"
|
||||||
|
|
||||||
|
const val VOLUME_URL_SUFFIX = "#vol"
|
||||||
|
const val VOLUME_TITLE_PREFIX = "[VOL] "
|
Loading…
Reference in New Issue