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