Overhaul 2025 (See PR for changes)

This commit is contained in:
Mio.
2025-06-27 23:44:38 +02:00
parent c52d8831aa
commit 965c2a4f43
81 changed files with 4404 additions and 2086 deletions

View File

@@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View File

@@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.lib.cookieinterceptor
import android.webkit.CookieManager
import okhttp3.Interceptor
import okhttp3.Response
class CookieInterceptor(
private val domain: String,
private val cookies: List<Pair<String, String>>
) : Interceptor {
constructor(domain: String, cookie: Pair<String, String>) : this(domain, listOf(cookie))
init {
val url = "https://$domain/"
cookies.forEach {
val cookie = "${it.first}=${it.second}; Domain=$domain; Path=/"
setCookie(url, cookie)
}
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.url.host.endsWith(domain)) return chain.proceed(request)
val cookieList = request.header("Cookie")?.split("; ") ?: emptyList()
if (cookies.all { (key, value) -> "$key=$value" in cookieList })
return chain.proceed(request)
cookies.forEach { (key, value) ->
setCookie("https://$domain/", "$key=$value; Domain=$domain; Path=/")
}
val newCookie = buildList(cookieList.size + cookies.size) {
cookieList.filterNotTo(this) { existing ->
cookies.any { (key, _) ->
existing.startsWith("$key=")
}
}
cookies.forEach { (key, value) ->
add("$key=$value")
}
}.joinToString("; ")
val newRequest = request.newBuilder()
.header("Cookie", newCookie)
.build()
return chain.proceed(newRequest)
}
private val cookieManager by lazy { CookieManager.getInstance() }
private fun setCookie(url: String, value: String) {
try {
cookieManager.setCookie(url, value)
} catch (_: Exception) { }
}
}

View File

@@ -1,22 +1,3 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
defaultConfig {
minSdk = AndroidConfig.minSdk
}
namespace = "eu.kanade.tachiyomi.lib.cryptoaes"
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.kotlin.stdlib)
id("lib-android")
}

View File

@@ -5,6 +5,7 @@ import android.util.Base64
import java.security.MessageDigest
import java.util.Arrays
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec

View File

@@ -1,24 +1,3 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
defaultConfig {
minSdk = AndroidConfig.minSdk
}
namespace = "eu.kanade.tachiyomi.lib.dataimage"
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.kotlin.stdlib)
compileOnly(libs.okhttp)
compileOnly(libs.jsoup)
id("lib-android")
}

View File

@@ -1,22 +1,3 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
defaultConfig {
minSdk = AndroidConfig.minSdk
}
namespace = "eu.kanade.tachiyomi.lib.i18n"
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.kotlin.stdlib)
id("lib-android")
}

View File

@@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View File

