diff --git a/src/all/mangaplus/build.gradle b/src/all/mangaplus/build.gradle index 952da0ef7..d2df6de6b 100644 --- a/src/all/mangaplus/build.gradle +++ b/src/all/mangaplus/build.gradle @@ -6,12 +6,14 @@ ext { appName = 'Tachiyomi: MANGA Plus by SHUEISHA' pkgNameSuffix = 'all.mangaplus' extClass = '.MangaPlusFactory' - extVersionCode = 2 + extVersionCode = 3 libVersion = '1.2' } dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.13.0' + compileOnly 'com.google.code.gson:gson:2.8.2' + compileOnly project(':duktape-stub') } apply from: "$rootDir/common.gradle" diff --git a/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlus.kt b/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlus.kt index 3aa126d26..e9bcc6604 100644 --- a/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlus.kt +++ b/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlus.kt @@ -1,5 +1,8 @@ package eu.kanade.tachiyomi.extension.all.mangaplus +import android.os.Build +import com.google.gson.Gson +import com.squareup.duktape.Duktape import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.* @@ -30,6 +33,12 @@ abstract class MangaPlus(override val lang: String, .addInterceptor { imageIntercept(it) } .build() + private val protobufJs: String by lazy { + client.newCall(GET(PROTOBUFJS_CDN, headers)).execute().body()!!.string() + } + + private val gson: Gson by lazy { Gson() } + override fun popularMangaRequest(page: Int): Request { return GET("$baseUrl/title_list/ranking", headers) } @@ -240,8 +249,8 @@ abstract class MangaPlus(override val lang: String, private fun decodeImage(encryptionKey: String, image: ByteArray): ByteArray { val keyStream = HEX_GROUP .findAll(encryptionKey) - .map { it.groupValues[1].toInt(16) } .toList() + .map { it.groupValues[1].toInt(16) } val content = image .map { it.toInt() } @@ -256,7 +265,33 @@ abstract class MangaPlus(override val lang: String, return ByteArray(content.size) { pos -> content[pos].toByte() } } - private fun Response.asProto(): MangaPlusResponse = ProtoBuf.load(MangaPlusSerializer, body()!!.bytes()) + private fun Response.asProto(): MangaPlusResponse { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) + return ProtoBuf.load(MangaPlusSerializer, body()!!.bytes()) + + // Apparently, the version used of Kotlinx Serialization lib causes a crash + // on KitKat devices (see #1678). So, if the device is running KitKat or lower, + // we use the old method of parsing their API -- using ProtobufJS + Duktape + Gson. + + val bytes = body()!!.bytes() + val messageBytes = "var BYTE_ARR = new Uint8Array([${bytes.joinToString()}]);" + + val res = Duktape.create().use { + it.set("helper", DuktapeHelper::class.java, object : DuktapeHelper { + override fun getProtobuf(): String = protobufJs + }) + it.evaluate(messageBytes + DECODE_SCRIPT) as String + } + + // The Json.parse method of the Kotlinx Serialization causes the app to crash too, + // so unfortunately we have to use Gson to deserialize. + return gson.fromJson(res, MangaPlusResponse::class.java) + } + + private interface DuktapeHelper { + @Suppress("unused") + fun getProtobuf(): String + } companion object { private const val WEB_URL = "https://mangaplus.shueisha.co.jp" @@ -264,5 +299,7 @@ abstract class MangaPlus(override val lang: String, private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.92 Safari/537.36" private val HEX_GROUP = "(.{1,2})".toRegex() + + private const val PROTOBUFJS_CDN = "https://cdn.rawgit.com/dcodeIO/protobuf.js/6.8.8/dist/light/protobuf.min.js" } } diff --git a/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlusApi.kt b/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlusApi.kt index 006d71360..87f1c5549 100644 --- a/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlusApi.kt +++ b/src/all/mangaplus/src/eu/kanade/tachiyomi/extension/all/mangaplus/MangaPlusApi.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.extension.all.mangaplus +import com.google.gson.annotations.SerializedName import kotlinx.serialization.Optional import kotlinx.serialization.SerialId import kotlinx.serialization.Serializable @@ -80,8 +81,13 @@ data class Title( ) enum class Language(val id: Int) { - @SerialId(0) ENGLISH(0), - @SerialId(1) SPANISH(1) + @SerialId(0) + @SerializedName("0") + ENGLISH(0), + + @SerialId(1) + @SerializedName("1") + SPANISH(1) } @Serializable @@ -115,3 +121,160 @@ data class MangaPage( @SerialId(3) val height: Int, @Optional @SerialId(5) val encryptionKey: String? = null ) + +// Used for the deserialization on KitKat devices. +const val DECODE_SCRIPT: String = """ + Duktape.modSearch = function(id) { + if (id == "protobufjs") + return helper.getProtobuf(); + throw new Error("Cannot find module: " + id); + } + + var protobuf = require("protobufjs"); + + var Root = protobuf.Root; + var Type = protobuf.Type; + var Field = protobuf.Field; + var Enum = protobuf.Enum; + var OneOf = protobuf.OneOf; + + var Response = new Type("Response") + .add( + new OneOf("data") + .add(new Field("success", 1, "SuccessResult")) + .add(new Field("error", 2, "ErrorResult")) + ); + + var ErrorResult = new Type("ErrorResult") + .add(new Field("action", 1, "Action")) + .add(new Field("englishPopup", 2, "Popup")) + .add(new Field("spanishPopup", 3, "Popup")); + + var Action = new Enum("Action") + .add("DEFAULT", 0) + .add("UNAUTHORIZED", 1) + .add("MAINTAINENCE", 2) + .add("GEOIP_BLOCKING", 3); + + var Popup = new Type("Popup") + .add(new Field("subject", 1, "string")) + .add(new Field("body", 2, "string")); + + var SuccessResult = new Type("SuccessResult") + .add(new Field("isFeaturedUpdated", 1, "bool")) + .add( + new OneOf("data") + .add(new Field("allTitlesView", 5, "AllTitlesView")) + .add(new Field("titleRankingView", 6, "TitleRankingView")) + .add(new Field("titleDetailView", 8, "TitleDetailView")) + .add(new Field("mangaViewer", 10, "MangaViewer")) + .add(new Field("webHomeView", 11, "WebHomeView")) + ); + + var TitleRankingView = new Type("TitleRankingView") + .add(new Field("titles", 1, "Title", "repeated")); + + var AllTitlesView = new Type("AllTitlesView") + .add(new Field("titles", 1, "Title", "repeated")); + + var WebHomeView = new Type("WebHomeView") + .add(new Field("groups", 2, "UpdatedTitleGroup", "repeated")); + + var TitleDetailView = new Type("TitleDetailView") + .add(new Field("title", 1, "Title")) + .add(new Field("titleImageUrl", 2, "string")) + .add(new Field("overview", 3, "string")) + .add(new Field("backgroundImageUrl", 4, "string")) + .add(new Field("nextTimeStamp", 5, "uint32")) + .add(new Field("updateTiming", 6, "UpdateTiming")) + .add(new Field("viewingPeriodDescription", 7, "string")) + .add(new Field("firstChapterList", 9, "Chapter", "repeated")) + .add(new Field("lastChapterList", 10, "Chapter", "repeated")) + .add(new Field("isSimulReleased", 14, "bool")) + .add(new Field("chaptersDescending", 17, "bool")); + + var UpdateTiming = new Enum("UpdateTiming") + .add("NOT_REGULARLY", 0) + .add("MONDAY", 1) + .add("TUESDAY", 2) + .add("WEDNESDAY", 3) + .add("THURSDAY", 4) + .add("FRIDAY", 5) + .add("SATURDAY", 6) + .add("SUNDAY", 7) + .add("DAY", 8); + + var MangaViewer = new Type("MangaViewer") + .add(new Field("pages", 1, "Page", "repeated")); + + var Title = new Type("Title") + .add(new Field("titleId", 1, "uint32")) + .add(new Field("name", 2, "string")) + .add(new Field("author", 3, "string")) + .add(new Field("portraitImageUrl", 4, "string")) + .add(new Field("landscapeImageUrl", 5, "string")) + .add(new Field("viewCount", 6, "uint32")) + .add(new Field("language", 7, "Language", {"default": 0})); + + var Language = new Enum("Language") + .add("ENGLISH", 0) + .add("SPANISH", 1); + + var UpdatedTitleGroup = new Type("UpdatedTitleGroup") + .add(new Field("groupName", 1, "string")) + .add(new Field("titles", 2, "UpdatedTitle", "repeated")); + + var UpdatedTitle = new Type("UpdatedTitle") + .add(new Field("title", 1, "Title")) + .add(new Field("chapterId", 2, "uint32")) + .add(new Field("chapterName", 3, "string")) + .add(new Field("chapterSubtitle", 4, "string")); + + var Chapter = new Type("Chapter") + .add(new Field("titleId", 1, "uint32")) + .add(new Field("chapterId", 2, "uint32")) + .add(new Field("name", 3, "string")) + .add(new Field("subTitle", 4, "string", "optional")) + .add(new Field("startTimeStamp", 6, "uint32")) + .add(new Field("endTimeStamp", 7, "uint32")); + + var Page = new Type("Page") + .add(new Field("page", 1, "MangaPage")); + + var MangaPage = new Type("MangaPage") + .add(new Field("imageUrl", 1, "string")) + .add(new Field("width", 2, "uint32")) + .add(new Field("height", 3, "uint32")) + .add(new Field("encryptionKey", 5, "string", "optional")); + + var root = new Root() + .define("mangaplus") + .add(Response) + .add(ErrorResult) + .add(Action) + .add(Popup) + .add(SuccessResult) + .add(TitleRankingView) + .add(AllTitlesView) + .add(WebHomeView) + .add(TitleDetailView) + .add(UpdateTiming) + .add(MangaViewer) + .add(Title) + .add(Language) + .add(UpdatedTitleGroup) + .add(UpdatedTitle) + .add(Chapter) + .add(Page) + .add(MangaPage); + + function decode(arr) { + var Response = root.lookupType("Response"); + var message = Response.decode(arr); + return Response.toObject(message, {defaults: true}); + } + + (function () { + return JSON.stringify(decode(BYTE_ARR)).replace(/\,\{\}/g, ""); + })(); + """