diff --git a/src/en/kagane/build.gradle b/src/en/kagane/build.gradle new file mode 100644 index 000000000..b0b192f34 --- /dev/null +++ b/src/en/kagane/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Kagane' + extClass = '.Kagane' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/kagane/res/mipmap-hdpi/ic_launcher.png b/src/en/kagane/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..14f638e51 Binary files /dev/null and b/src/en/kagane/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/kagane/res/mipmap-mdpi/ic_launcher.png b/src/en/kagane/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..605a2ff74 Binary files /dev/null and b/src/en/kagane/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/kagane/res/mipmap-xhdpi/ic_launcher.png b/src/en/kagane/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..5e038e3e5 Binary files /dev/null and b/src/en/kagane/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/kagane/res/mipmap-xxhdpi/ic_launcher.png b/src/en/kagane/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..3d9fb81fa Binary files /dev/null and b/src/en/kagane/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/kagane/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/kagane/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..c856ca277 Binary files /dev/null and b/src/en/kagane/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt new file mode 100644 index 000000000..f70cfef5b --- /dev/null +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt @@ -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, + val status: String, + val summary: String, + ) + + @Serializable + class BooksMetadataDto( + val authors: List, + ) { + @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 { + 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, +) diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/ImageInterceptor.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/ImageInterceptor.kt new file mode 100644 index 000000000..198253870 --- /dev/null +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/ImageInterceptor.kt @@ -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>, 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 + } + } +} diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt new file mode 100644 index 000000000..efbb348b6 --- /dev/null +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt @@ -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) { + val urlString = url.toString() + cookies.forEach { cookieManager.setCookie(urlString, it.toString()) } + } + + override fun loadForRequest(url: HttpUrl): List { + val cookies = cookieManager.getCookie(url.toString()).orEmpty() + val cookieList = mutableListOf() + 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>() + .map { it.toSManga(domain) } + + val pagination = data.getNextData("pagination", isList = false, selectFirst = false) + .parseAs() + + 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().toSManga(domain) + } + + // ============================== Chapters ============================== + + override fun chapterListParse(response: Response): List { + val seriesId = response.request.url.pathSegments.last() + val data = response.asJsoup().selectFirst("script:containsData(initialBooksData)")!!.data() + .getNextData("initialBooksData") + .parseAs>() + .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> { + 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 = """ + + + + + Title + + + + + + """.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()) + + 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() + } + + 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 { + 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() + } + + companion object { + private const val SHOW_NSFW_KEY = "pref_show_nsfw" + } +} diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Randomizer.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Randomizer.kt new file mode 100644 index 000000000..86763fceb --- /dev/null +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Randomizer.kt @@ -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 + + 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 { + 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 + } + } +} diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Scrambler.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Scrambler.kt new file mode 100644 index 000000000..0c0d9c74a --- /dev/null +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Scrambler.kt @@ -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 + + init { + dependencyGraph = buildDependencyGraph() + scramblePath = generateScramblePath() + } + + private data class DependencyGraph( + val graph: MutableMap>, + val inDegree: MutableMap, + ) + + private fun buildDependencyGraph(): DependencyGraph { + val graph = mutableMapOf>() + val inDegree = mutableMapOf() + + 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>, target: Int, start: Int): Boolean { + val visited = mutableSetOf() + val stack = ArrayDeque() + 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 { + val graphCopy = dependencyGraph.graph.mapValues { it.value.toMutableList() }.toMutableMap() + val inDegreeCopy = dependencyGraph.inDegree.toMutableMap() + + val queue = ArrayDeque() + for (n in 0 until totalPieces) { + if (inDegreeCopy[n] == 0) { + queue.add(n) + } + } + + val order = mutableListOf() + 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> { + 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>() + for (n in 0 until totalPieces) { + result.add(n to e[n]) + } + return result + } +} diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Utils.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Utils.kt new file mode 100644 index 000000000..7bb0e2daf --- /dev/null +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Utils.kt @@ -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()) +}