@@ -0,0 +1,294 @@
package eu.kanade.tachiyomi.lib.lzstring
typealias getCharFromIntFn = (it: Int) -> String
typealias getNextValueFn = (it: Int) -> Int
/**
* Reimplementation of [lz-string](https://github.com/pieroxy/lz-string) compression/decompression.
*/
object LZString {
private fun compress(
uncompressed: String,
bitsPerChar: Int,
getCharFromInt: getCharFromIntFn,
): String {
val context = CompressionContext(uncompressed.length, bitsPerChar, getCharFromInt)
for (ii in uncompressed.indices) {
context.c = uncompressed[ii].toString()
if (!context.dictionary.containsKey(context.c)) {
context.dictionary[context.c] = context.dictSize++
context.dictionaryToCreate[context.c] = true
}
context.wc = context.w + context.c
if (context.dictionary.containsKey(context.wc)) {
context.w = context.wc
continue
}
context.outputCodeForW()
context.decrementEnlargeIn()
context.dictionary[context.wc] = context.dictSize++
context.w = context.c
}
if (context.w.isNotEmpty()) {
context.outputCodeForW()
context.decrementEnlargeIn()
}
// Mark the end of the stream
context.value = 2
for (i in 0 until context.numBits) {
context.dataVal = (context.dataVal shl 1) or (context.value and 1)
context.appendDataOrAdvancePosition()
context.value = context.value shr 1
}
while (true) {
context.dataVal = context.dataVal shl 1
if (context.dataPosition == bitsPerChar - 1) {
context.data.append(getCharFromInt(context.dataVal))
break
}
context.dataPosition++
}
return context.data.toString()
}
private fun decompress(length: Int, resetValue: Int, getNextValue: getNextValueFn): String {
val dictionary = mutableListOf<String>()
val result = StringBuilder()
val data = DecompressionContext(resetValue, getNextValue)
var enlargeIn = 4
var numBits = 3
var entry: String
var c: Char? = null
for (i in 0 until 3) {
dictionary.add(i.toString())
}
data.loopUntilMaxPower()
when (data.bits) {
0 -> {
data.bits = 0
data.maxPower = 1 shl 8
data.power = 1
data.loopUntilMaxPower()
c = data.bits.toChar()
}
1 -> {
data.bits = 0
data.maxPower = 1 shl 16
data.power = 1
data.loopUntilMaxPower()
c = data.bits.toChar()
}
2 -> throw IllegalArgumentException("Invalid LZString")
}
if (c == null) {
throw Exception("No character found")
}
dictionary.add(c.toString())
var w = c.toString()
result.append(c.toString())
while (true) {
if (data.index > length) {
throw IllegalArgumentException("Invalid LZString")
}
data.bits = 0
data.maxPower = 1 shl numBits
data.power = 1
data.loopUntilMaxPower()
var cc = data.bits
when (data.bits) {
0 -> {
data.bits = 0
data.maxPower = 1 shl 8
data.power = 1
data.loopUntilMaxPower()
dictionary.add(data.bits.toChar().toString())
cc = dictionary.size - 1
enlargeIn--
}
1 -> {
data.bits = 0
data.maxPower = 1 shl 16
data.power = 1
data.loopUntilMaxPower()
dictionary.add(data.bits.toChar().toString())
cc = dictionary.size - 1
enlargeIn--
}
2 -> return result.toString()
}
if (enlargeIn == 0) {
enlargeIn = 1 shl numBits
numBits++
}
entry = if (cc < dictionary.size) {
dictionary[cc]
} else {
if (cc == dictionary.size) {
w + w[0]
} else {
throw Exception("Invalid LZString")
}
}
result.append(entry)
dictionary.add(w + entry[0])
enlargeIn--
w = entry
if (enlargeIn == 0) {
enlargeIn = 1 shl numBits
numBits++
}
}
}
private const val base64KeyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
fun compressToBase64(input: String): String =
compress(input, 6) { base64KeyStr[it].toString() }.let {
return when (it.length % 4) {
0 -> it
1 -> "$it==="
2 -> "$it=="
3 -> "$it="
else -> throw IllegalStateException("Modulo of 4 should not exceed 3.")
}
}
fun decompressFromBase64(input: String): String =
decompress(input.length, 32) {
base64KeyStr.indexOf(input[it])
}
}
private data class DecompressionContext(
val resetValue: Int,
val getNextValue: getNextValueFn,
var value: Int = getNextValue(0),
var position: Int = resetValue,
var index: Int = 1,
var bits: Int = 0,
var maxPower: Int = 1 shl 2,
var power: Int = 1,
) {
fun loopUntilMaxPower() {
while (power != maxPower) {
val resb = value and position
position = position shr 1
if (position == 0) {
position = resetValue
value = getNextValue(index++)
}
bits = bits or ((if (resb > 0) 1 else 0) * power)
power = power shl 1
}
}
}
private data class CompressionContext(
val uncompressedLength: Int,
val bitsPerChar: Int,
val getCharFromInt: getCharFromIntFn,
var value: Int = 0,
val dictionary: MutableMap<String, Int> = HashMap(),
val dictionaryToCreate: MutableMap<String, Boolean> = HashMap(),
var c: String = "",
var wc: String = "",
var w: String = "",
var enlargeIn: Int = 2, // Compensate for the first entry which should not count
var dictSize: Int = 3,
var numBits: Int = 2,
val data: StringBuilder = StringBuilder(uncompressedLength / 3),
var dataVal: Int = 0,
var dataPosition: Int = 0,
) {
fun appendDataOrAdvancePosition() {
if (dataPosition == bitsPerChar - 1) {
dataPosition = 0
data.append(getCharFromInt(dataVal))
dataVal = 0
} else {
dataPosition++
}
}
fun decrementEnlargeIn() {
enlargeIn--
if (enlargeIn == 0) {
enlargeIn = 1 shl numBits
numBits++
}
}
// Output the code for W.
fun outputCodeForW() {
if (dictionaryToCreate.containsKey(w)) {
if (w[0].code < 256) {
for (i in 0 until numBits) {
dataVal = dataVal shl 1
appendDataOrAdvancePosition()
}
value = w[0].code
for (i in 0 until 8) {
dataVal = (dataVal shl 1) or (value and 1)
appendDataOrAdvancePosition()
value = value shr 1
}
} else {
value = 1
for (i in 0 until numBits) {
dataVal = (dataVal shl 1) or value
appendDataOrAdvancePosition()
value = 0
}
value = w[0].code
for (i in 0 until 16) {
dataVal = (dataVal shl 1) or (value and 1)
appendDataOrAdvancePosition()
value = value shr 1
}
}
decrementEnlargeIn()
dictionaryToCreate.remove(w)
} else {
value = dictionary[w]!!
for (i in 0 until numBits) {
dataVal = (dataVal shl 1) or (value and 1)
appendDataOrAdvancePosition()
value = value shr 1
}
}
}
}

View File

@@ -1,23 +1,3 @@
plugins {
id("com.android.library")
kotlin("android")
id("kotlinx-serialization")
}
android {
compileSdk = AndroidConfig.compileSdk
defaultConfig {
minSdk = AndroidConfig.minSdk
}
namespace = "eu.kanade.tachiyomi.lib.randomua"
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.bundles.common)
id("lib-android")
}

View File

@@ -85,7 +85,7 @@ private class RandomUserAgentInterceptor(
}
companion object {
private const val UA_DB_URL = "https://tachiyomiorg.github.io/user-agents/user-agents.json"
private const val UA_DB_URL = "https://keiyoushi.github.io/user-agents/user-agents.json"
}
}

View File

