diff --git a/src/pt/vaposcans/AndroidManifest.xml b/src/pt/vaposcans/AndroidManifest.xml
new file mode 100644
index 000000000..1ddafe173
--- /dev/null
+++ b/src/pt/vaposcans/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pt/vaposcans/build.gradle b/src/pt/vaposcans/build.gradle
new file mode 100644
index 000000000..5f7a55b5f
--- /dev/null
+++ b/src/pt/vaposcans/build.gradle
@@ -0,0 +1,7 @@
+ext {
+ extName = 'Vapo Scans'
+ extClass = '.VapoScans'
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/pt/vaposcans/res/mipmap-hdpi/ic_launcher.png b/src/pt/vaposcans/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..cb1188756
Binary files /dev/null and b/src/pt/vaposcans/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/pt/vaposcans/res/mipmap-mdpi/ic_launcher.png b/src/pt/vaposcans/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..290e08c40
Binary files /dev/null and b/src/pt/vaposcans/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/pt/vaposcans/res/mipmap-xhdpi/ic_launcher.png b/src/pt/vaposcans/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..8a2d15ce6
Binary files /dev/null and b/src/pt/vaposcans/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/pt/vaposcans/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/vaposcans/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..cf3315e3e
Binary files /dev/null and b/src/pt/vaposcans/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/pt/vaposcans/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/vaposcans/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..57aa781b2
Binary files /dev/null and b/src/pt/vaposcans/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScans.kt b/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScans.kt
new file mode 100644
index 000000000..d6561254a
--- /dev/null
+++ b/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScans.kt
@@ -0,0 +1,198 @@
+package eu.kanade.tachiyomi.extension.pt.vaposcans
+
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+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.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+class VapoScans : HttpSource() {
+ override val name = "Vapo Scans"
+
+ override val baseUrl = "https://vaposcans.site"
+
+ override val lang = "pt-BR"
+
+ override val supportsLatest = true
+
+ override val client = network.cloudflareClient.newBuilder()
+ .rateLimit(2)
+ .build()
+
+ private val json: Json by injectLazy()
+
+ // Keeps the behavior of the web page
+ private val emptyPayload = "{}".toRequestBody()
+
+ private var popularMangaCache: List = mutableListOf()
+
+ override fun headersBuilder() = super.headersBuilder()
+ .set("Origin", baseUrl)
+ .set("Referer", "$baseUrl/")
+
+ override fun popularMangaRequest(page: Int) =
+ POST("$apiUrl/api/series/", headers, emptyPayload)
+
+ override fun popularMangaParse(response: Response) =
+ MangasPage(
+ response.parseAs>()
+ .map(::sMangaParse)
+ .also {
+ popularMangaCache = it
+ },
+ false,
+ )
+
+ override fun latestUpdatesRequest(page: Int) =
+ POST("$apiUrl/api/recent-chapters/", headers, emptyPayload)
+
+ override fun latestUpdatesParse(response: Response) =
+ MangasPage(
+ response.parseAs>()
+ .map { sMangaParse(it.mangaDto) },
+ false,
+ )
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ if (query.startsWith(URL_SEARCH_PREFIX)) {
+ val manga = SManga.create().apply {
+ url = query.substringAfter(URL_SEARCH_PREFIX)
+ }
+
+ return fetchMangaDetails(manga).map {
+ MangasPage(listOf(it), false)
+ }
+ }
+
+ if (popularMangaCache.isNotEmpty()) {
+ return Observable.just(findMangaByTitle(query))
+ }
+
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
+ POST("$apiUrl/api/series/#$query", headers, emptyPayload)
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val mangas = popularMangaParse(response).mangas
+ val query = response.request.url.toString().substringAfter("#")
+ return findMangaByTitle(query, mangas)
+ }
+
+ override fun getMangaUrl(manga: SManga): String = "$baseUrl/series/${manga.url}"
+
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val payload = MangaCode(manga.url).toRequestBody()
+ return POST("$apiUrl/api/serie/", headers, payload)
+ }
+
+ override fun mangaDetailsParse(response: Response) = SManga.create().apply {
+ response.parseAs().let {
+ title = it.title
+ description = it.synopsis
+ url = it.code
+ genre = it.genres.joinToString()
+ artist = it.artist
+ author = it.author
+ thumbnail_url = it.cover
+ status = when (it.status) {
+ "completed" -> SManga.COMPLETED
+ "ongoing" -> SManga.ONGOING
+ else -> SManga.UNKNOWN
+ }
+ }
+ }
+
+ override fun getChapterUrl(chapter: SChapter) = "$baseUrl/reader/${chapter.url}"
+
+ override fun chapterListRequest(manga: SManga): Request {
+ val payload = MangaCode(manga.url).toRequestBody()
+ return POST("$apiUrl/api/serie/chapters/", headers, payload)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ return response.parseAs>().map {
+ SChapter.create().apply {
+ name = it.number
+ url = it.code
+ date_upload = parseDate(it.upload_date)
+ chapter_number = it.number.toFloat()
+ }
+ }.sortedBy { it.chapter_number }.reversed()
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val payload = MangaCode(chapter.url).toRequestBody()
+ return POST("$apiUrl/api/chapter_details/", headers, payload)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val dto = response.parseAs()
+ val chapterUrl = "$baseUrl/reader/${dto.chapter_code}"
+ return dto.images.mapIndexed { index, image ->
+ Page(index, chapterUrl, "$apiUrl/$image")
+ }
+ }
+
+ override fun imageUrlParse(response: Response) = ""
+
+ private fun findMangaByTitle(query: String, collection: List = popularMangaCache): MangasPage {
+ val mangas = collection
+ .filter { it.title.contains(query, ignoreCase = true) }
+
+ return MangasPage(mangas, false)
+ }
+
+ private inline fun Response.parseAs(): T =
+ json.decodeFromString(body.string())
+
+ private inline fun T.toRequestBody(): RequestBody =
+ json.encodeToString(this)
+ .toRequestBody(JSON_MEDIA_TYPE)
+
+ private fun sMangaParse(dto: MangaDto) = SManga.create().apply {
+ title = dto.title
+ thumbnail_url = "$apiUrl/${dto.cover}"
+ url = dto.code
+ }
+
+ private fun parseDate(date: String): Long =
+ try { dateFormat.parse(date)!!.time } catch (_: Exception) { parseRelativeDate(date) }
+
+ private fun parseRelativeDate(date: String): Long {
+ val number = RELATIVE_DATE_REGEX.find(date)?.value?.toIntOrNull() ?: return 0
+ val cal = Calendar.getInstance()
+ return when {
+ date.contains("dia", ignoreCase = true) -> cal.apply { add(Calendar.DATE, -number) }.timeInMillis
+ date.contains("mes", ignoreCase = true) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
+ date.contains("ano", ignoreCase = true) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
+ else -> 0
+ }
+ }
+
+ companion object {
+ const val apiUrl = "https://api.vaposcans.site"
+ const val URL_SEARCH_PREFIX = "slug:"
+ val JSON_MEDIA_TYPE = "application/json".toMediaTypeOrNull()
+ val RELATIVE_DATE_REGEX = """(\d+)""".toRegex()
+
+ val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale("pt", "BR"))
+ }
+}
diff --git a/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScansDto.kt b/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScansDto.kt
new file mode 100644
index 000000000..fc14621c0
--- /dev/null
+++ b/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScansDto.kt
@@ -0,0 +1,47 @@
+package eu.kanade.tachiyomi.extension.pt.vaposcans
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+class MangaDto(
+ val code: String,
+ val cover: String,
+ val title: String,
+)
+
+@Serializable
+class LatestMangaDto(
+ val serie_code: String,
+ val serie_cover: String,
+ val serie_title: String,
+) {
+ val mangaDto get() = MangaDto(serie_code, serie_cover, serie_title)
+}
+
+@Serializable
+class MangaCode(val code: String)
+
+@Serializable
+class MangaDetailsDto(
+ val artist: String,
+ val author: String,
+ val code: String,
+ val cover: String,
+ val genres: List,
+ val status: String,
+ val synopsis: String,
+ val title: String,
+)
+
+@Serializable
+class ChapterDto(
+ val number: String,
+ val code: String,
+ val upload_date: String,
+)
+
+@Serializable
+class PagesDto(
+ val chapter_code: String,
+ val images: List,
+)
diff --git a/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScansUrlActivity.kt b/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScansUrlActivity.kt
new file mode 100644
index 000000000..f1754bbd6
--- /dev/null
+++ b/src/pt/vaposcans/src/eu/kanade/tachiyomi/extension/pt/vaposcans/VapoScansUrlActivity.kt
@@ -0,0 +1,37 @@
+package eu.kanade.tachiyomi.extension.pt.vaposcans
+
+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 VapoScansUrlActivity : 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 item = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${VapoScans.URL_SEARCH_PREFIX}$item")
+ 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)
+ }
+}