diff --git a/lib/cryptoaes/build.gradle.kts b/lib/cryptoaes/build.gradle.kts new file mode 100644 index 000000000..af3736596 --- /dev/null +++ b/lib/cryptoaes/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +android { + compileSdk = AndroidConfig.compileSdk + namespace = "eu.kanade.tachiyomi.lib.cryptoaes" + + defaultConfig { + minSdk = AndroidConfig.minSdk + targetSdk = AndroidConfig.targetSdk + } +} + +dependencies { + compileOnly(libs.kotlin.stdlib) +} diff --git a/lib/cryptoaes/src/main/java/eu/kanade/tachiyomi/lib/cryptoaes/CryptoAES.kt b/lib/cryptoaes/src/main/java/eu/kanade/tachiyomi/lib/cryptoaes/CryptoAES.kt new file mode 100644 index 000000000..14a9057af --- /dev/null +++ b/lib/cryptoaes/src/main/java/eu/kanade/tachiyomi/lib/cryptoaes/CryptoAES.kt @@ -0,0 +1,154 @@ +package eu.kanade.tachiyomi.lib.cryptoaes + +/* + * Copyright (C) The Tachiyomi Open Source Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// 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 + */ +@Suppress("unused", "FunctionName") +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 HASH_CIPHER_FALLBACK = "AES/CBC/PKCS5PADDING" + 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 { + 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) + return decryptAES( + cipherTextBytes, + keyAndIV?.get(0) ?: ByteArray(32), + keyAndIV?.get(1) ?: ByteArray(16), + ) + } catch (e: Exception) { + return "" + } + } + + /** + * 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 = try { + Cipher.getInstance(HASH_CIPHER) + } catch (e: Throwable) { Cipher.getInstance(HASH_CIPHER_FALLBACK) } + 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 + */ + private fun generateKeyAndIV(keyLength: Int, ivLength: Int, iterations: Int, salt: ByteArray, password: ByteArray, md: MessageDigest): Array? { + 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(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()) + } + } + + // Stolen from AnimixPlay(EN) / GogoCdnExtractor + fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } +} diff --git a/lib/cryptoaes/src/main/java/eu/kanade/tachiyomi/lib/cryptoaes/Deobfuscator.kt b/lib/cryptoaes/src/main/java/eu/kanade/tachiyomi/lib/cryptoaes/Deobfuscator.kt new file mode 100644 index 000000000..8c584bc48 --- /dev/null +++ b/lib/cryptoaes/src/main/java/eu/kanade/tachiyomi/lib/cryptoaes/Deobfuscator.kt @@ -0,0 +1,82 @@ +package eu.kanade.tachiyomi.lib.cryptoaes + +/* + * Copyright (C) The Tachiyomi Open Source Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * 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('[', '(') + var 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 + } +} diff --git a/src/en/kickassanime/AndroidManifest.xml b/src/en/kickassanime/AndroidManifest.xml index acb4de356..5fa512c5c 100644 --- a/src/en/kickassanime/AndroidManifest.xml +++ b/src/en/kickassanime/AndroidManifest.xml @@ -1,2 +1,24 @@ - \ No newline at end of file + + + + + + + + + + + + + + + diff --git a/src/en/kickassanime/build.gradle b/src/en/kickassanime/build.gradle index c4caddb37..3f7e8338b 100644 --- a/src/en/kickassanime/build.gradle +++ b/src/en/kickassanime/build.gradle @@ -1,18 +1,18 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlinx-serialization' +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) +} ext { extName = 'KickAssAnime' pkgNameSuffix = 'en.kickassanime' extClass = '.KickAssAnime' - extVersionCode = 21 - libVersion = '13' + extVersionCode = 22 } dependencies { - implementation(project(':lib-streamsb-extractor')) - implementation(project(':lib-dood-extractor')) + implementation(project(":lib-cryptoaes")) } apply from: "$rootDir/common.gradle" diff --git a/src/en/kickassanime/res/web_hi_res_512.png b/src/en/kickassanime/res/web_hi_res_512.png deleted file mode 100644 index 0c3866109..000000000 Binary files a/src/en/kickassanime/res/web_hi_res_512.png and /dev/null differ diff --git a/src/en/kickassanime/src/eu/kanade/tachiyomi/animeextension/en/kickassanime/JSONUtil.java b/src/en/kickassanime/src/eu/kanade/tachiyomi/animeextension/en/kickassanime/JSONUtil.java deleted file mode 100644 index 11d506f47..000000000 --- a/src/en/kickassanime/src/eu/kanade/tachiyomi/animeextension/en/kickassanime/JSONUtil.java +++ /dev/null @@ -1,88 +0,0 @@ -package eu.kanade.tachiyomi.animeextension.en.kickassanime; - -public class JSONUtil { - public static String escape(String input) { - StringBuilder output = new StringBuilder(); - - for(int i=0; i= 0x10000) { - assert false : "Java stores as u16, so it should never give us a character that's bigger than 2 bytes. It literally can't."; - } else if(chx > 127) { - output.append(String.format("\\u%04x", chx)); - } else { - output.append(ch); - } - } - - return output.toString(); - } - - public static String unescape(String input) { - StringBuilder builder = new StringBuilder(); - - int i = 0; - while (i < input.length()) { - char delimiter = input.charAt(i); i++; // consume letter or backslash - - if(delimiter == '\\' && i < input.length()) { - - // consume first after backslash - char ch = input.charAt(i); i++; - - if(ch == '\\' || ch == '/' || ch == '"' || ch == '\'') { - builder.append(ch); - } - else if(ch == 'n') builder.append('\n'); - else if(ch == 'r') builder.append('\r'); - else if(ch == 't') builder.append('\t'); - else if(ch == 'b') builder.append('\b'); - else if(ch == 'f') builder.append('\f'); - else if(ch == 'u') { - - StringBuilder hex = new StringBuilder(); - - // expect 4 digits - if (i+4 > input.length()) { - throw new RuntimeException("Not enough unicode digits! "); - } - for (char x : input.substring(i, i + 4).toCharArray()) { - if(!Character.isLetterOrDigit(x)) { - throw new RuntimeException("Bad character in unicode escape."); - } - hex.append(Character.toLowerCase(x)); - } - i+=4; // consume those four digits. - - int code = Integer.parseInt(hex.toString(), 16); - builder.append((char) code); - } else { - throw new RuntimeException("Illegal escape sequence: \\"+ch); - } - } else { // it's not a backslash, or it's the last character. - builder.append(delimiter); - } - } - - return builder.toString(); - } -} diff --git a/src/en/kickassanime/src/eu/kanade/tachiyomi/animeextension/en/kickassanime/KickAssAnime.kt b/src/en/kickassanime/src/eu/kanade/tachiyomi/animeextension/en/kickassanime/KickAssAnime.kt index e63c886c7..23fc79aab 100644 --- a/src/en/kickassanime/src/eu/kanade/tachiyomi/animeextension/en/kickassanime/KickAssAnime.kt +++ b/src/en/kickassanime/src/eu/kanade/tachiyomi/animeextension/en/kickassanime/KickAssAnime.kt @@ -2,514 +2,213 @@ package eu.kanade.tachiyomi.animeextension.en.kickassanime import android.app.Application import android.content.SharedPreferences -import android.net.Uri -import android.util.Base64 import androidx.preference.ListPreference import androidx.preference.PreferenceScreen -import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.GogoCdnExtractor -import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.PinkBird +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.AnimeInfoDto +import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.EpisodeResponseDto +import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.PopularItemDto +import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.PopularResponseDto +import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.RecentsResponseDto +import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.ServersDto +import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.KickAssAnimeExtractor import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.animesource.model.AnimesPage import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SEpisode -import eu.kanade.tachiyomi.animesource.model.Track import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource -import eu.kanade.tachiyomi.lib.doodextractor.DoodExtractor -import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.util.asJsoup -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.float -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.Headers -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient +import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import org.jsoup.nodes.Document +import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.regex.Pattern -@ExperimentalSerializationApi class KickAssAnime : ConfigurableAnimeSource, AnimeHttpSource() { override val name = "KickAssAnime" - override val baseUrl by lazy { - preferences.getString( - "preferred_domain", - "https://www2.kickassanime.ro", - )!! - } + override val baseUrl = "https://kaas.am" + + private val API_URL = "$baseUrl/api/show" override val lang = "en" - override val supportsLatest = false - - override val client: OkHttpClient = network.cloudflareClient - - private val json: Json by injectLazy() + override val supportsLatest = true private val preferences: SharedPreferences by lazy { Injekt.get().getSharedPreferences("source_$id", 0x0000) } - companion object { - private val DateFormatter by lazy { - SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH) + private val json = Json { + ignoreUnknownKeys = true + } + + // ============================== Popular =============================== + override fun popularAnimeRequest(page: Int) = GET("$API_URL/popular?page=$page") + + override fun popularAnimeParse(response: Response): AnimesPage { + val data = response.parseAs() + val animes = data.result.map(::popularAnimeFromObject) + val page = response.request.url.queryParameter("page")?.toIntOrNull() ?: 0 + val hasNext = data.page_count > page + return AnimesPage(animes, hasNext) + } + + private fun popularAnimeFromObject(anime: PopularItemDto): SAnime { + return SAnime.create().apply { + val useEnglish = preferences.getBoolean(PREF_USE_ENGLISH_KEY, PREF_USE_ENGLISH_DEFAULT) + title = when { + anime.title_en.isNotBlank() && useEnglish -> anime.title_en + else -> anime.title + } + setUrlWithoutDomain("/${anime.slug}") + thumbnail_url = "$baseUrl/${anime.poster.url}" } } - // Add non working server names here - private val deadServers = listOf( - "BETASERVER1", - "BETASERVER3", - "DEVSTREAM", - "THETA-ORIGINAL-V4", - "KICKASSANIME1", - ) + // ============================== Episodes ============================== + private fun episodeListRequest(anime: SAnime, page: Int) = + GET("$API_URL/${anime.url}/episodes?page=$page&lang=ja-JP") - private val workingServers = arrayOf( - "StreamSB", "PINK-BIRD", "Doodstream", "MAVERICKKI", "BETA-SERVER", "DAILYMOTION", - "BETAPLAYER", "Vidstreaming", "SAPPHIRE-DUCK", "KICKASSANIMEV2", "ORIGINAL-QUALITY-V2", - ) + private fun getEpisodeResponse(anime: SAnime, page: Int): EpisodeResponseDto { + return client.newCall(episodeListRequest(anime, page)) + .execute() + .parseAs() + } - override fun popularAnimeRequest(page: Int): Request = - GET("$baseUrl/api/get_anime_list/all/$page") + override fun fetchEpisodeList(anime: SAnime): Observable> { + val first = getEpisodeResponse(anime, 1) + val items = buildList { + addAll(first.result) - override fun popularAnimeParse(response: Response): AnimesPage { - val responseObject = json.decodeFromString(response.body.string()) - val data = responseObject["data"]!!.jsonArray - val animes = data.map { item -> - SAnime.create().apply { - setUrlWithoutDomain( - item.jsonObject["slug"]!!.jsonPrimitive.content.substringBefore( - "/episode", - ), - ) - thumbnail_url = - "$baseUrl/uploads/" + item.jsonObject["poster"]!!.jsonPrimitive.content - title = item.jsonObject["name"]!!.jsonPrimitive.content + first.pages.drop(1).forEachIndexed { index, _ -> + addAll(getEpisodeResponse(anime, index + 2).result) } } - return AnimesPage(animes, true) + + val episodes = items.map { + SEpisode.create().apply { + name = it.title + url = "${anime.url}/ep-${it.episode_string}-${it.slug}" + episode_number = it.episode_string.toFloatOrNull() ?: 0F + } + } + + return Observable.just(episodes.reversed()) } override fun episodeListParse(response: Response): List { - val data = getAppdata(response.asJsoup()) - val anime = data["anime"]!!.jsonObject - val episodeList = anime["episodes"]!!.jsonArray - return episodeList.map { item -> - SEpisode.create().apply { - url = item.jsonObject["slug"]!!.jsonPrimitive.content - episode_number = item.jsonObject["num"]!!.jsonPrimitive.float - name = item.jsonObject["epnum"]!!.jsonPrimitive.content - date_upload = parseDate(item.jsonObject["createddate"]!!.jsonPrimitive.content) - } - } + TODO("Not yet implemented") } - private fun parseDate(dateStr: String): Long { - return runCatching { DateFormatter.parse(dateStr)?.time } - .getOrNull() ?: 0L + // ============================ Video Links ============================= + override fun videoListRequest(episode: SEpisode): Request { + val url = API_URL + episode.url.replace("/ep-", "/episode/ep-") + return GET(url) } - override fun latestUpdatesParse(response: Response) = throw Exception("not used") - - override fun latestUpdatesRequest(page: Int) = throw Exception("not used") - override fun videoListParse(response: Response): List