diff --git a/src/pt/mangatube/AndroidManifest.xml b/src/pt/mangatube/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/pt/mangatube/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/pt/mangatube/build.gradle b/src/pt/mangatube/build.gradle
new file mode 100644
index 000000000..4d47d0b8a
--- /dev/null
+++ b/src/pt/mangatube/build.gradle
@@ -0,0 +1,17 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'MangaTube'
+ pkgNameSuffix = 'pt.mangatube'
+ extClass = '.MangaTube'
+ extVersionCode = 1
+ libVersion = '1.2'
+}
+
+dependencies {
+ implementation project(':lib-ratelimit')
+}
+
+apply from: "$rootDir/common.gradle"
+
diff --git a/src/pt/mangatube/res/mipmap-hdpi/ic_launcher.png b/src/pt/mangatube/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..5f3c83a4c
Binary files /dev/null and b/src/pt/mangatube/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/pt/mangatube/res/mipmap-mdpi/ic_launcher.png b/src/pt/mangatube/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..c8e6eb45e
Binary files /dev/null and b/src/pt/mangatube/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/pt/mangatube/res/mipmap-xhdpi/ic_launcher.png b/src/pt/mangatube/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..a18e0ed30
Binary files /dev/null and b/src/pt/mangatube/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/pt/mangatube/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/mangatube/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..37af587eb
Binary files /dev/null and b/src/pt/mangatube/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/pt/mangatube/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/mangatube/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..e3ca0324f
Binary files /dev/null and b/src/pt/mangatube/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/pt/mangatube/res/web_hi_res_512.png b/src/pt/mangatube/res/web_hi_res_512.png
new file mode 100644
index 000000000..35c4bb330
Binary files /dev/null and b/src/pt/mangatube/res/web_hi_res_512.png differ
diff --git a/src/pt/mangatube/src/eu/kanade/tachiyomi/extension/pt/mangatube/MangaTube.kt b/src/pt/mangatube/src/eu/kanade/tachiyomi/extension/pt/mangatube/MangaTube.kt
new file mode 100644
index 000000000..75bd74b20
--- /dev/null
+++ b/src/pt/mangatube/src/eu/kanade/tachiyomi/extension/pt/mangatube/MangaTube.kt
@@ -0,0 +1,292 @@
+package eu.kanade.tachiyomi.extension.pt.mangatube
+
+import com.github.salomonbrys.kotson.array
+import com.github.salomonbrys.kotson.get
+import com.github.salomonbrys.kotson.int
+import com.github.salomonbrys.kotson.nullArray
+import com.github.salomonbrys.kotson.obj
+import com.github.salomonbrys.kotson.string
+import com.google.gson.JsonElement
+import com.google.gson.JsonParser
+import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+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 eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.HttpUrl
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Element
+import rx.Observable
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+class MangaTube : HttpSource() {
+
+ override val name = "MangaTube"
+
+ override val baseUrl = "https://mangatube.site"
+
+ override val lang = "pt-BR"
+
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .addInterceptor(RateLimitInterceptor(1, 1, TimeUnit.SECONDS))
+ .addInterceptor(::searchIntercept)
+ .build()
+
+ override fun headersBuilder(): Headers.Builder = Headers.Builder()
+ .add("Accept", ACCEPT_HTML)
+ .add("Accept-Language", ACCEPT_LANGUAGE)
+ .add("Referer", "$baseUrl/")
+
+ private fun apiHeadersBuilder(): Headers.Builder = headersBuilder()
+ .set("Accept", ACCEPT)
+ .add("X-Requested-With", "XMLHttpRequest")
+
+ private val apiHeaders: Headers by lazy { apiHeadersBuilder().build() }
+
+ override fun popularMangaRequest(page: Int): Request {
+ return GET(baseUrl, headers)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+
+ val mangas = document.select("div:contains(Populares) ~ ul.mangasList li div.gridbox")
+ .map(::popularMangaFromElement)
+
+ return MangasPage(mangas, hasNextPage = false)
+ }
+
+ private fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
+ title = element.select("div.title a").first()!!.text()
+ thumbnail_url = element.select("div.thumb img").first()!!.attr("abs:src")
+ setUrlWithoutDomain(element.select("a").first()!!.attr("href"))
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ val form = FormBody.Builder()
+ .add("pagina", page.toString())
+ .build()
+
+ val newHeaders = apiHeadersBuilder()
+ .add("Content-Length", form.contentLength().toString())
+ .add("Content-Type", form.contentType().toString())
+ .build()
+
+ return POST("$baseUrl/jsons/news/chapters.json", newHeaders, form)
+ }
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val result = response.asJson().obj
+
+ val latestMangas = result["releases"].array
+ .map(::latestUpdatesFromObject)
+
+ val hasNextPage = result["page"].string.toInt() < result["total_page"].int
+
+ return MangasPage(latestMangas, hasNextPage)
+ }
+
+ private fun latestUpdatesFromObject(obj: JsonElement) = SManga.create().apply {
+ title = obj["name"].string
+ thumbnail_url = obj["image"].string
+ url = obj["link"].string
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = HttpUrl.parse("$baseUrl/wp-json/site/search/")!!.newBuilder()
+ .addQueryParameter("keyword", query)
+ .addQueryParameter("type", "undefined")
+ .toString()
+
+ return GET(url, apiHeaders)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val result = response.asJson().obj
+
+ val searchResults = result.entrySet()
+ .map { searchMangaFromObject(it.value) }
+
+ return MangasPage(searchResults, hasNextPage = false)
+ }
+
+ private fun searchMangaFromObject(obj: JsonElement) = SManga.create().apply {
+ title = obj["title"].string
+ thumbnail_url = obj["img"].string
+ setUrlWithoutDomain(obj["url"].string)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val document = response.asJsoup()
+ val infoElement = document.select("div.manga-single div.dados").first()
+
+ return SManga.create().apply {
+ title = infoElement.select("h1").first()!!.text()
+ thumbnail_url = infoElement.select("div.thumb img").first()!!.attr("abs:src")
+ description = infoElement.select("div.sinopse").first()!!.text()
+ genre = infoElement.select("ul.generos li a span.button").joinToString { it.text() }
+ }
+ }
+
+ override fun chapterListRequest(manga: SManga): Request = chapterListPaginatedRequest(manga.url)
+
+ private fun chapterListPaginatedRequest(mangaUrl: String, page: Int = 1): Request {
+ val mangaId = mangaUrl.substringAfterLast("/")
+
+ val newHeaders = apiHeadersBuilder()
+ .set("Referer", baseUrl + mangaUrl)
+ .build()
+
+ val url = HttpUrl.parse("$baseUrl/jsons/series/chapters_list.json")!!.newBuilder()
+ .addQueryParameter("page", page.toString())
+ .addQueryParameter("id_s", mangaId)
+ .toString()
+
+ return GET(url, newHeaders)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val mangaUrl = response.request().header("Referer")!!.substringAfter(baseUrl)
+
+ var result = response.asJson().obj
+
+ if (result["chapters"].nullArray == null || result["chapters"].array.size() == 0) {
+ return emptyList()
+ }
+
+ val chapters = result["chapters"].array
+ .map(::chapterFromObject)
+ .toMutableList()
+
+ var page = result["pagina"].int + 1
+ val lastPage = result["total_pags"].int
+
+ while (++page <= lastPage) {
+ val nextPageRequest = chapterListPaginatedRequest(mangaUrl, page)
+ result = client.newCall(nextPageRequest).execute().asJson().obj
+
+ chapters += result["chapters"].array
+ .map(::chapterFromObject)
+ .toMutableList()
+ }
+
+ return chapters
+ }
+
+ private fun chapterFromObject(obj: JsonElement): SChapter = SChapter.create().apply {
+ name = "Cap. " + (if (obj["number"].string == "false") "0" else obj["number"].string) +
+ (if (obj["chapter_name"].asJsonPrimitive.isString) " - " + obj["chapter_name"].string else "")
+ chapter_number = obj["number"].string.toFloatOrNull() ?: -1f
+ date_upload = obj["date_created"].string.substringBefore("T").toDate()
+ setUrlWithoutDomain(obj["link"].string)
+ }
+
+ private fun pageListApiRequest(chapterUrl: String, serieId: String, token: String): Request {
+ val newHeaders = apiHeadersBuilder()
+ .set("Referer", chapterUrl)
+ .build()
+
+ val url = HttpUrl.parse("$baseUrl/jsons/series/images_list.json")!!.newBuilder()
+ .addQueryParameter("id_serie", serieId)
+ .addQueryParameter("secury", token)
+ .toString()
+
+ return GET(url, newHeaders)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val document = response.asJsoup()
+ val apiParams = document.select("script:containsData(id_serie)").firstOrNull()
+ ?.data() ?: throw Exception(TOKEN_NOT_FOUND)
+
+ val chapterUrl = response.request().url().toString()
+ val serieId = apiParams.substringAfter("\"")
+ .substringBefore("\"")
+ val token = TOKEN_REGEX.find(apiParams)!!.groupValues[1]
+
+ val apiRequest = pageListApiRequest(chapterUrl, serieId, token)
+ val apiResponse = client.newCall(apiRequest).execute().asJson().obj
+
+ return apiResponse["images"].array
+ .filter { it["url"].string.startsWith("http") }
+ .mapIndexed { i, obj -> Page(i, chapterUrl, obj["url"].string) }
+ }
+
+ override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!)
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ override fun imageRequest(page: Page): Request {
+ val newHeaders = headersBuilder()
+ .set("Accept", ACCEPT_IMAGE)
+ .set("Referer", page.url)
+ .build()
+
+ return GET(page.imageUrl!!, newHeaders)
+ }
+
+ private fun searchIntercept(chain: Interceptor.Chain): Response {
+ if (chain.request().url().toString().contains("/search/")) {
+ val homeRequest = popularMangaRequest(1)
+ val document = chain.proceed(homeRequest).asJsoup()
+
+ val apiParams = document.select("script:containsData(pAPI)").first()!!.data()
+ .substringAfter("pAPI = ")
+ .substringBeforeLast(";")
+ .let { JSON_PARSER.parse(it) }
+
+ val newUrl = chain.request().url().newBuilder()
+ .addQueryParameter("nonce", apiParams["nonce"].string)
+ .build()
+
+ val newRequest = chain.request().newBuilder()
+ .url(newUrl)
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+
+ return chain.proceed(chain.request())
+ }
+
+ private fun String.toDate(): Long {
+ return try {
+ DATE_FORMATTER.parse(this)?.time ?: 0L
+ } catch (e: ParseException) {
+ 0L
+ }
+ }
+
+ private fun Response.asJson(): JsonElement = JSON_PARSER.parse(body()!!.string())
+
+ companion object {
+ private const val ACCEPT = "application/json, text/plain, */*"
+ private const val ACCEPT_HTML = "text/html,application/xhtml+xml,application/xml;q=0.9," +
+ "image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
+ private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"
+ private const val ACCEPT_LANGUAGE = "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7,es;q=0.6,gl;q=0.5"
+
+ private val TOKEN_REGEX = "token\\s+= \"(.*)\"".toRegex()
+
+ private val JSON_PARSER by lazy { JsonParser() }
+
+ private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
+
+ private const val TOKEN_NOT_FOUND = "Não foi possível obter o token de leitura."
+ }
+}