diff --git a/src/pt/mangaterra/AndroidManifest.xml b/src/pt/mangaterra/AndroidManifest.xml
new file mode 100644
index 000000000..0b740e3f6
--- /dev/null
+++ b/src/pt/mangaterra/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pt/mangaterra/build.gradle b/src/pt/mangaterra/build.gradle
new file mode 100644
index 000000000..aefaab357
--- /dev/null
+++ b/src/pt/mangaterra/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Manga Terra'
+ extClass = '.MangaTerra'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/pt/mangaterra/res/mipmap-hdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..d1db501d7
Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/pt/mangaterra/res/mipmap-mdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..573ba1bf7
Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/pt/mangaterra/res/mipmap-xhdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..36570c4a5
Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/pt/mangaterra/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..958f433bf
Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/pt/mangaterra/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/mangaterra/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..82a9daaaf
Binary files /dev/null and b/src/pt/mangaterra/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerra.kt b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerra.kt
new file mode 100644
index 000000000..762232fbb
--- /dev/null
+++ b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerra.kt
@@ -0,0 +1,242 @@
+package eu.kanade.tachiyomi.extension.pt.mangaterra
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+import eu.kanade.tachiyomi.source.model.Filter
+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.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+class MangaTerra : ParsedHttpSource() {
+ override val lang: String = "pt-BR"
+ override val supportsLatest: Boolean = true
+ override val name: String = "Manga Terra"
+ override val baseUrl: String = "https://manga-terra.com"
+
+ override val client = network.cloudflareClient.newBuilder()
+ .rateLimit(5, 2, TimeUnit.SECONDS)
+ .build()
+
+ private val noRedirectClient = network.cloudflareClient.newBuilder()
+ .followRedirects(false)
+ .build()
+
+ private val json: Json by injectLazy()
+
+ private var fetchGenresAttempts: Int = 0
+
+ private var genresList: List = emptyList()
+
+ override fun chapterFromElement(element: Element) = SChapter.create().apply {
+ name = element.selectFirst("h5")!!.ownText()
+ date_upload = element.selectFirst("h5 > div")!!.ownText().toDate()
+ setUrlWithoutDomain(element.absUrl("href"))
+ }
+
+ override fun chapterListSelector() = ".card-list-chapter a"
+
+ override fun imageUrlParse(document: Document) = document.selectFirst("img")!!.srcAttr()
+
+ override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
+
+ override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/manga?q=u&page=$page", headers)
+
+ override fun latestUpdatesSelector() = popularMangaSelector()
+
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ title = document.selectFirst(".card-body h1")!!.ownText()
+ description = document.selectFirst(".card-body p")?.ownText()
+ thumbnail_url = document.selectFirst(".card-body img")?.srcAttr()
+ genre = document.select(".card-series-about a").joinToString { it.ownText() }
+ setUrlWithoutDomain(document.location())
+ }
+
+ override fun pageListParse(document: Document): List {
+ val mangaChapterUrl = document.location()
+ val maxPage = findPageCount(mangaChapterUrl)
+ return (1..maxPage).map { page -> Page(page - 1, "$mangaChapterUrl/$page") }
+ }
+
+ override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+ title = element.selectFirst("p")!!.ownText()
+ thumbnail_url = element.selectFirst("img")?.srcAttr()
+ setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
+ }
+
+ override fun popularMangaNextPageSelector() = ".pagination > .page-item:not(.disabled):last-child"
+
+ override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga?q=p&page=$page", headers)
+
+ override fun popularMangaSelector(): String = ".card-body .row > div"
+
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ if (response.request.url.pathSegments.contains("search")) {
+ return searchByQueryMangaParse(response)
+ }
+ return super.searchMangaParse(response)
+ }
+
+ override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ if (query.startsWith(slugPrefix)) {
+ val slug = query.substringAfter(slugPrefix)
+ return client.newCall(GET("$baseUrl/manga/$slug", headers))
+ .asObservableSuccess().map { response ->
+ MangasPage(listOf(mangaDetailsParse(response)), false)
+ }
+ }
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = baseUrl.toHttpUrl().newBuilder()
+
+ if (query.isNotBlank()) {
+ url.addPathSegment("search")
+ .addQueryParameter("q", query)
+ return GET(url.build(), headers)
+ }
+
+ url.addPathSegment("manga")
+
+ filters.forEach { filter ->
+ when (filter) {
+ is GenreFilter -> {
+ filter.state.forEach {
+ if (it.state) {
+ url.addQueryParameter(it.query, it.value)
+ }
+ }
+ }
+ else -> {}
+ }
+ }
+
+ url.addQueryParameter("page", "$page")
+
+ return GET(url.build(), headers)
+ }
+
+ override fun searchMangaSelector() = popularMangaSelector()
+
+ override fun getFilterList(): FilterList {
+ CoroutineScope(Dispatchers.IO).launch { fetchGenres() }
+ val filters = mutableListOf>()
+
+ if (genresList.isNotEmpty()) {
+ filters += GenreFilter(
+ title = "Gêneros",
+ genres = genresList,
+ )
+ } else {
+ filters += listOf(
+ Filter.Separator(),
+ Filter.Header("Aperte 'Redefinir' mostrar os gêneros disponíveis"),
+ )
+ }
+ return FilterList(filters)
+ }
+
+ private fun searchByQueryMangaParse(response: Response): MangasPage {
+ val fragment = Jsoup.parseBodyFragment(
+ json.decodeFromString(response.body.string()),
+ baseUrl,
+ )
+
+ return MangasPage(
+ mangas = fragment.select("div.grid-item-series").map(::searchMangaFromElement),
+ hasNextPage = false,
+ )
+ }
+
+ private fun findPageCount(pageUrl: String): Int {
+ var lowerBound = 1
+ var upperBound = 100
+
+ while (lowerBound <= upperBound) {
+ val midpoint = lowerBound + (upperBound - lowerBound) / 2
+
+ val request = Request.Builder().apply {
+ url("$pageUrl/$midpoint")
+ headers(headers)
+ head()
+ }.build()
+
+ val response = try {
+ noRedirectClient.newCall(request).execute()
+ } catch (e: Exception) {
+ throw Exception("Failed to fetch $pageUrl")
+ }
+
+ if (response.code == 302) {
+ upperBound = midpoint - 1
+ } else {
+ lowerBound = midpoint + 1
+ }
+ }
+
+ return lowerBound
+ }
+
+ private fun Element.srcAttr(): String = when {
+ hasAttr("data-src") -> absUrl("data-src")
+ else -> absUrl("src")
+ }
+
+ private fun String.toDate() = try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L }
+
+ private fun fetchGenres() {
+ if (fetchGenresAttempts < 3 && genresList.isEmpty()) {
+ try {
+ genresList = client.newCall(GET("$baseUrl/manga")).execute()
+ .use { parseGenres(it.asJsoup()) }
+ } catch (_: Exception) {
+ } finally {
+ fetchGenresAttempts++
+ }
+ }
+ }
+
+ private fun parseGenres(document: Document): List {
+ return document.select(".form-filters .custom-checkbox")
+ .map { element ->
+ val input = element.selectFirst("input")!!
+ Genre(
+ name = element.selectFirst("label")!!.ownText(),
+ query = input.attr("name"),
+ value = input.attr("value"),
+ )
+ }
+ }
+
+ companion object {
+ val dateFormat = SimpleDateFormat("dd-MM-yyyy", Locale("pt", "BR"))
+ val slugPrefix = "slug:"
+ }
+}
diff --git a/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraFilters.kt b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraFilters.kt
new file mode 100644
index 000000000..2f823e692
--- /dev/null
+++ b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraFilters.kt
@@ -0,0 +1,7 @@
+package eu.kanade.tachiyomi.extension.pt.mangaterra
+
+import eu.kanade.tachiyomi.source.model.Filter
+
+class Genre(name: String, val query: String, val value: String) : Filter.CheckBox(name)
+
+class GenreFilter(title: String, genres: List) : Filter.Group(title, genres)
diff --git a/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraUrlActivity.kt b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraUrlActivity.kt
new file mode 100644
index 000000000..3e14eded1
--- /dev/null
+++ b/src/pt/mangaterra/src/eu/kanade.tachiyomi.extension.pt.mangaterra/MangaTerraUrlActivity.kt
@@ -0,0 +1,38 @@
+package eu.kanade.tachiyomi.extension.pt.mangaterra
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class MangaTerraUrlActivity : Activity() {
+
+ private val tag = javaClass.simpleName
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", slug(pathSegments))
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e(tag, e.toString())
+ }
+ } else {
+ Log.e(tag, "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+
+ private fun slug(pathSegments: List) = "${MangaTerra.slugPrefix}${pathSegments.last()}"
+}