Add kagane (#10599)
* Add kagane * Small code cleanup * Make sure nsfw cookie is always added * Add interceptor to automatically refresh token * Small code cleanup pt. 2
This commit is contained in:
parent
c47e2c1024
commit
2d450add12
8
src/en/kagane/build.gradle
Normal file
8
src/en/kagane/build.gradle
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'Kagane'
|
||||||
|
extClass = '.Kagane'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
BIN
src/en/kagane/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/en/kagane/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
BIN
src/en/kagane/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/en/kagane/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
BIN
src/en/kagane/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/en/kagane/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.2 KiB |
BIN
src/en/kagane/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/en/kagane/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
src/en/kagane/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/en/kagane/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
103
src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt
Normal file
103
src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kagane
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BookDto(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val source: String,
|
||||||
|
val metadata: MetadataDto,
|
||||||
|
val booksMetadata: BooksMetadataDto,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
class MetadataDto(
|
||||||
|
val genres: List<String>,
|
||||||
|
val status: String,
|
||||||
|
val summary: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BooksMetadataDto(
|
||||||
|
val authors: List<AuthorDto>,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
class AuthorDto(
|
||||||
|
val name: String,
|
||||||
|
val role: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toSManga(domain: String): SManga = SManga.create().apply {
|
||||||
|
title = name
|
||||||
|
url = "/series/$id"
|
||||||
|
description = buildString {
|
||||||
|
append(metadata.summary)
|
||||||
|
append("\n\n")
|
||||||
|
append("Source: ")
|
||||||
|
append(source)
|
||||||
|
}
|
||||||
|
thumbnail_url = "https://api.$domain/api/v1/series/$id/thumbnail"
|
||||||
|
author = getRoles(listOf("writer"))
|
||||||
|
artist = getRoles(listOf("inker", "colorist", "penciller"))
|
||||||
|
genre = metadata.genres.joinToString()
|
||||||
|
status = metadata.status.toStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toStatus(): Int {
|
||||||
|
return when (this) {
|
||||||
|
"ONGOING" -> SManga.ONGOING
|
||||||
|
"ENDED" -> SManga.COMPLETED
|
||||||
|
else -> SManga.COMPLETED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRoles(roles: List<String>): String {
|
||||||
|
return booksMetadata.authors
|
||||||
|
.filter { roles.contains(it.role) }
|
||||||
|
.joinToString { it.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterDto(
|
||||||
|
val id: String,
|
||||||
|
val metadata: MetadataDto,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
class MetadataDto(
|
||||||
|
val releaseDate: String? = null,
|
||||||
|
val title: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toSChapter(seriesId: String): SChapter = SChapter.create().apply {
|
||||||
|
url = "$seriesId;$id"
|
||||||
|
name = metadata.title
|
||||||
|
date_upload = dateFormat.tryParse(metadata.releaseDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val dateFormat by lazy {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChallengeDto(
|
||||||
|
@SerialName("access_token")
|
||||||
|
val accessToken: String,
|
||||||
|
@SerialName("page_count")
|
||||||
|
val pageCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class PaginationDto(
|
||||||
|
val hasNext: Boolean,
|
||||||
|
)
|
@ -0,0 +1,199 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kagane
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Protocol
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import okio.IOException
|
||||||
|
import java.math.BigInteger
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
class ImageInterceptor : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val url = chain.request().url
|
||||||
|
return if (url.queryParameterNames.contains("token")) {
|
||||||
|
val seriesId = url.pathSegments[3]
|
||||||
|
val chapterId = url.pathSegments[5]
|
||||||
|
val index = url.pathSegments.last().toInt()
|
||||||
|
|
||||||
|
val imageResp = chain.proceed(chain.request())
|
||||||
|
val imageBytes = imageResp.body.bytes()
|
||||||
|
val decrypted = decryptImage(imageBytes, seriesId, chapterId)
|
||||||
|
?: throw IOException("Unable to decrypt data")
|
||||||
|
val unscrambled = processData(decrypted, index, seriesId, chapterId)
|
||||||
|
?: throw IOException("Unable to unscramble data")
|
||||||
|
|
||||||
|
Response.Builder().body(unscrambled.toResponseBody())
|
||||||
|
.request(chain.request())
|
||||||
|
.protocol(Protocol.HTTP_1_0)
|
||||||
|
.code(200)
|
||||||
|
.message("")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
chain.proceed(chain.request())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WordArray(val words: IntArray, val sigBytes: Int)
|
||||||
|
|
||||||
|
private fun wordArrayToBytes(e: WordArray): ByteArray {
|
||||||
|
val result = ByteArray(e.sigBytes)
|
||||||
|
for (i in 0 until e.sigBytes) {
|
||||||
|
val word = e.words[i ushr 2]
|
||||||
|
val shift = 24 - (i % 4) * 8
|
||||||
|
result[i] = ((word ushr shift) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aesGcmDecrypt(keyWordArray: WordArray, ivWordArray: WordArray, cipherWordArray: WordArray): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val keyBytes = wordArrayToBytes(keyWordArray)
|
||||||
|
val iv = wordArrayToBytes(ivWordArray)
|
||||||
|
val cipherBytes = wordArrayToBytes(cipherWordArray)
|
||||||
|
|
||||||
|
val secretKey: SecretKey = SecretKeySpec(keyBytes, "AES")
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
val spec = GCMParameterSpec(128, iv)
|
||||||
|
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||||
|
cipher.doFinal(cipherBytes)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toWordArray(bytes: ByteArray): WordArray {
|
||||||
|
val words = IntArray((bytes.size + 3) / 4)
|
||||||
|
for (i in bytes.indices) {
|
||||||
|
val wordIndex = i / 4
|
||||||
|
val shift = 24 - (i % 4) * 8
|
||||||
|
words[wordIndex] = words[wordIndex] or ((bytes[i].toInt() and 0xFF) shl shift)
|
||||||
|
}
|
||||||
|
return WordArray(words, bytes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptImage(payload: ByteArray, keyPart1: String, keyPart2: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
if (payload.size < 140) return null
|
||||||
|
|
||||||
|
val iv = payload.sliceArray(128 until 140)
|
||||||
|
val ciphertext = payload.sliceArray(140 until payload.size)
|
||||||
|
|
||||||
|
val keyHash = "$keyPart1:$keyPart2".sha256()
|
||||||
|
|
||||||
|
val keyWA = toWordArray(keyHash)
|
||||||
|
val ivWA = toWordArray(iv)
|
||||||
|
val cipherWA = toWordArray(ciphertext)
|
||||||
|
|
||||||
|
aesGcmDecrypt(keyWA, ivWA, cipherWA)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processData(input: ByteArray, index: Int, seriesId: String, chapterId: String): ByteArray? {
|
||||||
|
fun isValidImage(data: ByteArray): Boolean {
|
||||||
|
return when {
|
||||||
|
data.size >= 2 && data[0] == 0xFF.toByte() && data[1] == 0xD8.toByte() -> true
|
||||||
|
data.size >= 12 && data[0] == 'R'.code.toByte() && data[1] == 'I'.code.toByte() &&
|
||||||
|
data[2] == 'F'.code.toByte() && data[3] == 'F'.code.toByte() &&
|
||||||
|
data[8] == 'W'.code.toByte() && data[9] == 'E'.code.toByte() &&
|
||||||
|
data[10] == 'B'.code.toByte() && data[11] == 'P'.code.toByte() -> true
|
||||||
|
data.size >= 2 && data[0] == 0xFF.toByte() && data[1] == 0x0A.toByte() -> true
|
||||||
|
data.size >= 12 && data.copyOfRange(0, 12).contentEquals(
|
||||||
|
byteArrayOf(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
12,
|
||||||
|
'J'.code.toByte(),
|
||||||
|
'X'.code.toByte(),
|
||||||
|
'L'.code.toByte(),
|
||||||
|
' '.code.toByte(),
|
||||||
|
),
|
||||||
|
) -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var processed: ByteArray = input
|
||||||
|
|
||||||
|
if (!isValidImage(processed)) {
|
||||||
|
val seed = generateSeed(seriesId, chapterId, "%04d.jpg".format(index))
|
||||||
|
val scrambler = Scrambler(seed, 10)
|
||||||
|
val scrambleMapping = scrambler.getScrambleMapping()
|
||||||
|
processed = unscramble(processed, scrambleMapping, true)
|
||||||
|
if (!isValidImage(processed)) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateSeed(t: String, n: String, e: String): BigInteger {
|
||||||
|
val sha256 = "$t:$n:$e".sha256()
|
||||||
|
var a = BigInteger.ZERO
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
a = a.shiftLeft(8).or(BigInteger.valueOf((sha256[i].toInt() and 0xFF).toLong()))
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unscramble(data: ByteArray, mapping: List<Pair<Int, Int>>, n: Boolean): ByteArray {
|
||||||
|
val s = mapping.size
|
||||||
|
val a = data.size
|
||||||
|
val l = a / s
|
||||||
|
val o = a % s
|
||||||
|
|
||||||
|
val (r, i) = if (n) {
|
||||||
|
if (o > 0) {
|
||||||
|
Pair(data.copyOfRange(0, o), data.copyOfRange(o, a))
|
||||||
|
} else {
|
||||||
|
Pair(ByteArray(0), data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (o > 0) {
|
||||||
|
Pair(data.copyOfRange(a - o, a), data.copyOfRange(0, a - o))
|
||||||
|
} else {
|
||||||
|
Pair(ByteArray(0), data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val chunks = (0 until s).map { idx ->
|
||||||
|
val start = idx * l
|
||||||
|
val end = (idx + 1) * l
|
||||||
|
i.copyOfRange(start, end)
|
||||||
|
}.toMutableList()
|
||||||
|
|
||||||
|
val u = Array(s) { ByteArray(0) }
|
||||||
|
|
||||||
|
if (n) {
|
||||||
|
for ((e, m) in mapping) {
|
||||||
|
if (e < s && m < s) {
|
||||||
|
u[e] = chunks[m]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for ((e, m) in mapping) {
|
||||||
|
if (e < s && m < s) {
|
||||||
|
u[m] = chunks[e]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val h = u.fold(ByteArray(0)) { acc, chunk -> acc + chunk }
|
||||||
|
|
||||||
|
return if (n) {
|
||||||
|
h + r
|
||||||
|
} else {
|
||||||
|
r + h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,439 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kagane
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Base64
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.PermissionRequest
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
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 eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import keiyoushi.utils.getPreferencesLazy
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import keiyoushi.utils.toJsonString
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.IOException
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.collections.forEach
|
||||||
|
import kotlin.getValue
|
||||||
|
import kotlin.text.split
|
||||||
|
|
||||||
|
class Kagane : HttpSource(), ConfigurableSource {
|
||||||
|
|
||||||
|
override val name = "Kagane"
|
||||||
|
|
||||||
|
private val domain = "kagane.org"
|
||||||
|
private val apiUrl = "https://api.$domain"
|
||||||
|
override val baseUrl = "https://$domain"
|
||||||
|
|
||||||
|
override val lang = "en"
|
||||||
|
|
||||||
|
override val supportsLatest = false
|
||||||
|
|
||||||
|
private val preferences by getPreferencesLazy()
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
|
.cookieJar(
|
||||||
|
object : CookieJar {
|
||||||
|
private val cookieManager by lazy { CookieManager.getInstance() }
|
||||||
|
|
||||||
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||||
|
val urlString = url.toString()
|
||||||
|
cookies.forEach { cookieManager.setCookie(urlString, it.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||||
|
val cookies = cookieManager.getCookie(url.toString()).orEmpty()
|
||||||
|
val cookieList = mutableListOf<Cookie>()
|
||||||
|
var hasNsfwCookie = false
|
||||||
|
|
||||||
|
cookies.split(";").mapNotNullTo(cookieList) { c ->
|
||||||
|
var cookieValue = c
|
||||||
|
if (url.host == domain && c.contains("kagane_mature_content")) {
|
||||||
|
hasNsfwCookie = true
|
||||||
|
val (key, _) = c.split("=")
|
||||||
|
cookieValue = "$key=${preferences.showNsfw}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Cookie.parse(url, cookieValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNsfwCookie && url.host == domain) {
|
||||||
|
Cookie.parse(url, "kagane_mature_content=${preferences.showNsfw}")?.let {
|
||||||
|
cookieList.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookieList
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addInterceptor(ImageInterceptor())
|
||||||
|
.addInterceptor(::refreshTokenInterceptor)
|
||||||
|
.rateLimit(2)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun refreshTokenInterceptor(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val url = request.url
|
||||||
|
if (!url.queryParameterNames.contains("token")) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
val seriesId = url.pathSegments[3]
|
||||||
|
val chapterId = url.pathSegments[5]
|
||||||
|
|
||||||
|
var response = chain.proceed(
|
||||||
|
request.newBuilder()
|
||||||
|
.url(url.newBuilder().setQueryParameter("token", accessToken).build())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
if (response.code == 401) {
|
||||||
|
response.close()
|
||||||
|
val challenge = try {
|
||||||
|
getChallengeResponse(seriesId, chapterId)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw IOException("Failed to retrieve token")
|
||||||
|
}
|
||||||
|
accessToken = challenge.accessToken
|
||||||
|
response = chain.proceed(
|
||||||
|
request.newBuilder()
|
||||||
|
.url(url.newBuilder().setQueryParameter("token", accessToken).build())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Popular ===============================
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
return GET("$baseUrl/?page=$page", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
return pageListParse(response, "initialSeriesData")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pageListParse(response: Response, key: String): MangasPage {
|
||||||
|
val data = response.asJsoup().selectFirst("script:containsData($key)")!!.data()
|
||||||
|
|
||||||
|
val mangaList = data.getNextData(key)
|
||||||
|
.parseAs<List<BookDto>>()
|
||||||
|
.map { it.toSManga(domain) }
|
||||||
|
|
||||||
|
val pagination = data.getNextData("pagination", isList = false, selectFirst = false)
|
||||||
|
.parseAs<PaginationDto>()
|
||||||
|
|
||||||
|
return MangasPage(mangaList, pagination.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Latest ===============================
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Search ===============================
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
|
||||||
|
addQueryParameter("name", query)
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
return pageListParse(response, "ssrData")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================== Manga Details ============================
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val data = response.asJsoup().selectFirst("script:containsData(initialSeriesData)")!!.data()
|
||||||
|
.getNextData("initialSeriesData", isList = false)
|
||||||
|
|
||||||
|
return data.parseAs<BookDto>().toSManga(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Chapters ==============================
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val seriesId = response.request.url.pathSegments.last()
|
||||||
|
val data = response.asJsoup().selectFirst("script:containsData(initialBooksData)")!!.data()
|
||||||
|
.getNextData("initialBooksData")
|
||||||
|
.parseAs<List<ChapterDto>>()
|
||||||
|
.reversed()
|
||||||
|
|
||||||
|
return data.map { it.toSChapter(seriesId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChapterUrl(chapter: SChapter): String {
|
||||||
|
val (seriesId, chapterId) = chapter.url.split(";")
|
||||||
|
return "$baseUrl/series/$seriesId/reader/$chapterId"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Pages ================================
|
||||||
|
|
||||||
|
private val apiHeaders = headers.newBuilder().apply {
|
||||||
|
add("Origin", baseUrl)
|
||||||
|
add("Referer", "$baseUrl/")
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
private fun getCertificate(): String {
|
||||||
|
return client.newCall(GET("$apiUrl/api/v1/static/bin.bin", apiHeaders)).execute()
|
||||||
|
.body.bytes()
|
||||||
|
.toBase64()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
val (seriesId, chapterId) = chapter.url.split(";")
|
||||||
|
|
||||||
|
val challengeResp = getChallengeResponse(seriesId, chapterId)
|
||||||
|
accessToken = challengeResp.accessToken
|
||||||
|
val pages = (0 until challengeResp.pageCount).map { page ->
|
||||||
|
val pageUrl = "$apiUrl/api/v1/books".toHttpUrl().newBuilder().apply {
|
||||||
|
addPathSegment(seriesId)
|
||||||
|
addPathSegment("file")
|
||||||
|
addPathSegment(chapterId)
|
||||||
|
addPathSegment((page + 1).toString())
|
||||||
|
addQueryParameter("token", accessToken)
|
||||||
|
}.build().toString()
|
||||||
|
|
||||||
|
Page(page, imageUrl = pageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Observable.just(pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accessToken: String = ""
|
||||||
|
private fun getChallengeResponse(seriesId: String, chapterId: String): ChallengeDto {
|
||||||
|
val f = "$seriesId:$chapterId".sha256().sliceArray(0 until 16)
|
||||||
|
|
||||||
|
val interfaceName = "jsInterface"
|
||||||
|
val html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Title</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
function base64ToArrayBuffer(base64) {
|
||||||
|
var binaryString = atob(base64);
|
||||||
|
var bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (var i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getData() {
|
||||||
|
const g = base64ToArrayBuffer("${getCertificate()}");
|
||||||
|
let t = await navigator.requestMediaKeySystemAccess("com.widevine.alpha", [{
|
||||||
|
initDataTypes: ["cenc"],
|
||||||
|
audioCapabilities: [],
|
||||||
|
videoCapabilities: [{
|
||||||
|
contentType: 'video/mp4; codecs="avc1.42E01E"'
|
||||||
|
}]
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let e = await t.createMediaKeys();
|
||||||
|
await e.setServerCertificate(g);
|
||||||
|
let n = e.createSession();
|
||||||
|
let i = new Promise((resolve, reject) => {
|
||||||
|
function onMessage(event) {
|
||||||
|
n.removeEventListener("message", onMessage);
|
||||||
|
resolve(event.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
n.removeEventListener("error", onError);
|
||||||
|
reject(new Error("Failed to generate license challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
n.addEventListener("message", onMessage);
|
||||||
|
n.addEventListener("error", onError);
|
||||||
|
});
|
||||||
|
|
||||||
|
await n.generateRequest("cenc", base64ToArrayBuffer("${getPssh(f).toBase64()}"));
|
||||||
|
let o = await i;
|
||||||
|
let m = new Uint8Array(o);
|
||||||
|
let v = btoa(String.fromCharCode(...m));
|
||||||
|
window.$interfaceName.passPayload(v);
|
||||||
|
}
|
||||||
|
getData();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
val jsInterface = JsInterface(latch)
|
||||||
|
var webView: WebView? = null
|
||||||
|
|
||||||
|
handler.post {
|
||||||
|
val innerWv = WebView(Injekt.get<Application>())
|
||||||
|
|
||||||
|
webView = innerWv
|
||||||
|
innerWv.settings.domStorageEnabled = true
|
||||||
|
innerWv.settings.javaScriptEnabled = true
|
||||||
|
innerWv.settings.blockNetworkImage = true
|
||||||
|
innerWv.settings.userAgentString = headers["User-Agent"]
|
||||||
|
innerWv.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||||
|
innerWv.addJavascriptInterface(jsInterface, interfaceName)
|
||||||
|
|
||||||
|
innerWv.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onPermissionRequest(request: PermissionRequest?) {
|
||||||
|
if (request?.resources?.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) == true) {
|
||||||
|
request.grant(request.resources)
|
||||||
|
} else {
|
||||||
|
super.onPermissionRequest(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
innerWv.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
latch.await(10, TimeUnit.SECONDS)
|
||||||
|
handler.post { webView?.destroy() }
|
||||||
|
|
||||||
|
if (latch.count == 1L) {
|
||||||
|
throw Exception("Timed out getting drm challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsInterface.challenge.isEmpty()) {
|
||||||
|
throw Exception("Failed to get drm challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
val challengeUrl = "$apiUrl/api/v1/books/$seriesId/file/$chapterId"
|
||||||
|
val challengeBody = buildJsonObject {
|
||||||
|
put("challenge", jsInterface.challenge)
|
||||||
|
}.toJsonString().toRequestBody("application/json".toMediaType())
|
||||||
|
|
||||||
|
return client.newCall(POST(challengeUrl, apiHeaders, challengeBody)).execute()
|
||||||
|
.parseAs<ChallengeDto>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun concat(vararg arrays: ByteArray): ByteArray =
|
||||||
|
arrays.reduce { acc, bytes -> acc + bytes }
|
||||||
|
|
||||||
|
private fun getPssh(t: ByteArray): ByteArray {
|
||||||
|
val e = Base64.decode("7e+LqXnWSs6jyCfc1R0h7Q==", Base64.DEFAULT)
|
||||||
|
val zeroes = ByteArray(4)
|
||||||
|
|
||||||
|
val i = byteArrayOf(18, t.size.toByte()) + t
|
||||||
|
val s = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(i.size).array()
|
||||||
|
|
||||||
|
val innerBox = concat(zeroes, e, s, i)
|
||||||
|
val outerSize = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(innerBox.size + 8).array()
|
||||||
|
val psshHeader = "pssh".toByteArray(StandardCharsets.UTF_8)
|
||||||
|
|
||||||
|
return concat(outerSize, psshHeader, innerBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class JsInterface(private val latch: CountDownLatch) {
|
||||||
|
var challenge: String = ""
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
@Suppress("UNUSED")
|
||||||
|
fun passPayload(rawData: String) {
|
||||||
|
try {
|
||||||
|
challenge = rawData
|
||||||
|
latch.countDown()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ Preferences =============================
|
||||||
|
|
||||||
|
private val SharedPreferences.showNsfw
|
||||||
|
get() = this.getBoolean(SHOW_NSFW_KEY, true)
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
key = SHOW_NSFW_KEY
|
||||||
|
title = "Show nsfw entries"
|
||||||
|
setDefaultValue(true)
|
||||||
|
}.let(screen::addPreference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================= Utilities ==============================
|
||||||
|
|
||||||
|
private fun String.getNextData(key: String, isList: Boolean = true, selectFirst: Boolean = true): String {
|
||||||
|
val (startDel, endDel) = if (isList) '[' to ']' else '{' to '}'
|
||||||
|
|
||||||
|
val keyIndex = if (selectFirst) this.indexOf(key) else this.lastIndexOf(key)
|
||||||
|
val start = this.indexOf(startDel, keyIndex)
|
||||||
|
|
||||||
|
var depth = 1
|
||||||
|
var i = start + 1
|
||||||
|
|
||||||
|
while (i < this.length && depth > 0) {
|
||||||
|
when (this[i]) {
|
||||||
|
startDel -> depth++
|
||||||
|
endDel -> depth--
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\"${this.substring(start, i)}\"".parseAs<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SHOW_NSFW_KEY = "pref_show_nsfw"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kagane
|
||||||
|
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
class Randomizer(seedInput: BigInteger, t: Int) {
|
||||||
|
|
||||||
|
val size: Int = t * t
|
||||||
|
val seed: BigInteger
|
||||||
|
private var state: BigInteger
|
||||||
|
private val entropyPool: ByteArray
|
||||||
|
val order: MutableList<Int>
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val MASK64 = BigInteger("FFFFFFFFFFFFFFFF", 16)
|
||||||
|
private val MASK32 = BigInteger("FFFFFFFF", 16)
|
||||||
|
private val MASK8 = BigInteger("FF", 16)
|
||||||
|
private val PRNG_MULT = BigInteger("27BB2EE687B0B0FD", 16)
|
||||||
|
private val RND_MULT_32 = BigInteger("45d9f3b", 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
val seedMask = BigInteger("FFFFFFFFFFFFFFFF", 16)
|
||||||
|
seed = seedInput.and(seedMask)
|
||||||
|
state = hashSeed(seed)
|
||||||
|
entropyPool = expandEntropy(seed)
|
||||||
|
order = MutableList(size) { it }
|
||||||
|
permute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hashSeed(e: BigInteger): BigInteger {
|
||||||
|
val md = e.toString().sha256()
|
||||||
|
return readBigUInt64BE(md, 0).xor(readBigUInt64BE(md, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBigUInt64BE(bytes: ByteArray, offset: Int): BigInteger {
|
||||||
|
var n = BigInteger.ZERO
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
n = n.shiftLeft(8).or(BigInteger.valueOf((bytes[offset + i].toInt() and 0xFF).toLong()))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expandEntropy(e: BigInteger): ByteArray =
|
||||||
|
MessageDigest.getInstance("SHA-512").digest(e.toString().toByteArray(StandardCharsets.UTF_8))
|
||||||
|
|
||||||
|
private fun sbox(e: Int): Int {
|
||||||
|
val t = intArrayOf(163, 95, 137, 13, 55, 193, 107, 228, 114, 185, 22, 243, 68, 218, 158, 40)
|
||||||
|
return t[e and 15] xor t[e shr 4 and 15]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prng(): BigInteger {
|
||||||
|
state = state.xor(state.shiftLeft(11).and(MASK64))
|
||||||
|
state = state.xor(state.shiftRight(19))
|
||||||
|
state = state.xor(state.shiftLeft(7).and(MASK64))
|
||||||
|
state = state.multiply(PRNG_MULT).and(MASK64)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roundFunc(e: BigInteger, t: Int): BigInteger {
|
||||||
|
var n = e.xor(prng()).xor(BigInteger.valueOf(t.toLong()))
|
||||||
|
|
||||||
|
val rot = n.shiftLeft(5).or(n.shiftRight(3)).and(MASK32)
|
||||||
|
n = rot.multiply(RND_MULT_32).and(MASK32)
|
||||||
|
|
||||||
|
val sboxVal = sbox(n.and(MASK8).toInt())
|
||||||
|
n = n.xor(BigInteger.valueOf(sboxVal.toLong()))
|
||||||
|
|
||||||
|
n = n.xor(n.shiftRight(13))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun feistelMix(e: Int, t: Int, rounds: Int): Pair<BigInteger, BigInteger> {
|
||||||
|
var r = BigInteger.valueOf(e.toLong())
|
||||||
|
var i = BigInteger.valueOf(t.toLong())
|
||||||
|
for (round in 0 until rounds) {
|
||||||
|
val ent = entropyPool[round % entropyPool.size].toInt() and 0xFF
|
||||||
|
r = r.xor(roundFunc(i, ent))
|
||||||
|
val secondArg = ent xor (round * 31 and 255)
|
||||||
|
i = i.xor(roundFunc(r, secondArg))
|
||||||
|
}
|
||||||
|
return Pair(r, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun permute() {
|
||||||
|
val half = size / 2
|
||||||
|
val sizeBig = BigInteger.valueOf(size.toLong())
|
||||||
|
|
||||||
|
for (t in 0 until half) {
|
||||||
|
val n = t + half
|
||||||
|
val (rBig, iBig) = feistelMix(t, n, 4)
|
||||||
|
val s = rBig.mod(sizeBig).toInt()
|
||||||
|
val a = iBig.mod(sizeBig).toInt()
|
||||||
|
val tmp = order[s]
|
||||||
|
order[s] = order[a]
|
||||||
|
order[a] = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
for (e in size - 1 downTo 1) {
|
||||||
|
val ent = entropyPool[e % entropyPool.size].toInt() and 0xFF
|
||||||
|
val idxBig = prng().add(BigInteger.valueOf(ent.toLong())).mod(BigInteger.valueOf((e + 1).toLong()))
|
||||||
|
val n = idxBig.toInt()
|
||||||
|
val tmp = order[e]
|
||||||
|
order[e] = order[n]
|
||||||
|
order[n] = tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,125 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kagane
|
||||||
|
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
class Scrambler(private val seed: BigInteger, private val gridSize: Int) {
|
||||||
|
|
||||||
|
private val totalPieces: Int = gridSize * gridSize
|
||||||
|
private val randomizer: Randomizer = Randomizer(seed, gridSize)
|
||||||
|
private val dependencyGraph: DependencyGraph
|
||||||
|
private val scramblePath: List<Int>
|
||||||
|
|
||||||
|
init {
|
||||||
|
dependencyGraph = buildDependencyGraph()
|
||||||
|
scramblePath = generateScramblePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DependencyGraph(
|
||||||
|
val graph: MutableMap<Int, MutableList<Int>>,
|
||||||
|
val inDegree: MutableMap<Int, Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildDependencyGraph(): DependencyGraph {
|
||||||
|
val graph = mutableMapOf<Int, MutableList<Int>>()
|
||||||
|
val inDegree = mutableMapOf<Int, Int>()
|
||||||
|
|
||||||
|
for (n in 0 until totalPieces) {
|
||||||
|
inDegree[n] = 0
|
||||||
|
graph[n] = mutableListOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
val rng = Randomizer(seed, gridSize)
|
||||||
|
|
||||||
|
for (r in 0 until totalPieces) {
|
||||||
|
val i = (rng.prng() % BigInteger.valueOf(3) + BigInteger.valueOf(2)).toInt()
|
||||||
|
repeat(i) {
|
||||||
|
val j = (rng.prng() % BigInteger.valueOf(totalPieces.toLong())).toInt()
|
||||||
|
if (j != r && !wouldCreateCycle(graph, j, r)) {
|
||||||
|
graph[j]!!.add(r)
|
||||||
|
inDegree[r] = inDegree[r]!! + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (r in 0 until totalPieces) {
|
||||||
|
if (inDegree[r] == 0) {
|
||||||
|
var tries = 0
|
||||||
|
while (tries < 10) {
|
||||||
|
val s = (rng.prng() % BigInteger.valueOf(totalPieces.toLong())).toInt()
|
||||||
|
if (s != r && !wouldCreateCycle(graph, s, r)) {
|
||||||
|
graph[s]!!.add(r)
|
||||||
|
inDegree[r] = inDegree[r]!! + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DependencyGraph(graph, inDegree)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wouldCreateCycle(graph: Map<Int, List<Int>>, target: Int, start: Int): Boolean {
|
||||||
|
val visited = mutableSetOf<Int>()
|
||||||
|
val stack = ArrayDeque<Int>()
|
||||||
|
stack.add(start)
|
||||||
|
|
||||||
|
while (stack.isNotEmpty()) {
|
||||||
|
val n = stack.removeLast()
|
||||||
|
if (n == target) return true
|
||||||
|
if (!visited.add(n)) continue
|
||||||
|
graph[n]?.let { stack.addAll(it) }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateScramblePath(): List<Int> {
|
||||||
|
val graphCopy = dependencyGraph.graph.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||||
|
val inDegreeCopy = dependencyGraph.inDegree.toMutableMap()
|
||||||
|
|
||||||
|
val queue = ArrayDeque<Int>()
|
||||||
|
for (n in 0 until totalPieces) {
|
||||||
|
if (inDegreeCopy[n] == 0) {
|
||||||
|
queue.add(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val order = mutableListOf<Int>()
|
||||||
|
while (queue.isNotEmpty()) {
|
||||||
|
val i = queue.removeFirst()
|
||||||
|
order.add(i)
|
||||||
|
val neighbors = graphCopy[i]
|
||||||
|
if (neighbors != null) {
|
||||||
|
for (e in neighbors) {
|
||||||
|
inDegreeCopy[e] = inDegreeCopy[e]!! - 1
|
||||||
|
if (inDegreeCopy[e] == 0) {
|
||||||
|
queue.add(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getScrambleMapping(): List<Pair<Int, Int>> {
|
||||||
|
var e = randomizer.order.toMutableList()
|
||||||
|
|
||||||
|
if (scramblePath.size == totalPieces) {
|
||||||
|
val t = Array(totalPieces) { 0 }
|
||||||
|
for (i in scramblePath.indices) {
|
||||||
|
t[i] = scramblePath[i]
|
||||||
|
}
|
||||||
|
val n = Array(totalPieces) { 0 }
|
||||||
|
for (r in 0 until totalPieces) {
|
||||||
|
n[r] = e[t[r]]
|
||||||
|
}
|
||||||
|
e = n.toMutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = mutableListOf<Pair<Int, Int>>()
|
||||||
|
for (n in 0 until totalPieces) {
|
||||||
|
result.add(n to e[n])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.en.kagane
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
fun ByteArray.toBase64(): String {
|
||||||
|
return Base64.encodeToString(this, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.sha256(): ByteArray {
|
||||||
|
return MessageDigest
|
||||||
|
.getInstance("SHA-256")
|
||||||
|
.digest(toByteArray())
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user