@@ -8,9 +8,9 @@ import androidx.preference.PreferenceScreen
import okhttp3.Headers
/**
* Helper function to return UserAgentType based on SharedPreference value
*/
/**
* Helper function to return UserAgentType based on SharedPreference value
*/
fun SharedPreferences.getPrefUAType(): UserAgentType {
return when (getString(PREF_KEY_RANDOM_UA, "off")) {
"mobile" -> UserAgentType.MOBILE
@@ -34,7 +34,9 @@ fun SharedPreferences.getPrefCustomUA(): String? {
fun addRandomUAPreferenceToScreen(
screen: PreferenceScreen,
) {
ListPreference(screen.context).apply {
val context = screen.context
ListPreference(context).apply {
key = PREF_KEY_RANDOM_UA
title = TITLE_RANDOM_UA
entries = RANDOM_UA_ENTRIES
@@ -43,28 +45,27 @@ fun addRandomUAPreferenceToScreen(
setDefaultValue("off")
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
EditTextPreference(context).apply {
key = PREF_KEY_CUSTOM_UA
title = TITLE_CUSTOM_UA
summary = CUSTOM_UA_SUMMARY
setOnPreferenceChangeListener { _, newValue ->
try {
Headers.Builder().add("User-Agent", newValue as String).build()
Headers.headersOf("User-Agent", newValue as String)
true
} catch (e: IllegalArgumentException) {
Toast.makeText(screen.context, "User Agent invalid${e.message}", Toast.LENGTH_LONG).show()
Toast.makeText(context, "Invalid user agent string: ${e.message}", Toast.LENGTH_LONG).show()
false
}
}
}.also(screen::addPreference)
}
const val TITLE_RANDOM_UA = "Random User-Agent (Requires Restart)"
const val TITLE_RANDOM_UA = "Random user agent string (requires restart)"
const val PREF_KEY_RANDOM_UA = "pref_key_random_ua_"
val RANDOM_UA_ENTRIES = arrayOf("OFF", "Desktop", "Mobile")
val RANDOM_UA_VALUES = arrayOf("off", "desktop", "mobile")
const val TITLE_CUSTOM_UA = "Custom User-Agent (Requires Restart)"
const val TITLE_CUSTOM_UA = "Custom user agent string (requires restart)"
const val PREF_KEY_CUSTOM_UA = "pref_key_custom_ua_"
const val CUSTOM_UA_SUMMARY = "Leave blank to use application default user-agent (IGNORED if Random User-Agent is enabled)"
const val CUSTOM_UA_SUMMARY = "Leave blank to use the default user agent string (ignored if random user agent string is enabled)"

View File

@@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:textinterceptor"))
}

View File

@@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.lib.speedbinb
private const val URLSAFE_BASE64_LOOKUP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
internal fun determineKeyPair(src: String?, ptbl: List<String>, ctbl: List<String>): Pair<String, String> {
val i = mutableListOf(0, 0)
if (src != null) {
val filename = src.substringAfterLast("/")
for (e in filename.indices) {
i[e % 2] = i[e % 2] + filename[e].code
}
i[0] = i[0] % 8
i[1] = i[1] % 8
}
return Pair(ptbl[i[0]], ctbl[i[1]])
}
internal fun decodeScrambleTable(cid: String, sharedKey: String, table: String): String {
val r = "$cid:$sharedKey"
var e = r.toCharArray()
.map { it.code }
.reduceIndexed { index, acc, i -> acc + (i shl index % 16) } and 2147483647
if (e == 0) {
e = 0x12345678
}
return buildString(table.length) {
for (s in table.indices) {
e = e ushr 1 xor (1210056708 and -(1 and e))
append(((table[s].code - 32 + e) % 94 + 32).toChar())
}
}
}
internal fun generateSharedKey(cid: String): String {
val randomChars = randomChars(16)
val cidRepeatCount = (16 + cid.length - 1) / cid.length
val unk1 = buildString(cid.length * cidRepeatCount) {
for (i in 0 until cidRepeatCount) {
append(cid)
}
}
val unk2 = unk1.substring(0, 16)
val unk3 = unk1.substring(unk1.length - 16, unk1.length)
var s = 0
var h = 0
var u = 0
return buildString(randomChars.length * 2) {
for (i in randomChars.indices) {
s = s xor randomChars[i].code
h = h xor unk2[i].code
u = u xor unk3[i].code
append(randomChars[i])
append(URLSAFE_BASE64_LOOKUP[(s + h + u) and 63])
}
}
}
private fun randomChars(length: Int) = buildString(length) {
for (i in 0 until length) {
append(URLSAFE_BASE64_LOOKUP.random())
}
}

View File

@@ -0,0 +1,102 @@
package eu.kanade.tachiyomi.lib.speedbinb
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
private val COORD_REGEX = Regex("""^i:(\d+),(\d+)\+(\d+),(\d+)>(\d+),(\d+)$""")
@Serializable
class BibContentInfo(
val result: Int,
val items: List<BibContentItem>,
)
@Serializable
class BibContentItem(
@SerialName("ContentID") val contentId: String,
@SerialName("ContentsServer") val contentServer: String,
@SerialName("ServerType") val serverType: Int,
val stbl: String,
val ttbl: String,
val ptbl: String,
val ctbl: String,
@SerialName("p") val requestToken: String? = null,
@SerialName("ViewMode") val viewMode: Int,
@SerialName("ContentDate") val contentDate: String? = null,
@SerialName("ShopURL") val shopUrl: String? = null,
) {
fun getSbcUrl(readerUrl: HttpUrl, cid: String) =
contentServer.toHttpUrl().newBuilder().apply {
when (serverType) {
ServerType.DIRECT -> addPathSegment("content.js")
ServerType.REST -> addPathSegment("content")
ServerType.SBC -> {
addPathSegment("sbcGetCntnt.php")
setQueryParameter("cid", cid)
requestToken?.let { setQueryParameter("p", it) }
setQueryParameter("q", "1")
setQueryParameter("vm", viewMode.toString())
setQueryParameter("dmytime", contentDate ?: System.currentTimeMillis().toString())
copyKeyParametersFrom(readerUrl)
}
else -> throw UnsupportedOperationException("Unsupported ServerType value $serverType")
}
}.toString()
}
object ServerType {
const val SBC = 0
const val DIRECT = 1
const val REST = 2
}
object ViewMode {
const val COMMERCIAL = 1
const val NON_MEMBER_TRIAL = 2
const val MEMBER_TRIAL = 3
}
@Serializable
class PtImg(
@SerialName("ptimg-version") val ptImgVersion: Int,
val resources: PtImgResources,
val views: List<PtImgViews>,
) {
val translations by lazy {
views[0].coords.map { coord ->
val v = COORD_REGEX.matchEntire(coord)!!.groupValues.drop(1).map { it.toInt() }
PtImgTranslation(v[0], v[1], v[2], v[3], v[4], v[5])
}
}
}
@Serializable
class PtImgResources(
val i: PtImgImage,
)
@Serializable
class PtImgImage(
val src: String,
val width: Int,
val height: Int,
)
@Serializable
class PtImgViews(
val width: Int,
val height: Int,
val coords: Array<String>,
)
class PtImgTranslation(val xsrc: Int, val ysrc: Int, val width: Int, val height: Int, val xdest: Int, val ydest: Int)
@Serializable
class SBCContent(
@SerialName("SBCVersion") val sbcVersion: String,
val result: Int,
val ttx: String,
@SerialName("ImageClass") val imageClass: String? = null,
)

View File

@@ -0,0 +1,85 @@
package eu.kanade.tachiyomi.lib.speedbinb
import android.graphics.BitmapFactory
import eu.kanade.tachiyomi.lib.speedbinb.descrambler.PtBinbDescramblerA
import eu.kanade.tachiyomi.lib.speedbinb.descrambler.PtBinbDescramblerF
import eu.kanade.tachiyomi.lib.speedbinb.descrambler.PtImgDescrambler
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptor
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.IOException
class SpeedBinbInterceptor(private val json: Json) : Interceptor {
private val textInterceptor by lazy { TextInterceptor() }
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val host = request.url.host
val filename = request.url.pathSegments.last()
val fragment = request.url.fragment
return when {
host == TextInterceptorHelper.HOST -> textInterceptor.intercept(chain)
filename.endsWith(".ptimg.json") -> interceptPtImg(chain, request)
fragment == null -> chain.proceed(request)
fragment.startsWith("ptbinb,") -> interceptPtBinB(chain, request)
else -> chain.proceed(request)
}
}
private fun interceptPtImg(chain: Interceptor.Chain, request: Request): Response {
val response = chain.proceed(request)
val metadata = json.decodeFromString<PtImg>(response.body.string())
val imageUrl = request.url.newBuilder()
.setPathSegment(request.url.pathSize - 1, metadata.resources.i.src)
.build()
val imageResponse = chain.proceed(
request.newBuilder().url(imageUrl).build(),
)
if (metadata.translations.isEmpty()) {
return imageResponse
}
val image = BitmapFactory.decodeStream(imageResponse.body.byteStream())
val descrambler = PtImgDescrambler(metadata)
return imageResponse.newBuilder()
.body(descrambler.descrambleImage(image)!!.toResponseBody(JPEG_MEDIA_TYPE))
.build()
}
private fun interceptPtBinB(chain: Interceptor.Chain, request: Request): Response {
val response = chain.proceed(request)
val fragment = request.url.fragment!!
val (s, u) = fragment.removePrefix("ptbinb,").split(",", limit = 2)
if (s.isEmpty() && u.isEmpty()) {
return response
}
val imageData = response.body.bytes()
val image = BitmapFactory.decodeByteArray(imageData, 0, imageData.size)
val descrambler = if (s[0] == '=' && u[0] == '=') {
PtBinbDescramblerF(s, u, image.width, image.height)
} else if (NUMERIC_CHARACTERS.contains(s[0]) && NUMERIC_CHARACTERS.contains(u[0])) {
PtBinbDescramblerA(s, u, image.width, image.height)
} else {
throw IOException("Cannot select descrambler for key pair s=$s, u=$u")
}
val descrambled = descrambler.descrambleImage(image) ?: imageData
return response.newBuilder()
.body(descrambled.toResponseBody(JPEG_MEDIA_TYPE))
.build()
}
}
private const val NUMERIC_CHARACTERS = "0123456789"
private val JPEG_MEDIA_TYPE = "image/jpeg".toMediaType()

View File

@@ -0,0 +1,197 @@
package eu.kanade.tachiyomi.lib.speedbinb
import eu.kanade.tachiyomi.lib.textinterceptor.TextInterceptorHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
/**
* SpeedBinb is a reader for various Japanese manga sites.
*
* Versions (`SpeedBinb.VERSION` in DevTools console):
* - Minimum version tested: `1.6650.0001`
* - Maximum version tested: `1.6930.1101`
*
* These versions are only for reference purposes, and does not reflect the actual range
* of versions this class can scrape.
*/
class SpeedBinbReader(
private val client: OkHttpClient,
private val headers: Headers,
private val json: Json,
private val highQualityMode: Boolean = false,
) {
private val isInterceptorAdded by lazy {
client.interceptors.filterIsInstance<SpeedBinbInterceptor>().isNotEmpty()
}
fun pageListParse(response: Response): List<Page> =
pageListParse(response.asJsoup())
fun pageListParse(document: Document): List<Page> {
// We throw here instead of in the `init {}` block because extensions that fail
// to load just mysteriously disappears from the extension list, no errors no nothing.
if (!isInterceptorAdded) {
throw Exception("SpeedBinbInterceptor was not added to the client.")
}
val readerUrl = document.location().toHttpUrl()
val content = document.selectFirst("#content")!!
if (!content.hasAttr("data-ptbinb")) {
return content.select("[data-ptimg]").mapIndexed { i, it ->
Page(i, imageUrl = it.absUrl("data-ptimg"))
}
}
val cid = content.attr("data-ptbinb-cid")
.ifEmpty { readerUrl.queryParameter("cid") }
?: throw Exception("Could not find chapter ID")
val sharedKey = generateSharedKey(cid)
val contentInfoUrl = content.absUrl("data-ptbinb").toHttpUrl().newBuilder()
.copyKeyParametersFrom(readerUrl)
.setQueryParameter("cid", cid)
.setQueryParameter("k", sharedKey)
.setQueryParameter("dmytime", System.currentTimeMillis().toString())
.build()
val contentInfo = client.newCall(GET(contentInfoUrl, headers)).execute().parseAs<BibContentInfo>()
if (contentInfo.result != 1) {
throw Exception("Failed to execute bibGetCntntInfo API.")
}
if (contentInfo.items.isEmpty()) {
throw Exception("There is no item.")
}
val contentItem = contentInfo.items[0]
val ctbl = json.decodeFromString<List<String>>(decodeScrambleTable(cid, sharedKey, contentItem.ctbl))
val ptbl = json.decodeFromString<List<String>>(decodeScrambleTable(cid, sharedKey, contentItem.ptbl))
val sbcUrl = contentItem.getSbcUrl(readerUrl, cid)
val sbcData = client.newCall(GET(sbcUrl, headers)).execute().body.string().let {
val raw = if (contentItem.serverType == ServerType.DIRECT) {
it.substringAfter("DataGet_Content(").substringBeforeLast(")")
} else {
it
}
json.decodeFromString<SBCContent>(raw)
}
if (sbcData.result != 1) {
throw Exception("Failed to fetch content")
}
val isSingleQuality = sbcData.imageClass == "singlequality"
val ttx = Jsoup.parseBodyFragment(sbcData.ttx, document.location())
val pageBaseUrl = when (contentItem.serverType) {
ServerType.DIRECT, ServerType.REST -> contentItem.contentServer
ServerType.SBC -> sbcUrl.replaceFirst("/sbcGetCntnt.php", "/sbcGetImg.php")
else -> throw UnsupportedOperationException("Unsupported ServerType value ${contentItem.serverType}")
}.toHttpUrl()
val pages = ttx.select("t-case:first-of-type t-img").mapIndexed { i, it ->
val src = it.attr("src")
val keyPair = determineKeyPair(src, ptbl, ctbl)
val fragment = "ptbinb,${keyPair.first},${keyPair.second}"
val imageUrl = pageBaseUrl.newBuilder()
.buildImageUrl(
readerUrl,
src,
contentItem,
isSingleQuality,
highQualityMode,
)
.fragment(fragment)
.toString()
Page(i, imageUrl = imageUrl)
}.toMutableList()
// This is probably the silliest use of TextInterceptor ever.
//
// If chapter purchases are enabled, and there's a link to purchase the current chapter,
// we add in the purchase URL as the last page.
val buyIconPosition = document.selectFirst("script:containsData(Config.LoginBuyIconPosition)")
?.data()
?.substringAfter("Config.LoginBuyIconPosition=")
?.substringBefore(";")
?.trim()
?: "-1"
val enableBuying = buyIconPosition != "-1"
if (enableBuying && contentItem.viewMode != ViewMode.COMMERCIAL && !contentItem.shopUrl.isNullOrEmpty()) {
pages.add(
Page(pages.size, imageUrl = TextInterceptorHelper.createUrl("", "購入: ${contentItem.shopUrl}")),
)
}
return pages
}
private inline fun <reified T> Response.parseAs(): T =
json.decodeFromString(body.string())
}
private fun HttpUrl.Builder.buildImageUrl(
readerUrl: HttpUrl,
src: String,
contentItem: BibContentItem,
isSingleQuality: Boolean,
highQualityMode: Boolean,
) = apply {
when (contentItem.serverType) {
ServerType.DIRECT -> {
val filename = when {
isSingleQuality -> "M.jpg"
highQualityMode -> "M_H.jpg"
else -> "M_L.jpg"
}
addPathSegments(src)
addPathSegment(filename)
contentItem.contentDate?.let { setQueryParameter("dmytime", it) }
}
ServerType.REST -> {
addPathSegment("img")
addPathSegments(src)
if (!isSingleQuality && !highQualityMode) {
setQueryParameter("q", "1")
}
contentItem.contentDate?.let { setQueryParameter("dmytime", it) }
copyKeyParametersFrom(readerUrl)
}
ServerType.SBC -> {
setQueryParameter("src", src)
contentItem.requestToken?.let { setQueryParameter("p", it) }
if (!isSingleQuality) {
setQueryParameter("q", if (highQualityMode) "0" else "1")
}
setQueryParameter("vm", contentItem.viewMode.toString())
contentItem.contentDate?.let { setQueryParameter("dmytime", it) }
copyKeyParametersFrom(readerUrl)
}
else -> throw UnsupportedOperationException("Unsupported ServerType value ${contentItem.serverType}")
}
}
internal fun HttpUrl.Builder.copyKeyParametersFrom(url: HttpUrl): HttpUrl.Builder {
for (i in 0..9) {
url.queryParameter("u$i")?.let {
setQueryParameter("u$i", it)
}
}
return this
}

View File

@@ -0,0 +1,301 @@
package eu.kanade.tachiyomi.lib.speedbinb.descrambler
import eu.kanade.tachiyomi.lib.speedbinb.PtImgTranslation
private val PTBINBF_REGEX = Regex("""^=([0-9]+)-([0-9]+)([-+])([0-9]+)-([-_0-9A-Za-z]+)$""")
private const val PTBINBF_CHAR_LOOKUP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
private const val PTBINBA_CHAR_LOOKUP = "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ"
abstract class PtBinbDescrambler(
val s: String,
val u: String,
val width: Int,
val height: Int,
) : SpeedBinbDescrambler()
class PtBinbDescramblerF(s: String, u: String, width: Int, height: Int) : PtBinbDescrambler(s, u, width, height) {
private var widthPieces: Int = 0
private var heightPieces: Int = 0
private var piecePadding: Int = 0
private lateinit var hDstPosLookup: List<Int>
private lateinit var wDstPosLookup: List<Int>
private lateinit var hPosLookup: List<Int>
private lateinit var wPosLookup: List<Int>
private var pieceDest: List<Int>? = null
init {
// Kotlin init blocks don't allow early returns...
init()
}
private fun init() {
val srcData = PTBINBF_REGEX.matchEntire(s)?.groupValues
val dstData = PTBINBF_REGEX.matchEntire(u)?.groupValues
if (
dstData == null ||
srcData == null ||
dstData[1] != srcData[1] ||
dstData[2] != srcData[2] ||
dstData[4] != srcData[4] ||
dstData[3] != "+" ||
srcData[3] != "-"
) {
return
}
widthPieces = dstData[1].toInt()
heightPieces = dstData[2].toInt()
piecePadding = dstData[4].toInt()
if (widthPieces < 8 || heightPieces < 8 || widthPieces * heightPieces < 64) {
return
}
val e = widthPieces + heightPieces + widthPieces * heightPieces
if (dstData[5].length != e || srcData[5].length != e) {
return
}
val srcTnp = decodePieceData(srcData[5])
val dstTnp = decodePieceData(dstData[5])
hDstPosLookup = dstTnp.hPos
wDstPosLookup = dstTnp.wPos
hPosLookup = srcTnp.hPos
wPosLookup = srcTnp.wPos
pieceDest = buildList(widthPieces * heightPieces) {
for (i in 0 until widthPieces * heightPieces) {
add(dstTnp.pieces[srcTnp.pieces[i]])
}
}
}
override fun isScrambled() =
pieceDest != null
override fun canDescramble(): Boolean {
val i = 2 * widthPieces * piecePadding
val n = 2 * heightPieces * piecePadding
return width >= 64 + i && height >= 64 + n && width * height >= (320 + i) * (320 + n)
}
override fun getCanvasDimensions(): Pair<Int, Int> {
return if (canDescramble()) {
Pair(
width - 2 * widthPieces * piecePadding,
height - 2 * heightPieces * piecePadding,
)
} else {
Pair(width, height)
}
}
override fun getDescrambleCoords(): List<PtImgTranslation> {
val pieceDest = this.pieceDest
if (!isScrambled() || pieceDest == null) {
return emptyList()
}
if (!canDescramble()) {
return listOf(
PtImgTranslation(0, 0, width, height, 0, 0),
)
}
val canvasWidth = width - 2 * widthPieces * piecePadding
val canvasHeight = height - 2 * heightPieces * piecePadding
val pieceWidth = (canvasWidth + widthPieces - 1).div(widthPieces)
val remainderWidth = canvasWidth - (widthPieces - 1) * pieceWidth
val pieceHeight = (canvasHeight + heightPieces - 1).div(heightPieces)
val remainderHeight = canvasHeight - (heightPieces - 1) * pieceHeight
return buildList(widthPieces * heightPieces) {
for (o in 0 until widthPieces * heightPieces) {
val hPos = o % widthPieces
val wPos = o.div(widthPieces)
val hDstPos = pieceDest[o] % widthPieces
val wDstPos = pieceDest[o].div(widthPieces)
add(
PtImgTranslation(
xsrc = piecePadding + hPos * (pieceWidth + 2 * piecePadding) + if (hPosLookup[wPos] < hPos) remainderWidth - pieceWidth else 0,
ysrc = piecePadding + wPos * (pieceHeight + 2 * piecePadding) + if (wPosLookup[hPos] < wPos) remainderHeight - pieceHeight else 0,
width = if (hPosLookup[wPos] == hPos) remainderWidth else pieceWidth,
height = if (wPosLookup[hPos] == wPos) remainderHeight else pieceHeight,
xdest = hDstPos * pieceWidth + if (hDstPosLookup[wDstPos] < hDstPos) remainderWidth - pieceWidth else 0,
ydest = wDstPos * pieceHeight + if (wDstPosLookup[hDstPos] < wDstPos) remainderHeight - pieceHeight else 0,
),
)
}
}
}
private fun decodePieceData(key: String): TNP {
val wPos = buildList(widthPieces) {
for (i in 0 until widthPieces) {
add(PTBINBF_CHAR_LOOKUP.indexOf(key[i]))
}
}
val hPos = buildList(heightPieces) {
for (i in 0 until heightPieces) {
add(PTBINBF_CHAR_LOOKUP.indexOf(key[widthPieces + i]))
}
}
val pieces = buildList(widthPieces * heightPieces) {
for (i in 0 until widthPieces * heightPieces) {
add(PTBINBF_CHAR_LOOKUP.indexOf(key[widthPieces + heightPieces + i]))
}
}
return TNP(wPos, hPos, pieces)
}
private class TNP(val wPos: List<Int>, val hPos: List<Int>, val pieces: List<Int>)
}
class PtBinbDescramblerA(s: String, u: String, width: Int, height: Int) : PtBinbDescrambler(s, u, width, height) {
private var srcPieces: PieceCollection? = null
private var dstPieces: PieceCollection? = null
init {
val srcPieces = calculatePieces(u)
val dstPieces = calculatePieces(s)
if (
srcPieces != null &&
dstPieces != null &&
srcPieces.ndx == dstPieces.ndx &&
srcPieces.ndy == dstPieces.ndy
) {
this.srcPieces = srcPieces
this.dstPieces = dstPieces
}
}
override fun isScrambled() =
srcPieces != null && dstPieces != null
override fun canDescramble(): Boolean =
width >= 64 && height >= 64 && width * height >= 102400
override fun getCanvasDimensions(): Pair<Int, Int> =
Pair(width, height)
override fun getDescrambleCoords(): List<PtImgTranslation> {
if (!isScrambled()) {
return emptyList()
}
if (!canDescramble()) {
return listOf(
PtImgTranslation(0, 0, width, height, 0, 0),
)
}
val srcPieces = this.srcPieces!!
val dstPieces = this.dstPieces!!
return buildList(srcPieces.piece.size + 2) {
val n = width - width % 8
val pieceWidth = (n - 1).div(7) - (n - 1).div(7) % 8
val e = n - 7 * pieceWidth
val s = height - height % 8
val pieceHeight = (s - 1).div(7) - (s - 1).div(7) % 8
val u = s - 7 * pieceHeight
for (i in srcPieces.piece.indices) {
val src = srcPieces.piece[i]
val dst = dstPieces.piece[i]
add(
PtImgTranslation(
xsrc = src.x.div(2) * pieceWidth + src.x % 2 * e,
ysrc = src.y.div(2) * pieceHeight + src.y % 2 * u,
width = src.w.div(2) * pieceWidth + src.w % 2 * e,
height = src.h.div(2) * pieceHeight + src.h % 2 * u,
xdest = dst.x.div(2) * pieceWidth + dst.x % 2 * e,
ydest = dst.y.div(2) * pieceHeight + dst.y % 2 * u,
),
)
}
val l = pieceWidth * (srcPieces.ndx - 1) + e
val v = pieceHeight * (srcPieces.ndy - 1) + u
if (l < width) {
add(
PtImgTranslation(l, 0, width - l, v, l, 0),
)
}
if (v < height) {
add(
PtImgTranslation(0, v, width, height - v, 0, v),
)
}
}
}
private fun calculatePieces(key: String): PieceCollection? {
if (key.isEmpty()) {
return null
}
val parts = key.split("-")
if (parts.size != 3) {
return null
}
val ndx = parts[0].toInt()
val ndy = parts[1].toInt()
val e = parts[2]
if (ndx * ndy * 2 != e.length) {
return null
}
val pieces = buildList(ndx * ndy) {
val a = (ndx - 1) * (ndy - 1) - 1
val f = ndx - 1 + a
val c = ndy - 1 + f
val l = 1 + c
var w = 0
var h = 0
for (d in 0 until ndx * ndy) {
val x = PTBINBA_CHAR_LOOKUP.indexOf(e[2 * d])
val y = PTBINBA_CHAR_LOOKUP.indexOf(e[2 * d + 1])
if (d <= a) {
h = 2
w = 2
} else if (d <= f) {
h = 1
w = 2
} else if (d <= c) {
h = 2
w = 1
} else if (d <= l) {
h = 1
w = 1
}
add(Piece(x, y, w, h))
}
}
return PieceCollection(ndx, ndy, pieces)
}
private class Piece(val x: Int, val y: Int, val w: Int, val h: Int)
private class PieceCollection(val ndx: Int, val ndy: Int, val piece: List<Piece>)
}

View File

@@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.lib.speedbinb.descrambler
import eu.kanade.tachiyomi.lib.speedbinb.PtImg
class PtImgDescrambler(private val metadata: PtImg) : SpeedBinbDescrambler() {
override fun isScrambled() = metadata.translations.isNotEmpty()
override fun canDescramble() = metadata.translations.isNotEmpty()
override fun getCanvasDimensions() = Pair(metadata.views[0].width, metadata.views[0].height)
override fun getDescrambleCoords() = metadata.translations
}

View File

@@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.lib.speedbinb.descrambler
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import eu.kanade.tachiyomi.lib.speedbinb.PtImgTranslation
import java.io.ByteArrayOutputStream
abstract class SpeedBinbDescrambler {
abstract fun isScrambled(): Boolean
abstract fun canDescramble(): Boolean
abstract fun getCanvasDimensions(): Pair<Int, Int>
abstract fun getDescrambleCoords(): List<PtImgTranslation>
open fun descrambleImage(image: Bitmap): ByteArray? {
if (!isScrambled()) {
return null
}
val (width, height) = getCanvasDimensions()
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
getDescrambleCoords().forEach {
val src = Rect(it.xsrc, it.ysrc, it.xsrc + it.width, it.ysrc + it.height)
val dst = Rect(it.xdest, it.ydest, it.xdest + it.width, it.ydest + it.height)
canvas.drawBitmap(image, src, dst, null)
}
return ByteArrayOutputStream()
.also {
result.compress(Bitmap.CompressFormat.JPEG, 90, it)
}
.toByteArray()
}
}

View File

@@ -1,22 +1,3 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
defaultConfig {
minSdk = AndroidConfig.minSdk
}
namespace = "eu.kanade.tachiyomi.lib.synchrony"
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.bundles.common)
id("lib-android")
}

File diff suppressed because one or more lines are too long

View File

@@ -39,4 +39,4 @@ object Deobfuscator {
}
// Update this when the script is updated!
private const val SCRIPT_NAME = "synchrony-v2.4.2.1.js"
private const val SCRIPT_NAME = "synchrony-v2.4.5.1.js"

View File

@@ -1,23 +1,3 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
defaultConfig {
minSdk = AndroidConfig.minSdk
}
namespace = "eu.kanade.tachiyomi.lib.textinterceptor"
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.kotlin.stdlib)
compileOnly(libs.okhttp)
id("lib-android")
}

