diff --git a/lib-multisrc/peachscan/build.gradle.kts b/lib-multisrc/peachscan/build.gradle.kts index 0e5d1ac9a..aee4ec189 100644 --- a/lib-multisrc/peachscan/build.gradle.kts +++ b/lib-multisrc/peachscan/build.gradle.kts @@ -5,5 +5,5 @@ plugins { baseVersionCode = 9 dependencies { - compileOnly("com.github.tachiyomiorg:image-decoder:e08e9be535") + implementation(project(":lib:zipinterceptor")) } diff --git a/lib-multisrc/peachscan/src/eu/kanade/tachiyomi/multisrc/peachscan/PeachScan.kt b/lib-multisrc/peachscan/src/eu/kanade/tachiyomi/multisrc/peachscan/PeachScan.kt index ed8f115a4..b3568a0b1 100644 --- a/lib-multisrc/peachscan/src/eu/kanade/tachiyomi/multisrc/peachscan/PeachScan.kt +++ b/lib-multisrc/peachscan/src/eu/kanade/tachiyomi/multisrc/peachscan/PeachScan.kt @@ -1,12 +1,7 @@ package eu.kanade.tachiyomi.multisrc.peachscan import android.annotation.SuppressLint -import android.app.ActivityManager -import android.app.Application -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Rect -import android.util.Base64 +import eu.kanade.tachiyomi.lib.zipinterceptor.ZipInterceptor import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.FilterList @@ -21,23 +16,16 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import rx.Observable -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy -import java.io.ByteArrayOutputStream import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone -import java.util.zip.ZipInputStream @SuppressLint("WrongConstant") abstract class PeachScan( @@ -53,7 +41,7 @@ abstract class PeachScan( override val client = network.cloudflareClient .newBuilder() - .addInterceptor(::zipImageInterceptor) + .addInterceptor(ZipInterceptor()::zipImageInterceptor) .build() private val json: Json by injectLazy() @@ -192,90 +180,6 @@ abstract class PeachScan( return GET(page.imageUrl!!, imgHeaders) } - private val dataUriRegex = Regex("""base64,([0-9a-zA-Z/+=\s]+)""") - - private fun zipImageInterceptor(chain: Interceptor.Chain): Response { - val request = chain.request() - val response = chain.proceed(request) - val filename = request.url.pathSegments.last() - - if (request.url.fragment != "page" || !filename.contains(".zip")) { - return response - } - - val zis = ZipInputStream(response.body.byteStream()) - - val images = generateSequence { zis.nextEntry } - .mapNotNull { - val entryName = it.name - val splitEntryName = entryName.split('.') - val entryIndex = splitEntryName.first().toInt() - val entryType = splitEntryName.last() - - val imageData = if (entryType == "avif" || splitEntryName.size == 1) { - zis.readBytes() - } else { - val svgBytes = zis.readBytes() - val svgContent = svgBytes.toString(Charsets.UTF_8) - val b64 = dataUriRegex.find(svgContent)?.groupValues?.get(1) - ?: return@mapNotNull null - - Base64.decode(b64, Base64.DEFAULT) - } - - entryIndex to PeachScanUtils.decodeImage(imageData, isLowRamDevice, filename, entryName) - } - .sortedBy { it.first } - .toList() - - zis.closeEntry() - zis.close() - - val totalWidth = images.maxOf { it.second.width } - val totalHeight = images.sumOf { it.second.height } - - val result = Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(result) - - var dy = 0 - - images.forEach { - val srcRect = Rect(0, 0, it.second.width, it.second.height) - val dstRect = Rect(0, dy, it.second.width, dy + it.second.height) - - canvas.drawBitmap(it.second, srcRect, dstRect, null) - - dy += it.second.height - } - - val output = ByteArrayOutputStream() - result.compress(Bitmap.CompressFormat.JPEG, 90, output) - - val image = output.toByteArray() - val body = image.toResponseBody("image/jpeg".toMediaType()) - - return response.newBuilder() - .body(body) - .build() - } - - /** - * ActivityManager#isLowRamDevice is based on a system property, which isn't - * necessarily trustworthy. 1GB is supposedly the regular threshold. - * - * Instead, we consider anything with less than 3GB of RAM as low memory - * considering how heavy image processing can be. - */ - private val isLowRamDevice by lazy { - val ctx = Injekt.get() - val activityManager = ctx.getSystemService("activity") as ActivityManager - val memInfo = ActivityManager.MemoryInfo() - - activityManager.getMemoryInfo(memInfo) - - memInfo.totalMem < 3L * 1024 * 1024 * 1024 - } - companion object { const val URL_SEARCH_PREFIX = "slug:" } 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 index 43594c247..65325dd53 100644 --- 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 @@ -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 diff --git a/lib/zipinterceptor/build.gradle.kts b/lib/zipinterceptor/build.gradle.kts new file mode 100644 index 000000000..c87112291 --- /dev/null +++ b/lib/zipinterceptor/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("lib-android") +} + +dependencies { + compileOnly("com.github.tachiyomiorg:image-decoder:e08e9be535") +} diff --git a/lib-multisrc/peachscan/src/eu/kanade/tachiyomi/multisrc/peachscan/PeachScanUtils.kt b/lib/zipinterceptor/src/main/java/eu/kanade/tachiyomi/lib/zipinterceptor/ZipInterceptor.kt similarity index 51% rename from lib-multisrc/peachscan/src/eu/kanade/tachiyomi/multisrc/peachscan/PeachScanUtils.kt rename to lib/zipinterceptor/src/main/java/eu/kanade/tachiyomi/lib/zipinterceptor/ZipInterceptor.kt index cba074d98..2c5f2facb 100644 --- a/lib-multisrc/peachscan/src/eu/kanade/tachiyomi/multisrc/peachscan/PeachScanUtils.kt +++ b/lib/zipinterceptor/src/main/java/eu/kanade/tachiyomi/lib/zipinterceptor/ZipInterceptor.kt @@ -1,28 +1,33 @@ -package eu.kanade.tachiyomi.multisrc.peachscan +package eu.kanade.tachiyomi.lib.zipinterceptor +import android.app.ActivityManager +import android.app.Application import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Rect +import android.util.Base64 +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody import tachiyomi.decoder.ImageDecoder +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream import java.lang.reflect.Method +import java.util.zip.ZipInputStream -/** - * TachiyomiJ2K is on a 2-year-old version of ImageDecoder at the time of writing, - * with a different signature than the one being used as a compile-only dependency. - * - * Because of this, if [ImageDecoder.decode] is called as-is on TachiyomiJ2K, we - * end up with a [NoSuchMethodException]. - * - * This is a hack for determining which signature to call when decoding images. - */ -object PeachScanUtils { +open class ZipInterceptor { private var decodeMethod: Method private var newInstanceMethod: Method - private var classSignature = ClassSignature.Newest + private val dataUriRegex = Regex("""base64,([0-9a-zA-Z/+=\s]+)""") + private enum class ClassSignature { Old, New, Newest } @@ -121,4 +126,95 @@ object PeachScanUtils { return bitmap } + + + open fun zipGetByteStream(request: Request, response: Response): InputStream { + return response.body.byteStream() + } + + open fun requestIsZipImage(request: Request): Boolean { + return request.url.fragment == "page" && request.url.pathSegments.last().contains(".zip") + } + + fun zipImageInterceptor(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + val filename = request.url.pathSegments.last() + + if (requestIsZipImage(request).not()) { + return response + } + + val zis = ZipInputStream(zipGetByteStream(request, response)) + + val images = generateSequence { zis.nextEntry } + .mapNotNull { + val entryName = it.name + val splitEntryName = entryName.split('.') + val entryIndex = splitEntryName.first().toInt() + val entryType = splitEntryName.last() + + val imageData = if (entryType == "avif" || splitEntryName.size == 1) { + zis.readBytes() + } else { + val svgBytes = zis.readBytes() + val svgContent = svgBytes.toString(Charsets.UTF_8) + val b64 = dataUriRegex.find(svgContent)?.groupValues?.get(1) + ?: return@mapNotNull null + + Base64.decode(b64, Base64.DEFAULT) + } + + entryIndex to decodeImage(imageData, isLowRamDevice, filename, entryName) + } + .sortedBy { it.first } + .toList() + + zis.closeEntry() + zis.close() + + val totalWidth = images.maxOf { it.second.width } + val totalHeight = images.sumOf { it.second.height } + + val result = Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(result) + + var dy = 0 + + images.forEach { + val srcRect = Rect(0, 0, it.second.width, it.second.height) + val dstRect = Rect(0, dy, it.second.width, dy + it.second.height) + + canvas.drawBitmap(it.second, srcRect, dstRect, null) + + dy += it.second.height + } + + val output = ByteArrayOutputStream() + result.compress(Bitmap.CompressFormat.JPEG, 90, output) + + val image = output.toByteArray() + val body = image.toResponseBody("image/jpeg".toMediaType()) + + return response.newBuilder() + .body(body) + .build() + } + + /** + * ActivityManager#isLowRamDevice is based on a system property, which isn't + * necessarily trustworthy. 1GB is supposedly the regular threshold. + * + * Instead, we consider anything with less than 3GB of RAM as low memory + * considering how heavy image processing can be. + */ + private val isLowRamDevice by lazy { + val ctx = Injekt.get() + val activityManager = ctx.getSystemService("activity") as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + + activityManager.getMemoryInfo(memInfo) + + memInfo.totalMem < 3L * 1024 * 1024 * 1024 + } } diff --git a/src/pt/randomscan/build.gradle b/src/pt/randomscan/build.gradle index 58c426055..a16148f56 100644 --- a/src/pt/randomscan/build.gradle +++ b/src/pt/randomscan/build.gradle @@ -1,13 +1,13 @@ ext { extName = 'Lura Toon' extClass = '.LuraToon' - themePkg = 'peachscan' baseUrl = 'https://luratoons.com' - overrideVersionCode = 45 + extVersionCode = 55 } apply from: "$rootDir/common.gradle" dependencies { implementation project(':lib:randomua') + implementation project(':lib:zipinterceptor') } diff --git a/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/LuraToon.kt b/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/LuraToon.kt index 5bd49cff2..d8ca3cc88 100644 --- a/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/LuraToon.kt +++ b/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/LuraToon.kt @@ -3,33 +3,55 @@ package eu.kanade.tachiyomi.extension.pt.randomscan import android.app.Application import android.content.SharedPreferences import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.extension.pt.randomscan.dto.Capitulo +import eu.kanade.tachiyomi.extension.pt.randomscan.dto.CapituloPagina +import eu.kanade.tachiyomi.extension.pt.randomscan.dto.MainPage +import eu.kanade.tachiyomi.extension.pt.randomscan.dto.Manga +import eu.kanade.tachiyomi.extension.pt.randomscan.dto.SearchResponse import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA import eu.kanade.tachiyomi.lib.randomua.getPrefUAType import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent -import eu.kanade.tachiyomi.multisrc.peachscan.PeachScan +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Interceptor import okhttp3.Response -import org.jsoup.nodes.Element +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.TimeZone +import kotlin.getValue -class LuraToon : - PeachScan( - "Lura Toon", - "https://luratoons.com", - "pt-BR", - ), - ConfigurableSource { +class LuraToon : HttpSource(), ConfigurableSource { + override val baseUrl = "https://luratoons.com" + override val name = "Lura Toon" + override val lang = "pt-BR" + override val supportsLatest = true + override val versionId = 2 + + private val json: Json by injectLazy() private val preferences: SharedPreferences by lazy { Injekt.get().getSharedPreferences("source_$id", 0x0000) } - override val client = super.client.newBuilder() + override val client = network.cloudflareClient + .newBuilder() + .addInterceptor(::loggedVerifyInterceptor) + .addInterceptor(LuraZipInterceptor()::zipImageInterceptor) .rateLimit(3) .setRandomUserAgent( preferences.getPrefUAType(), @@ -37,27 +59,133 @@ class LuraToon : ) .build() + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/main/?part=${page - 1}", headers) + override fun popularMangaRequest(page: Int) = GET("$baseUrl/api/main/?part=${page - 1}", headers) + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = GET("$baseUrl/api/autocomplete/$query", headers) + override fun chapterListRequest(manga: SManga) = GET("$baseUrl/api/obra/${manga.url.trimStart('/')}", headers) + override fun mangaDetailsRequest(manga: SManga) = chapterListRequest(manga) + override fun setupPreferenceScreen(screen: PreferenceScreen) { addRandomUAPreferenceToScreen(screen) } - override fun chapterFromElement(element: Element): SChapter { - val mangaUrl = element.ownerDocument()!!.location() - - return super.chapterFromElement(element).apply { - val num = url.removeSuffix("/") - .substringAfterLast("/") - val chapUrl = mangaUrl.removeSuffix("/") + "/$num/" - - setUrlWithoutDomain(chapUrl) + override fun mangaDetailsParse(response: Response) = SManga.create().apply { + val data = response.parseAs() + title = data.titulo + author = data.autor + artist = data.artista + genre = data.generos.joinToString(", ") { it.name } + status = when (data.status) { + "Em Lançamento" -> SManga.ONGOING + "Finalizado" -> SManga.COMPLETED + else -> SManga.UNKNOWN } + thumbnail_url = "$baseUrl${data.capa}" + + val category = data.tipo + val synopsis = data.sinopse + description = "Tipo: $category\n\n$synopsis" + } + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.parseAs() + + val mangas = document.lancamentos.map { + SManga.create().apply { + title = it.title + thumbnail_url = "$baseUrl${it.capa}" + setUrlWithoutDomain("/${it.slug}/") + } + } + + return MangasPage(mangas, document.lancamentos.isNotEmpty()) + } + + override fun fetchChapterList(manga: SManga): Observable> { + return client.newCall(chapterListRequest(manga)) + .asObservable() + .map { response -> + chapterListParse(manga, response) + } + } + + fun chapterListParse(manga: SManga, response: Response): List { + if (response.code == 404) { + throw Exception("Capitulos não encontrados, tente migrar o manga, alguns nomes da LuraToon mudaram") + } + + val comics = response.parseAs() + + return comics.caps.sortedByDescending { + it.num + }.map { chapterFromElement(manga, it) } + } + + private fun chapterFromElement(manga: SManga, capitulo: Capitulo) = SChapter.create().apply { + val capSlug = capitulo.slug.trimStart('/') + val mangaUrl = manga.url.trimEnd('/').trimStart('/') + setUrlWithoutDomain("/api/obra/$mangaUrl/$capSlug") + name = capitulo.num.toString().removeSuffix(".0") + date_upload = runCatching { + dateFormat.parse(capitulo.data)!!.time + }.getOrDefault(0L) } override fun pageListParse(response: Response): List { + val capitulo = response.parseAs() val pathSegments = response.request.url.pathSegments - if (pathSegments.contains("login") || pathSegments.isEmpty()) { + return (0 until capitulo.files).map { i -> + Page(i, baseUrl, "$baseUrl/api/cap-download/${capitulo.obra.id}/${capitulo.id}/$i?obra_id=${capitulo.obra.id}&cap_id=${capitulo.id}&slug=${pathSegments[2]}&cap_slug=${pathSegments[3]}") + } + } + + override fun searchMangaParse(response: Response): MangasPage { + val mangas = response.parseAs().obras.map { + SManga.create().apply { + title = it.titulo + thumbnail_url = "$baseUrl${it.capa}" + setUrlWithoutDomain("/${it.slug}/") + } + } + + return MangasPage(mangas, false) + } + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.parseAs() + + val mangas = document.top_10.map { + SManga.create().apply { + title = it.title + thumbnail_url = "$baseUrl${it.capa}" + setUrlWithoutDomain("/${it.slug}/") + } + } + + return MangasPage(mangas, false) + } + + private fun loggedVerifyInterceptor(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val pathSegments = response.request.url.pathSegments + if (response.request.url.pathSegments.contains("login") || pathSegments.isEmpty()) { throw Exception("Faça o login na WebView para acessar o contéudo") } - return super.pageListParse(response) + if (response.code == 429) { + throw Exception("A LuraToon lhe bloqueou por acessar rápido demais, aguarde por volta de 1 minuto e tente novamente") + } + return response } + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).apply { + timeZone = TimeZone.getTimeZone("America/Sao_Paulo") + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + + override fun chapterListParse(response: Response) = throw UnsupportedOperationException() } diff --git a/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/LuraZipInterceptor.kt b/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/LuraZipInterceptor.kt new file mode 100644 index 000000000..1397a9f8f --- /dev/null +++ b/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/LuraZipInterceptor.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.extension.pt.randomscan + +import eu.kanade.tachiyomi.lib.zipinterceptor.ZipInterceptor +import okhttp3.Request +import okhttp3.Response +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class LuraZipInterceptor : ZipInterceptor() { + fun decryptFile(encryptedData: ByteArray, keyBytes: ByteArray): ByteArray { + val keyHash = MessageDigest.getInstance("SHA-256").digest(keyBytes) + + val key: SecretKey = SecretKeySpec(keyHash, "AES") + + val counter = encryptedData.copyOfRange(0, 8) + val iv = IvParameterSpec(counter) + + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, iv) + + val decryptedData = cipher.doFinal(encryptedData.copyOfRange(8, encryptedData.size)) + + return decryptedData + } + + override fun requestIsZipImage(request: Request): Boolean { + return request.url.pathSegments.contains("cap-download") + } + + override fun zipGetByteStream(request: Request, response: Response): InputStream { + val keyData = listOf("obra_id", "slug", "cap_id", "cap_slug").joinToString("") { + request.url.queryParameterValues(it).first().toString() + }.toByteArray(StandardCharsets.UTF_8) + val encryptedData = response.body.bytes() + + val decryptedData = decryptFile(encryptedData, keyData) + return ByteArrayInputStream(decryptedData) + } +} diff --git a/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/dto/MangaDTO.kt b/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/dto/MangaDTO.kt new file mode 100644 index 000000000..543429fa3 --- /dev/null +++ b/src/pt/randomscan/src/eu/kanade/tachiyomi/extension/pt/randomscan/dto/MangaDTO.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.extension.pt.randomscan.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Genero( + val name: String, +) + +@Serializable +data class Capitulo( + val num: Double, + val data: String, + val slug: String, +) + +@Serializable +data class Manga( + val capa: String, + val titulo: String, + val autor: String?, + val artista: String?, + val status: String, + val sinopse: String, + val tipo: String, + val generos: List, + val caps: List, +) + +@Serializable +data class Obra( + val id: Int, +) + +@Serializable +data class CapituloPagina( + val id: Int, + val obra: Obra, + val files: Int, +) + +@Serializable +data class MainPageManga( + val title: String, + val capa: String, + val slug: String, +) + +@Serializable +data class MainPage( + val lancamentos: List, + val top_10: List, +) + +@Serializable +data class SearchResponseManga( + val titulo: String, + val capa: String, + val slug: String, +) + +@Serializable +data class SearchResponse( + val obras: List, +)