diff --git a/src/en/toptoonplus/build.gradle b/src/en/toptoonplus/build.gradle index 871fa938e..d01857d01 100644 --- a/src/en/toptoonplus/build.gradle +++ b/src/en/toptoonplus/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'TOPTOON+' pkgNameSuffix = 'en.toptoonplus' extClass = '.TopToonPlus' - extVersionCode = 3 + extVersionCode = 4 isNsfw = true } diff --git a/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt index 76f8eabc3..af00ea094 100644 --- a/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt +++ b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlus.kt @@ -19,12 +19,10 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import rx.Observable -import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale -import java.util.UUID import java.util.concurrent.TimeUnit class TopToonPlus : HttpSource() { @@ -38,7 +36,8 @@ class TopToonPlus : HttpSource() { override val supportsLatest = true override val client: OkHttpClient = network.client.newBuilder() - .addInterceptor(TopToonPlusWebViewInterceptor(baseUrl, headersBuilder().build())) + .addInterceptor(TopToonPlusTokenInterceptor(baseUrl, headersBuilder().build())) + .addInterceptor(TopToonPlusViewerInterceptor(baseUrl, headersBuilder().build())) .addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS)) .build() @@ -49,8 +48,6 @@ class TopToonPlus : HttpSource() { .getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, Locale.US)!! .toUpperCase(Locale.US) - private val deviceId: String by lazy { UUID.randomUUID().toString() } - override fun headersBuilder(): Headers.Builder = super.headersBuilder() .add("Origin", baseUrl) .add("Referer", "$baseUrl/") @@ -58,6 +55,7 @@ class TopToonPlus : HttpSource() { override fun popularMangaRequest(page: Int): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("Language", lang) .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -86,6 +84,7 @@ class TopToonPlus : HttpSource() { override fun latestUpdatesRequest(page: Int): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("Language", lang) .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -156,6 +155,7 @@ class TopToonPlus : HttpSource() { private fun mangaDetailsApiRequest(mangaUrl: String): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("Language", lang) .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -216,7 +216,7 @@ class TopToonPlus : HttpSource() { override fun pageListRequest(chapter: SChapter): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) - .add("Language", "en") + .add("Language", lang) .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -251,6 +251,11 @@ class TopToonPlus : HttpSource() { val viewerRequest = viewerRequest(usableEpisode.comicId, usableEpisode.episodeId) val viewerResponse = client.newCall(viewerRequest).execute() + + if (!viewerResponse.isSuccessful) { + throw Exception(COULD_NOT_GET_CHAPTER_IMAGES) + } + val viewerResult = viewerResponse.parseAs() return viewerResult.data!!.episode @@ -262,6 +267,7 @@ class TopToonPlus : HttpSource() { private fun viewerRequest(comicId: Int, episodeId: Int): Request { val newHeaders = headersBuilder() .add("Accept", ACCEPT_JSON) + .add("Language", lang) .add("UA", "web") .add("X-Api-Key", API_KEY) .build() @@ -308,6 +314,7 @@ class TopToonPlus : HttpSource() { private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8" private const val COULD_NOT_PARSE_RESPONSE = "Could not parse the API response." + private const val COULD_NOT_GET_CHAPTER_IMAGES = "Could not get the chapter images." private const val CHAPTER_NOT_FREE = "This chapter is not free to read." private val DATE_FORMATTER by lazy { diff --git a/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt index 6a9bdc97a..84e45992c 100644 --- a/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt +++ b/src/en/toptoonplus/src/eu/kanade/tachiyomi/extension/en/toptoonplus/TopToonPlusWebViewInterceptor.kt @@ -5,9 +5,13 @@ import android.app.Application import android.os.Handler import android.os.Looper import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import eu.kanade.tachiyomi.network.GET +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Request @@ -19,24 +23,21 @@ import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -/** - * WebView interceptor to get the access token from the user. - * It was created because the website started to use reCAPTCHA. - */ -class TopToonPlusWebViewInterceptor( - private val baseUrl: String, - private val headers: Headers -) : Interceptor { +abstract class TopToonPlusWebViewInterceptor : Interceptor { - private val handler by lazy { Handler(Looper.getMainLooper()) } + protected abstract val baseUrl: String - private val windowKey: String by lazy { + protected abstract val headers: Headers + + protected open val executeJavascript: Boolean = true + + protected val windowKey: String by lazy { UUID.randomUUID().toString().replace("-", "") } - private var token: String? = null + protected val handler by lazy { Handler(Looper.getMainLooper()) } - internal class JsInterface(private val latch: CountDownLatch, var payload: String = "") { + protected class JsInterface(private val latch: CountDownLatch, var payload: String = "") { @JavascriptInterface fun passPayload(passedPayload: String) { payload = passedPayload @@ -44,6 +45,72 @@ class TopToonPlusWebViewInterceptor( } } + abstract override fun intercept(chain: Interceptor.Chain): Response + + @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") + protected fun proceedWithWebView(websiteRequest: Request): String? { + val latch = CountDownLatch(1) + var webView: WebView? = null + + val requestUrl = websiteRequest.url.toString() + val headers = websiteRequest.headers.toMultimap() + .mapValues { it.value.getOrNull(0) ?: "" } + .toMutableMap() + val userAgent = headers["User-Agent"] + val jsInterface = JsInterface(latch) + + handler.post { + val webview = WebView(Injekt.get()) + webView = webview + + with(webview.settings) { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + useWideViewPort = false + loadWithOverviewMode = false + userAgentString = userAgent.orEmpty().ifEmpty { userAgentString } + } + + if (executeJavascript) { + webview.addJavascriptInterface(jsInterface, windowKey) + } + + webview.webViewClient = createWebViewClient(jsInterface) + + webview.loadUrl(requestUrl, headers) + } + + latch.await(TIMEOUT_SEC, TimeUnit.SECONDS) + + handler.postDelayed({ webView?.destroy() }, DELAY_MILLIS) + + if (jsInterface.payload.isBlank()) { + return null + } + + return jsInterface.payload + } + + protected abstract fun createWebViewClient(jsInterface: JsInterface): WebViewClient + + companion object { + private const val TIMEOUT_SEC: Long = 20 + private const val DELAY_MILLIS: Long = 10 * 1000 + } +} + +/** + * WebView interceptor to get the access token from the user. + * It was created because the website started to use reCAPTCHA. + */ +class TopToonPlusTokenInterceptor( + override val baseUrl: String, + override val headers: Headers +) : TopToonPlusWebViewInterceptor() { + + private var token: String? = null + @Synchronized override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() @@ -88,51 +155,10 @@ class TopToonPlusWebViewInterceptor( return chain.proceed(request) } - @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") - private fun proceedWithWebView(websiteRequest: Request): String? { - val latch = CountDownLatch(1) - var webView: WebView? = null - - val requestUrl = websiteRequest.url.toString() - val headers = websiteRequest.headers.toMultimap() - .mapValues { it.value.getOrNull(0) ?: "" } - .toMutableMap() - val userAgent = headers["User-Agent"] - val jsInterface = JsInterface(latch) - - handler.post { - val webview = WebView(Injekt.get()) - webView = webview - - with(webview.settings) { - javaScriptEnabled = true - domStorageEnabled = true - databaseEnabled = true - useWideViewPort = false - loadWithOverviewMode = false - userAgentString = userAgent.orEmpty().ifEmpty { userAgentString } - } - - webview.addJavascriptInterface(jsInterface, windowKey) - - webview.webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView, url: String?) { - view.evaluateJavascript(createScript()) {} - } - } - - webview.loadUrl(requestUrl, headers) + override fun createWebViewClient(jsInterface: JsInterface): WebViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + view.evaluateJavascript(createScript()) {} } - - latch.await(TIMEOUT_SEC, TimeUnit.SECONDS) - - handler.postDelayed({ webView?.destroy() }, DELAY_MILLIS) - - if (jsInterface.payload.isBlank()) { - return null - } - - return jsInterface.payload } private fun createScript(): String = """ @@ -155,9 +181,75 @@ class TopToonPlusWebViewInterceptor( window["$windowKey"].passPayload(accessToken || ""); })(); """.trimIndent() +} + +/** + * WebView interceptor to get the viewer token for the chapter. + * It was created because the website started to use reCAPTCHA. + */ +class TopToonPlusViewerInterceptor( + override val baseUrl: String, + override val headers: Headers +) : TopToonPlusWebViewInterceptor() { + + override val executeJavascript: Boolean = false + + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + + if (!request.url.toString().startsWith(TopToonPlus.API_URL)) { + return chain.proceed(request) + } + + if (request.url.pathSegments.joinToString("/") != VIEWER_ENDPOINT) { + return chain.proceed(request) + } + + val comicId = request.url.queryParameter("comicId")!! + val episodeId = request.url.queryParameter("episodeId")!! + val chapterRequest = GET("$baseUrl/comic/$comicId/$episodeId", headers) + val urlWithToken: String + + try { + urlWithToken = proceedWithWebView(chapterRequest).orEmpty() + .ifEmpty { request.url.toString() } + } catch (e: Exception) { + throw IOException(e.message) + } + + request = request.newBuilder() + .url(urlWithToken) + .build() + + return chain.proceed(request) + } + + override fun createWebViewClient(jsInterface: JsInterface): WebViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest + ): WebResourceResponse? { + if (!request.url.toString().contains(VIEWER_ENDPOINT)) { + return null + } + + val badResponse = buildJsonObject { + put("action", "unusable comic") + put("message", "not allowed") + put("uuid", UUID.randomUUID().toString()) + } + + jsInterface.passPayload(request.url.toString()) + + return WebResourceResponse( + "application/json", + "utf-8", + badResponse.toString().byteInputStream() + ) + } + } companion object { - private const val TIMEOUT_SEC: Long = 20 - private const val DELAY_MILLIS: Long = 10 * 1000 + private const val VIEWER_ENDPOINT = "api/v1/page/viewer" } }