View File

@@ -18,69 +18,73 @@ import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayOutputStream
// Designer values:
private const val WIDTH: Int = 1000
private const val X_PADDING: Float = 50f
private const val Y_PADDING: Float = 25f
private const val HEADING_FONT_SIZE: Float = 36f
private const val BODY_FONT_SIZE: Float = 30f
private const val SPACING_MULT: Float = 1.1f
private const val SPACING_ADD: Float = 2f
// No need to touch this one:
private const val HOST = TextInterceptorHelper.HOST
class TextInterceptor : Interceptor {
// With help from:
// https://github.com/tachiyomiorg/tachiyomi-extensions/pull/13304#issuecomment-1234532897
// https://medium.com/over-engineering/drawing-multiline-text-to-canvas-on-android-9b98f0bfa16a
companion object {
// Designer values:
private const val WIDTH: Int = 1000
private const val X_PADDING: Float = 50f
private const val Y_PADDING: Float = 25f
private const val HEADING_FONT_SIZE: Float = 36f
private const val BODY_FONT_SIZE: Float = 30f
private const val SPACING_MULT: Float = 1.1f
private const val SPACING_ADD: Float = 2f
// No need to touch this one:
private const val HOST = TextInterceptorHelper.HOST
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url
if (url.host != HOST) return chain.proceed(request)
val creator = textFixer("Author's Notes from ${url.pathSegments[0]}")
val story = textFixer(url.pathSegments[1])
val heading = url.pathSegments[0].takeIf { it.isNotEmpty() }?.let {
val title = textFixer(url.pathSegments[0])
// Heading
val paintHeading = TextPaint().apply {
color = Color.BLACK
textSize = HEADING_FONT_SIZE
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
// Heading
val paintHeading = TextPaint().apply {
color = Color.BLACK
textSize = HEADING_FONT_SIZE
typeface = Typeface.DEFAULT_BOLD
isAntiAlias = true
}
@Suppress("DEPRECATION")
StaticLayout(
title, paintHeading, (WIDTH - 2 * X_PADDING).toInt(),
Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true
)
}
@Suppress("DEPRECATION")
val heading = StaticLayout(
creator, paintHeading, (WIDTH - 2 * X_PADDING).toInt(),
Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true
)
val body = url.pathSegments[1].takeIf { it.isNotEmpty() }?.let {
val story = textFixer(it)
// Body
val paintBody = TextPaint().apply {
color = Color.BLACK
textSize = BODY_FONT_SIZE
typeface = Typeface.DEFAULT
isAntiAlias = true
// Body
val paintBody = TextPaint().apply {
color = Color.BLACK
textSize = BODY_FONT_SIZE
typeface = Typeface.DEFAULT
isAntiAlias = true
}
@Suppress("DEPRECATION")
StaticLayout(
story, paintBody, (WIDTH - 2 * X_PADDING).toInt(),
Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true
)
}
@Suppress("DEPRECATION")
val body = StaticLayout(
story, paintBody, (WIDTH - 2 * X_PADDING).toInt(),
Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true
)
// Image building
val imgHeight: Int = (heading.height + body.height + 2 * Y_PADDING).toInt()
val headingHeight = heading?.height ?: 0
val bodyHeight = body?.height ?: 0
val imgHeight: Int = (headingHeight + bodyHeight + 2 * Y_PADDING).toInt()
val bitmap: Bitmap = Bitmap.createBitmap(WIDTH, imgHeight, Bitmap.Config.ARGB_8888)
Canvas(bitmap).apply {
drawColor(Color.WHITE)
heading.draw(this, X_PADDING, Y_PADDING)
body.draw(this, X_PADDING, Y_PADDING + heading.height.toFloat())
heading?.draw(this, X_PADDING, Y_PADDING)
body?.draw(this, X_PADDING, Y_PADDING + headingHeight.toFloat())
}
// Image converting & returning
@@ -119,7 +123,7 @@ object TextInterceptorHelper {
const val HOST = "tachiyomi-lib-textinterceptor"
fun createUrl(creator: String, text: String): String {
return "http://$HOST/" + Uri.encode(creator) + "/" + Uri.encode(text)
fun createUrl(title: String, text: String): String {
return "http://$HOST/" + Uri.encode(title) + "/" + Uri.encode(text)
}
}

View File

@@ -1,12 +1,3 @@
plugins {
`java-library`
kotlin("jvm")
}
repositories {
mavenCentral()
}
dependencies {
compileOnly(libs.kotlin.stdlib)
id("lib-android")
}

View File

@@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
compileOnly("com.github.tachiyomiorg:image-decoder:e08e9be535")
}