Initial commit
This commit is contained in:
22
lib/cryptoaes/build.gradle.kts
Normal file
22
lib/cryptoaes/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package eu.kanade.tachiyomi.lib.cryptoaes
|
||||
// Thanks to Vlad on Stackoverflow: https://stackoverflow.com/a/63701411
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.MessageDigest
|
||||
import java.util.Arrays
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Conforming with CryptoJS AES method
|
||||
*/
|
||||
object CryptoAES {
|
||||
|
||||
private const val KEY_SIZE = 256
|
||||
private const val IV_SIZE = 128
|
||||
private const val HASH_CIPHER = "AES/CBC/PKCS7PADDING"
|
||||
private const val AES = "AES"
|
||||
private const val KDF_DIGEST = "MD5"
|
||||
|
||||
/**
|
||||
* Decrypt using CryptoJS defaults compatible method.
|
||||
* Uses KDF equivalent to OpenSSL's EVP_BytesToKey function
|
||||
*
|
||||
* http://stackoverflow.com/a/29152379/4405051
|
||||
* @param cipherText base64 encoded ciphertext
|
||||
* @param password passphrase
|
||||
*/
|
||||
fun decrypt(cipherText: String, password: String): String {
|
||||
return try {
|
||||
val ctBytes = Base64.decode(cipherText, Base64.DEFAULT)
|
||||
val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16)
|
||||
val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size)
|
||||
val md5: MessageDigest = MessageDigest.getInstance("MD5")
|
||||
val keyAndIV = generateKeyAndIV(32, 16, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5)
|
||||
decryptAES(cipherTextBytes,
|
||||
keyAndIV?.get(0) ?: ByteArray(32),
|
||||
keyAndIV?.get(1) ?: ByteArray(16))
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt using CryptoJS defaults compatible method.
|
||||
*
|
||||
* @param cipherText base64 encoded ciphertext
|
||||
* @param keyBytes key as a bytearray
|
||||
* @param ivBytes iv as a bytearray
|
||||
*/
|
||||
fun decrypt(cipherText: String, keyBytes: ByteArray, ivBytes: ByteArray): String {
|
||||
return try {
|
||||
val cipherTextBytes = Base64.decode(cipherText, Base64.DEFAULT)
|
||||
decryptAES(cipherTextBytes, keyBytes, ivBytes)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt using CryptoJS defaults compatible method.
|
||||
*
|
||||
* @param cipherTextBytes encrypted text as a bytearray
|
||||
* @param keyBytes key as a bytearray
|
||||
* @param ivBytes iv as a bytearray
|
||||
*/
|
||||
private fun decryptAES(cipherTextBytes: ByteArray, keyBytes: ByteArray, ivBytes: ByteArray): String {
|
||||
return try {
|
||||
val cipher = Cipher.getInstance(HASH_CIPHER)
|
||||
val keyS = SecretKeySpec(keyBytes, AES)
|
||||
cipher.init(Cipher.DECRYPT_MODE, keyS, IvParameterSpec(ivBytes))
|
||||
cipher.doFinal(cipherTextBytes).toString(Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a key and an initialization vector (IV) with the given salt and password.
|
||||
*
|
||||
* https://stackoverflow.com/a/41434590
|
||||
* This method is equivalent to OpenSSL's EVP_BytesToKey function
|
||||
* (see https://github.com/openssl/openssl/blob/master/crypto/evp/evp_key.c).
|
||||
* By default, OpenSSL uses a single iteration, MD5 as the algorithm and UTF-8 encoded password data.
|
||||
*
|
||||
* @param keyLength the length of the generated key (in bytes)
|
||||
* @param ivLength the length of the generated IV (in bytes)
|
||||
* @param iterations the number of digestion rounds
|
||||
* @param salt the salt data (8 bytes of data or `null`)
|
||||
* @param password the password data (optional)
|
||||
* @param md the message digest algorithm to use
|
||||
* @return an two-element array with the generated key and IV
|
||||
*/
|
||||
@Suppress("SameParameterValue")
|
||||
private fun generateKeyAndIV(keyLength: Int, ivLength: Int, iterations: Int, salt: ByteArray, password: ByteArray, md: MessageDigest): Array<ByteArray?>? {
|
||||
val digestLength = md.digestLength
|
||||
val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength
|
||||
val generatedData = ByteArray(requiredLength)
|
||||
var generatedLength = 0
|
||||
return try {
|
||||
md.reset()
|
||||
|
||||
// Repeat process until sufficient data has been generated
|
||||
while (generatedLength < keyLength + ivLength) {
|
||||
|
||||
// Digest data (last digest if available, password data, salt if available)
|
||||
if (generatedLength > 0) md.update(generatedData, generatedLength - digestLength, digestLength)
|
||||
md.update(password)
|
||||
md.update(salt, 0, 8)
|
||||
md.digest(generatedData, generatedLength, digestLength)
|
||||
|
||||
// additional rounds
|
||||
for (i in 1 until iterations) {
|
||||
md.update(generatedData, generatedLength, digestLength)
|
||||
md.digest(generatedData, generatedLength, digestLength)
|
||||
}
|
||||
generatedLength += digestLength
|
||||
}
|
||||
|
||||
// Copy key and IV into separate byte arrays
|
||||
val result = arrayOfNulls<ByteArray>(2)
|
||||
result[0] = generatedData.copyOfRange(0, keyLength)
|
||||
if (ivLength > 0) result[1] = generatedData.copyOfRange(keyLength, keyLength + ivLength)
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
} finally {
|
||||
// Clean out temporary data
|
||||
Arrays.fill(generatedData, 0.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package eu.kanade.tachiyomi.lib.cryptoaes
|
||||
/**
|
||||
* Helper class to deobfuscate JavaScript strings encoded in JSFuck style.
|
||||
*
|
||||
* More info on JSFuck found [here](https://en.wikipedia.org/wiki/JSFuck).
|
||||
*
|
||||
* Currently only supports Numeric and decimal ('.') characters
|
||||
*/
|
||||
object Deobfuscator {
|
||||
fun deobfuscateJsPassword(inputString: String): String {
|
||||
var idx = 0
|
||||
val brackets = listOf('[', '(')
|
||||
val evaluatedString = StringBuilder()
|
||||
while (idx < inputString.length) {
|
||||
val chr = inputString[idx]
|
||||
if (chr !in brackets) {
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
val closingIndex = getMatchingBracketIndex(idx, inputString)
|
||||
if (chr == '[') {
|
||||
val digit = calculateDigit(inputString.substring(idx, closingIndex))
|
||||
evaluatedString.append(digit)
|
||||
} else {
|
||||
evaluatedString.append('.')
|
||||
if (inputString.getOrNull(closingIndex + 1) == '[') {
|
||||
val skippingIndex = getMatchingBracketIndex(closingIndex + 1, inputString)
|
||||
idx = skippingIndex + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
idx = closingIndex + 1
|
||||
}
|
||||
return evaluatedString.toString()
|
||||
}
|
||||
|
||||
private fun getMatchingBracketIndex(openingIndex: Int, inputString: String): Int {
|
||||
val openingBracket = inputString[openingIndex]
|
||||
val closingBracket = when (openingBracket) {
|
||||
'[' -> ']'
|
||||
else -> ')'
|
||||
}
|
||||
var counter = 0
|
||||
for (idx in openingIndex until inputString.length) {
|
||||
if (inputString[idx] == openingBracket) counter++
|
||||
if (inputString[idx] == closingBracket) counter--
|
||||
|
||||
if (counter == 0) return idx // found matching bracket
|
||||
if (counter < 0) return -1 // unbalanced brackets
|
||||
}
|
||||
return -1 // matching bracket not found
|
||||
}
|
||||
|
||||
private fun calculateDigit(inputSubString: String): Char {
|
||||
/* 0 == '+[]'
|
||||
1 == '+!+[]'
|
||||
2 == '!+[]+!+[]'
|
||||
3 == '!+[]+!+[]+!+[]'
|
||||
...
|
||||
therefore '!+[]' count equals the digit
|
||||
if count equals 0, check for '+[]' just to be sure
|
||||
*/
|
||||
val digit = "!\\+\\[]".toRegex().findAll(inputSubString).count() // matches '!+[]'
|
||||
if (digit == 0) {
|
||||
if ("\\+\\[]".toRegex().findAll(inputSubString).count() == 1) { // matches '+[]'
|
||||
return '0'
|
||||
}
|
||||
} else if (digit in 1..9) {
|
||||
return digit.digitToChar()
|
||||
}
|
||||
return '-' // Illegal digit
|
||||
}
|
||||
}
|
||||
24
lib/dataimage/build.gradle.kts
Normal file
24
lib/dataimage/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package eu.kanade.tachiyomi.lib.dataimage
|
||||
|
||||
import android.util.Base64
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
/**
|
||||
* If a source provides images via a data:image string instead of a URL, use these functions and interceptor
|
||||
*/
|
||||
|
||||
/**
|
||||
* Use if the attribute tag could have a data:image string or URL
|
||||
* Transforms data:image in to a fake URL that OkHttp won't die on
|
||||
*/
|
||||
fun Element.dataImageAsUrl(attr: String): String {
|
||||
return if (this.attr(attr).startsWith("data")) {
|
||||
"https://127.0.0.1/?" + this.attr(attr).substringAfter(":")
|
||||
} else {
|
||||
this.attr("abs:$attr")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use if the attribute tag has a data:image string but real URLs are on a different attribute
|
||||
*/
|
||||
fun Element.dataImageAsUrlOrNull(attr: String): String? {
|
||||
return if (this.attr(attr).startsWith("data")) {
|
||||
"https://127.0.0.1/?" + this.attr(attr).substringAfter(":")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interceptor that detects the URLs we created with the above functions, base64 decodes the data if necessary,
|
||||
* and builds a response with a valid image that Tachiyomi can display
|
||||
*/
|
||||
class DataImageInterceptor : Interceptor {
|
||||
private val mediaTypePattern = Regex("""(^[^;,]*)[;,]""")
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val url = chain.request().url.toString()
|
||||
return if (url.startsWith("https://127.0.0.1/?image")) {
|
||||
val dataString = url.substringAfter("?")
|
||||
val byteArray = if (dataString.contains("base64")) {
|
||||
Base64.decode(dataString.substringAfter("base64,"), Base64.DEFAULT)
|
||||
} else {
|
||||
dataString.substringAfter(",").toByteArray()
|
||||
}
|
||||
val mediaType = mediaTypePattern.find(dataString)!!.value.toMediaTypeOrNull()
|
||||
Response.Builder().body(byteArray.toResponseBody(mediaType))
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_0)
|
||||
.code(200)
|
||||
.message("")
|
||||
.build()
|
||||
} else {
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/i18n/build.gradle.kts
Normal file
22
lib/i18n/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
93
lib/i18n/src/main/java/eu/kanade/tachiyomi/lib/i18n/Intl.kt
Normal file
93
lib/i18n/src/main/java/eu/kanade/tachiyomi/lib/i18n/Intl.kt
Normal file
@@ -0,0 +1,93 @@
|
||||
package eu.kanade.tachiyomi.lib.i18n
|
||||
|
||||
import org.jetbrains.annotations.PropertyKey
|
||||
import java.io.InputStreamReader
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
import java.util.PropertyResourceBundle
|
||||
|
||||
/**
|
||||
* A simple wrapper to make internationalization easier to use in sources.
|
||||
*
|
||||
* Message files should be put in the `assets/i18n` folder, with the name
|
||||
* `messages_{iso_639_1}.properties`, where `iso_639_1` should be using
|
||||
* snake case and be in lowercase.
|
||||
*
|
||||
* To edit the strings, use the official JetBrain's
|
||||
* [Resource Bundle Editor plugin](https://plugins.jetbrains.com/plugin/17035-resource-bundle-editor).
|
||||
*
|
||||
* Make sure to configure Android Studio to save Properties files as UTF-8 as well.
|
||||
* You can refer to this [documentation](https://www.jetbrains.com/help/idea/properties-files.html#1cbc434e)
|
||||
* on how to do so.
|
||||
*/
|
||||
class Intl(
|
||||
language: String,
|
||||
availableLanguages: Set<String>,
|
||||
private val baseLanguage: String,
|
||||
private val classLoader: ClassLoader,
|
||||
private val createMessageFileName: (String) -> String = { createDefaultMessageFileName(it) }
|
||||
) {
|
||||
|
||||
val chosenLanguage: String = when (language) {
|
||||
in availableLanguages -> language
|
||||
else -> baseLanguage
|
||||
}
|
||||
|
||||
private val locale: Locale = Locale.forLanguageTag(chosenLanguage)
|
||||
|
||||
val collator: Collator = Collator.getInstance(locale)
|
||||
|
||||
private val baseBundle: PropertyResourceBundle by lazy { createBundle(baseLanguage) }
|
||||
|
||||
private val bundle: PropertyResourceBundle by lazy {
|
||||
if (chosenLanguage == baseLanguage) baseBundle else createBundle(chosenLanguage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string from the message file. If the [key] is not present
|
||||
* in the current language, the English value will be returned. If the [key]
|
||||
* is also not present in English, the [key] surrounded by brackets will be returned.
|
||||
*/
|
||||
@Suppress("InvalidBundleOrProperty")
|
||||
operator fun get(@PropertyKey(resourceBundle = "i18n.messages") key: String): String = when {
|
||||
bundle.containsKey(key) -> bundle.getString(key)
|
||||
baseBundle.containsKey(key) -> baseBundle.getString(key)
|
||||
else -> "[$key]"
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the string as a format string and returns a string obtained by
|
||||
* substituting the specified arguments, using the instance locale.
|
||||
*/
|
||||
@Suppress("InvalidBundleOrProperty")
|
||||
fun format(@PropertyKey(resourceBundle = "i18n.messages") key: String, vararg args: Any?) =
|
||||
get(key).format(locale, *args)
|
||||
|
||||
fun languageDisplayName(localeCode: String): String =
|
||||
Locale.forLanguageTag(localeCode)
|
||||
.getDisplayName(locale)
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||
|
||||
/**
|
||||
* Creates a [PropertyResourceBundle] instance from the language specified.
|
||||
* The expected message file will be loaded from the `res/raw`.
|
||||
*
|
||||
* The [PropertyResourceBundle] is used directly instead of [java.util.ResourceBundle]
|
||||
* because the later has issues with UTF-8 files in Java 8, which would need
|
||||
* the message files to be saved in ISO-8859-1, making the file readability bad.
|
||||
*/
|
||||
private fun createBundle(lang: String): PropertyResourceBundle {
|
||||
val fileName = createMessageFileName(lang)
|
||||
val fileContent = classLoader.getResourceAsStream(fileName)
|
||||
|
||||
return PropertyResourceBundle(InputStreamReader(fileContent, "UTF-8"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createDefaultMessageFileName(lang: String): String {
|
||||
val langSnakeCase = lang.replace("-", "_").lowercase()
|
||||
|
||||
return "assets/i18n/messages_$langSnakeCase.properties"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
lib/randomua/build.gradle.kts
Normal file
23
lib/randomua/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package eu.kanade.tachiyomi.lib.randomua
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
private class RandomUserAgentInterceptor(
|
||||
private val userAgentType: UserAgentType,
|
||||
private val customUA: String?,
|
||||
private val filterInclude: List<String>,
|
||||
private val filterExclude: List<String>,
|
||||
) : Interceptor {
|
||||
|
||||
private var userAgent: String? = null
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val network: NetworkHelper by injectLazy()
|
||||
|
||||
private val client = network.client
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
try {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val newUserAgent = getUserAgent()
|
||||
?: return chain.proceed(originalRequest)
|
||||
|
||||
val originalHeaders = originalRequest.headers
|
||||
|
||||
val modifiedHeaders = originalHeaders.newBuilder()
|
||||
.set("User-Agent", newUserAgent)
|
||||
.build()
|
||||
|
||||
return chain.proceed(
|
||||
originalRequest.newBuilder()
|
||||
.headers(modifiedHeaders)
|
||||
.build()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IOException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUserAgent(): String? {
|
||||
if (userAgentType == UserAgentType.OFF) {
|
||||
return customUA?.ifBlank { null }
|
||||
}
|
||||
|
||||
if (!userAgent.isNullOrEmpty()) return userAgent
|
||||
|
||||
val uaResponse = client.newCall(GET(UA_DB_URL)).execute()
|
||||
|
||||
if (!uaResponse.isSuccessful) {
|
||||
uaResponse.close()
|
||||
return null
|
||||
}
|
||||
|
||||
val userAgentList = uaResponse.use { json.decodeFromString<UserAgentList>(it.body.string()) }
|
||||
|
||||
return when (userAgentType) {
|
||||
UserAgentType.DESKTOP -> userAgentList.desktop
|
||||
UserAgentType.MOBILE -> userAgentList.mobile
|
||||
else -> error("Expected UserAgentType.DESKTOP or UserAgentType.MOBILE but got UserAgentType.${userAgentType.name} instead")
|
||||
}
|
||||
.filter {
|
||||
filterInclude.isEmpty() || filterInclude.any { filter ->
|
||||
it.contains(filter, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
.filterNot {
|
||||
filterExclude.any { filter ->
|
||||
it.contains(filter, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
.randomOrNull()
|
||||
.also { userAgent = it }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val UA_DB_URL = "https://tachiyomiorg.github.io/user-agents/user-agents.json"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to add a latest random user agent interceptor.
|
||||
* The interceptor will added at the first position in the chain,
|
||||
* so the CloudflareInterceptor in the app will be able to make usage of it.
|
||||
*
|
||||
* @param userAgentType User Agent type one of (DESKTOP, MOBILE, OFF)
|
||||
* @param customUA Optional custom user agent used when userAgentType is OFF
|
||||
* @param filterInclude Filter to only include User Agents containing these strings
|
||||
* @param filterExclude Filter to exclude User Agents containing these strings
|
||||
*/
|
||||
fun OkHttpClient.Builder.setRandomUserAgent(
|
||||
userAgentType: UserAgentType,
|
||||
customUA: String? = null,
|
||||
filterInclude: List<String> = emptyList(),
|
||||
filterExclude: List<String> = emptyList(),
|
||||
) = apply {
|
||||
interceptors().add(0, RandomUserAgentInterceptor(userAgentType, customUA, filterInclude, filterExclude))
|
||||
}
|
||||
|
||||
enum class UserAgentType {
|
||||
MOBILE,
|
||||
DESKTOP,
|
||||
OFF
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class UserAgentList(
|
||||
val desktop: List<String>,
|
||||
val mobile: List<String>
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.kanade.tachiyomi.lib.randomua
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import okhttp3.Headers
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to return UserAgentType based on SharedPreference value
|
||||
*/
|
||||
fun SharedPreferences.getPrefUAType(): UserAgentType {
|
||||
return when (getString(PREF_KEY_RANDOM_UA, "off")) {
|
||||
"mobile" -> UserAgentType.MOBILE
|
||||
"desktop" -> UserAgentType.DESKTOP
|
||||
else -> UserAgentType.OFF
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to return custom UserAgent from SharedPreference
|
||||
*/
|
||||
fun SharedPreferences.getPrefCustomUA(): String? {
|
||||
return getString(PREF_KEY_CUSTOM_UA, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to add Random User-Agent settings to SharedPreference
|
||||
*
|
||||
* @param screen, PreferenceScreen from `setupPreferenceScreen`
|
||||
*/
|
||||
fun addRandomUAPreferenceToScreen(
|
||||
screen: PreferenceScreen,
|
||||
) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_KEY_RANDOM_UA
|
||||
title = TITLE_RANDOM_UA
|
||||
entries = RANDOM_UA_ENTRIES
|
||||
entryValues = RANDOM_UA_VALUES
|
||||
summary = "%s"
|
||||
setDefaultValue("off")
|
||||
}.also(screen::addPreference)
|
||||
|
||||
EditTextPreference(screen.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()
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Toast.makeText(screen.context, "User Agent invalid:${e.message}", Toast.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
const val TITLE_RANDOM_UA = "Random User-Agent (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 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)"
|
||||
|
||||
22
lib/synchrony/build.gradle.kts
Normal file
22
lib/synchrony/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
289
lib/synchrony/src/main/assets/synchrony-v2.4.2.1.js
Normal file
289
lib/synchrony/src/main/assets/synchrony-v2.4.2.1.js
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,42 @@
|
||||
package eu.kanade.tachiyomi.lib.synchrony
|
||||
|
||||
import app.cash.quickjs.QuickJs
|
||||
|
||||
/**
|
||||
* Helper class to deobfuscate JavaScript strings with synchrony.
|
||||
*/
|
||||
object Deobfuscator {
|
||||
fun deobfuscateScript(source: String): String? {
|
||||
val originalScript = javaClass.getResource("/assets/$SCRIPT_NAME")
|
||||
?.readText() ?: return null
|
||||
|
||||
// Sadly needed until QuickJS properly supports module imports:
|
||||
// Regex for finding one and two in "export{one as Deobfuscator,two as Transformer};"
|
||||
val regex = """export\{(.*) as Deobfuscator,(.*) as Transformer\};""".toRegex()
|
||||
val synchronyScript = regex.find(originalScript)?.let { match ->
|
||||
val (deob, trans) = match.destructured
|
||||
val replacement = "const Deobfuscator = $deob, Transformer = $trans;"
|
||||
originalScript.replace(match.value, replacement)
|
||||
} ?: return null
|
||||
|
||||
return QuickJs.create().use { engine ->
|
||||
engine.evaluate("globalThis.console = { log: () => {}, warn: () => {}, error: () => {}, trace: () => {} };")
|
||||
engine.evaluate(synchronyScript)
|
||||
|
||||
engine.set(
|
||||
"source", TestInterface::class.java,
|
||||
object : TestInterface {
|
||||
override fun getValue() = source
|
||||
},
|
||||
)
|
||||
engine.evaluate("new Deobfuscator().deobfuscateSource(source.getValue())") as? String
|
||||
}
|
||||
}
|
||||
|
||||
private interface TestInterface {
|
||||
fun getValue(): String
|
||||
}
|
||||
}
|
||||
|
||||
// Update this when the script is updated!
|
||||
private const val SCRIPT_NAME = "synchrony-v2.4.2.1.js"
|
||||
23
lib/textinterceptor/build.gradle.kts
Normal file
23
lib/textinterceptor/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package eu.kanade.tachiyomi.lib.textinterceptor
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.text.Html
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
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])
|
||||
|
||||
// Heading
|
||||
val paintHeading = TextPaint().apply {
|
||||
color = Color.BLACK
|
||||
textSize = HEADING_FONT_SIZE
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val heading = StaticLayout(
|
||||
creator, paintHeading, (WIDTH - 2 * X_PADDING).toInt(),
|
||||
Layout.Alignment.ALIGN_NORMAL, SPACING_MULT, SPACING_ADD, true
|
||||
)
|
||||
|
||||
// Body
|
||||
val paintBody = TextPaint().apply {
|
||||
color = Color.BLACK
|
||||
textSize = BODY_FONT_SIZE
|
||||
typeface = Typeface.DEFAULT
|
||||
isAntiAlias = 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 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())
|
||||
}
|
||||
|
||||
// Image converting & returning
|
||||
val stream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
|
||||
val responseBody = stream.toByteArray().toResponseBody("image/png".toMediaType())
|
||||
return Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body(responseBody)
|
||||
.build()
|
||||
}
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private fun textFixer(htmlString: String): String {
|
||||
return if (Build.VERSION.SDK_INT >= 24) {
|
||||
Html.fromHtml(htmlString , Html.FROM_HTML_MODE_LEGACY).toString()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Html.fromHtml(htmlString).toString()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) {
|
||||
canvas.save()
|
||||
canvas.translate(x, y)
|
||||
this.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
object TextInterceptorHelper {
|
||||
|
||||
const val HOST = "tachiyomi-lib-textinterceptor"
|
||||
|
||||
fun createUrl(creator: String, text: String): String {
|
||||
return "http://$HOST/" + Uri.encode(creator) + "/" + Uri.encode(text)
|
||||
}
|
||||
}
|
||||
12
lib/unpacker/build.gradle.kts
Normal file
12
lib/unpacker/build.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
`java-library`
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(libs.kotlin.stdlib)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.lib.unpacker
|
||||
|
||||
/**
|
||||
* A helper class to extract substrings efficiently.
|
||||
*
|
||||
* Note that all methods move [startIndex] over the ending delimiter.
|
||||
*/
|
||||
class SubstringExtractor(private val text: String) {
|
||||
private var startIndex = 0
|
||||
|
||||
fun skipOver(str: String) {
|
||||
val index = text.indexOf(str, startIndex)
|
||||
if (index == -1) return
|
||||
startIndex = index + str.length
|
||||
}
|
||||
|
||||
fun substringBefore(str: String): String {
|
||||
val index = text.indexOf(str, startIndex)
|
||||
if (index == -1) return ""
|
||||
val result = text.substring(startIndex, index)
|
||||
startIndex = index + str.length
|
||||
return result
|
||||
}
|
||||
|
||||
fun substringBetween(left: String, right: String): String {
|
||||
val index = text.indexOf(left, startIndex)
|
||||
if (index == -1) return ""
|
||||
val leftIndex = index + left.length
|
||||
val rightIndex = text.indexOf(right, leftIndex)
|
||||
if (rightIndex == -1) return ""
|
||||
startIndex = rightIndex + right.length
|
||||
return text.substring(leftIndex, rightIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package eu.kanade.tachiyomi.lib.unpacker
|
||||
|
||||
/**
|
||||
* Helper class to unpack JavaScript code compressed by [packer](http://dean.edwards.name/packer/).
|
||||
*
|
||||
* Source code of packer can be found [here](https://github.com/evanw/packer/blob/master/packer.js).
|
||||
*/
|
||||
object Unpacker {
|
||||
|
||||
/**
|
||||
* Unpacks JavaScript code compressed by packer.
|
||||
*
|
||||
* Specify [left] and [right] to unpack only the data between them.
|
||||
*
|
||||
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
|
||||
*/
|
||||
fun unpack(script: String, left: String? = null, right: String? = null): String =
|
||||
unpack(SubstringExtractor(script), left, right)
|
||||
|
||||
/**
|
||||
* Unpacks JavaScript code compressed by packer.
|
||||
*
|
||||
* Specify [left] and [right] to unpack only the data between them.
|
||||
*
|
||||
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
|
||||
*/
|
||||
fun unpack(script: SubstringExtractor, left: String? = null, right: String? = null): String {
|
||||
val packed = script
|
||||
.substringBetween("}('", ".split('|'),0,{}))")
|
||||
.replace("\\'", "\"")
|
||||
|
||||
val parser = SubstringExtractor(packed)
|
||||
val data: String
|
||||
if (left != null && right != null) {
|
||||
data = parser.substringBetween(left, right)
|
||||
parser.skipOver("',")
|
||||
} else {
|
||||
data = parser.substringBefore("',")
|
||||
}
|
||||
if (data.isEmpty()) return ""
|
||||
|
||||
val dictionary = parser.substringBetween("'", "'").split("|")
|
||||
val size = dictionary.size
|
||||
|
||||
return wordRegex.replace(data) {
|
||||
val key = it.value
|
||||
val index = parseRadix62(key)
|
||||
if (index >= size) return@replace key
|
||||
dictionary[index].ifEmpty { key }
|
||||
}
|
||||
}
|
||||
|
||||
private val wordRegex by lazy { Regex("""\w+""") }
|
||||
|
||||
private fun parseRadix62(str: String): Int {
|
||||
var result = 0
|
||||
for (ch in str.toCharArray()) {
|
||||
result = result * 62 + when {
|
||||
ch.code <= '9'.code -> { // 0-9
|
||||
ch.code - '0'.code
|
||||
}
|
||||
|
||||
ch.code >= 'a'.code -> { // a-z
|
||||
// ch - 'a' + 10
|
||||
ch.code - ('a'.code - 10)
|
||||
}
|
||||
|
||||
else -> { // A-Z
|
||||
// ch - 'A' + 36
|
||||
ch.code - ('A'.code - 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user