diff --git a/src/pt/slimeread/AndroidManifest.xml b/src/pt/slimeread/AndroidManifest.xml
new file mode 100644
index 000000000..d91361ff4
--- /dev/null
+++ b/src/pt/slimeread/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pt/slimeread/build.gradle b/src/pt/slimeread/build.gradle
new file mode 100644
index 000000000..7ef2001f4
--- /dev/null
+++ b/src/pt/slimeread/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'SlimeRead'
+ extClass = '.SlimeRead'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/pt/slimeread/res/mipmap-hdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..a20519904
Binary files /dev/null and b/src/pt/slimeread/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/pt/slimeread/res/mipmap-mdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..841ef2e73
Binary files /dev/null and b/src/pt/slimeread/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/pt/slimeread/res/mipmap-xhdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..9034642e8
Binary files /dev/null and b/src/pt/slimeread/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/pt/slimeread/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..919a41edd
Binary files /dev/null and b/src/pt/slimeread/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/pt/slimeread/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/slimeread/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..7ec1182dd
Binary files /dev/null and b/src/pt/slimeread/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeRead.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeRead.kt
new file mode 100644
index 000000000..8e6105a5a
--- /dev/null
+++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeRead.kt
@@ -0,0 +1,189 @@
+package eu.kanade.tachiyomi.extension.pt.slimeread
+
+import eu.kanade.tachiyomi.extension.pt.slimeread.dto.ChapterDto
+import eu.kanade.tachiyomi.extension.pt.slimeread.dto.LatestResponseDto
+import eu.kanade.tachiyomi.extension.pt.slimeread.dto.MangaInfoDto
+import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PageListDto
+import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PopularMangaDto
+import eu.kanade.tachiyomi.extension.pt.slimeread.dto.toSMangaList
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
+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.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+class SlimeRead : HttpSource() {
+
+ override val name = "SlimeRead"
+
+ override val baseUrl = "https://slimeread.com"
+
+ override val lang = "pt-BR"
+
+ override val supportsLatest = true
+
+ override val client by lazy {
+ network.client.newBuilder()
+ .rateLimitHost(baseUrl.toHttpUrl(), 2)
+ .rateLimitHost(API_URL.toHttpUrl(), 1)
+ .build()
+ }
+
+ override fun headersBuilder() = super.headersBuilder().add("Origin", baseUrl)
+
+ private val json: Json by injectLazy()
+
+ // ============================== Popular ===============================
+ override fun popularMangaRequest(page: Int) = GET("$API_URL/ranking/semana?nsfw=false", headers)
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val items = response.parseAs>()
+ val mangaList = items.toSMangaList()
+ return MangasPage(mangaList, false)
+ }
+
+ // =============================== Latest ===============================
+ override fun latestUpdatesRequest(page: Int) = GET("$API_URL/books?page=$page", headers)
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val dto = response.parseAs()
+ val mangaList = dto.data.toSMangaList()
+ val hasNextPage = dto.page < dto.pages
+ return MangasPage(mangaList, hasNextPage)
+ }
+
+ // =============================== Search ===============================
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
+ val id = query.removePrefix(PREFIX_SEARCH)
+ client.newCall(GET("$API_URL/book/$id", headers))
+ .asObservableSuccess()
+ .map(::searchMangaByIdParse)
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ private fun searchMangaByIdParse(response: Response): MangasPage {
+ val details = mangaDetailsParse(response)
+ return MangasPage(listOf(details), false)
+ }
+
+ override fun getFilterList() = SlimeReadFilters.FILTER_LIST
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val params = SlimeReadFilters.getSearchParameters(filters)
+
+ val url = "$API_URL/book_search".toHttpUrl().newBuilder()
+ .addIfNotBlank("query", query)
+ .addIfNotBlank("genre[]", params.genre)
+ .addIfNotBlank("status", params.status)
+ .addIfNotBlank("searchMethod", params.searchMethod)
+ .apply {
+ params.categories.forEach {
+ addQueryParameter("categories[]", it)
+ }
+ }.build()
+
+ return GET(url, headers)
+ }
+
+ override fun searchMangaParse(response: Response) = popularMangaParse(response)
+
+ // =========================== Manga Details ============================
+ override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.replace("/book/", "/manga/")
+
+ override fun mangaDetailsRequest(manga: SManga) = GET(API_URL + manga.url, headers)
+
+ override fun mangaDetailsParse(response: Response) = SManga.create().apply {
+ val info = response.parseAs()
+ thumbnail_url = info.thumbnail_url
+ title = info.name
+ description = info.description
+ genre = info.categories.joinToString()
+ status = when (info.status) {
+ 1 -> SManga.ONGOING
+ 2 -> SManga.COMPLETED
+ 3, 4 -> SManga.CANCELLED
+ 5 -> SManga.ON_HIATUS
+ else -> SManga.UNKNOWN
+ }
+ }
+
+ // ============================== Chapters ==============================
+ override fun chapterListRequest(manga: SManga) =
+ GET("$API_URL/book_cap_units_all?manga_id=${manga.url.substringAfterLast("/")}", headers)
+
+ override fun chapterListParse(response: Response): List {
+ val items = response.parseAs>()
+ val mangaId = response.request.url.queryParameter("manga_id")!!
+ return items.map {
+ SChapter.create().apply {
+ name = "Cap " + parseChapterNumber(it.number)
+ chapter_number = it.number
+ scanlator = it.scan?.scan_name
+ url = "/book_cap_units?manga_id=$mangaId&cap=${it.number}"
+ }
+ }.reversed()
+ }
+
+ private fun parseChapterNumber(number: Float): String {
+ val cap = number + 1F
+ val num = "%.2f".format(cap)
+ .let { if (cap < 10F) "0$it" else it }
+ .replace(",00", "")
+ .replace(",", ".")
+ return num
+ }
+
+ override fun getChapterUrl(chapter: SChapter): String {
+ val url = "$baseUrl${chapter.url}".toHttpUrl()
+ val id = url.queryParameter("manga_id")!!
+ val cap = url.queryParameter("cap")!!.toFloat()
+ val num = parseChapterNumber(cap)
+ return "$baseUrl/ler/$id/cap-$num"
+ }
+
+ // =============================== Pages ================================
+ override fun pageListRequest(chapter: SChapter) = GET(API_URL + chapter.url, headers)
+
+ override fun pageListParse(response: Response): List {
+ val pages = response.parseAs>().flatMap { it.pages }
+
+ return pages.mapIndexed { index, item ->
+ Page(index, "", item.url)
+ }
+ }
+
+ override fun imageUrlParse(response: Response): String {
+ throw UnsupportedOperationException()
+ }
+
+ // ============================= Utilities ==============================
+ private inline fun Response.parseAs(): T = use {
+ json.decodeFromStream(it.body.byteStream())
+ }
+
+ private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
+ if (value.isNotBlank()) addQueryParameter(query, value)
+ return this
+ }
+
+ companion object {
+ const val PREFIX_SEARCH = "id:"
+
+ private const val API_URL = "https://ai3.slimeread.com:8443"
+ }
+}
diff --git a/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadFilters.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadFilters.kt
new file mode 100644
index 000000000..019bf954d
--- /dev/null
+++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadFilters.kt
@@ -0,0 +1,141 @@
+package eu.kanade.tachiyomi.extension.pt.slimeread
+
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+
+object SlimeReadFilters {
+ open class SelectFilter(
+ displayName: String,
+ val vals: Array>,
+ ) : Filter.Select(
+ displayName,
+ vals.map { it.first }.toTypedArray(),
+ ) {
+ val selected get() = vals[state].second
+ }
+
+ private inline fun FilterList.getSelected(): String {
+ return (first { it is R } as SelectFilter).selected
+ }
+
+ open class CheckBoxFilterList(name: String, val pairs: Array>) :
+ Filter.Group(name, pairs.map { CheckBoxVal(it.first) })
+
+ private class CheckBoxVal(name: String) : Filter.CheckBox(name, false)
+
+ private inline fun FilterList.parseCheckbox(
+ options: Array>,
+ ): Sequence {
+ return (first { it is R } as CheckBoxFilterList).state
+ .asSequence()
+ .filter { it.state }
+ .map { checkbox -> options.find { it.first == checkbox.name }!!.second }
+ }
+
+ internal class CategoriesFilter : CheckBoxFilterList("Categorias", SlimeReadFiltersData.CATEGORIES)
+
+ internal class GenreFilter : SelectFilter("Gênero", SlimeReadFiltersData.GENRES)
+ internal class SearchMethodFilter : SelectFilter("Método de busca", SlimeReadFiltersData.SEARCH_METHODS)
+ internal class StatusFilter : SelectFilter("Status", SlimeReadFiltersData.STATUS)
+
+ val FILTER_LIST get() = FilterList(
+ CategoriesFilter(),
+ GenreFilter(),
+ SearchMethodFilter(),
+ StatusFilter(),
+ )
+
+ data class FilterSearchParams(
+ val categories: Sequence = emptySequence(),
+ val genre: String = "",
+ val searchMethod: String = "",
+ val status: String = "",
+ )
+
+ internal fun getSearchParameters(filters: FilterList): FilterSearchParams {
+ if (filters.isEmpty()) return FilterSearchParams()
+
+ return FilterSearchParams(
+ filters.parseCheckbox(SlimeReadFiltersData.CATEGORIES),
+ filters.getSelected(),
+ filters.getSelected(),
+ filters.getSelected(),
+ )
+ }
+
+ private object SlimeReadFiltersData {
+ val CATEGORIES = arrayOf(
+ Pair("Adulto", "125"),
+ Pair("Artes Marciais", "117"),
+ Pair("Avant Garde", "154"),
+ Pair("Aventura", "112"),
+ Pair("Ação", "146"),
+ Pair("Comédia", "147"),
+ Pair("Culinária", "126"),
+ Pair("Doujinshi", "113"),
+ Pair("Drama", "148"),
+ Pair("Ecchi", "127"),
+ Pair("Erotico", "152"),
+ Pair("Esporte", "135"),
+ Pair("Fantasia", "114"),
+ Pair("Ficção Científica", "120"),
+ Pair("Filosofico", "150"),
+ Pair("Harém", "128"),
+ Pair("Histórico", "115"),
+ Pair("Isekai", "129"),
+ Pair("Josei", "116"),
+ Pair("Mecha", "130"),
+ Pair("Militar", "149"),
+ Pair("Mistério", "142"),
+ Pair("Médico", "118"),
+ Pair("One-shot", "131"),
+ Pair("Premiado", "155"),
+ Pair("Psicológico", "119"),
+ Pair("Romance", "141"),
+ Pair("Seinen", "140"),
+ Pair("Shoujo", "133"),
+ Pair("Shoujo-ai", "121"),
+ Pair("Shounen", "139"),
+ Pair("Shounen-ai", "134"),
+ Pair("Slice-of-life", "122"),
+ Pair("Sobrenatural", "123"),
+ Pair("Sugestivo", "153"),
+ Pair("Terror", "144"),
+ Pair("Thriller", "151"),
+ Pair("Tragédia", "137"),
+ Pair("Vida Escolar", "132"),
+ Pair("Yaoi", "124"),
+ Pair("Yuri", "136"),
+ )
+
+ private val SELECT = Pair("Selecione", "")
+
+ val GENRES = arrayOf(
+ SELECT,
+ Pair("Manga", "29"),
+ Pair("Light Novel", "34"),
+ Pair("Manhua", "31"),
+ Pair("Manhwa", "30"),
+ Pair("Novel", "33"),
+ Pair("Webcomic", "35"),
+ Pair("Webnovel", "36"),
+ Pair("Webtoon", "32"),
+ Pair("4-Koma", "37"),
+ )
+
+ val SEARCH_METHODS = arrayOf(
+ SELECT,
+ Pair("Preciso", "0"),
+ Pair("Geral", "1"),
+ )
+
+ val STATUS = arrayOf(
+ SELECT,
+ Pair("Em andamento", "1"),
+ Pair("Completo", "2"),
+ Pair("Dropado", "3"),
+ Pair("Cancelado", "4"),
+ Pair("Hiato", "5"),
+ )
+ }
+}
diff --git a/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadUrlActivity.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadUrlActivity.kt
new file mode 100644
index 000000000..3e1241c75
--- /dev/null
+++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/SlimeReadUrlActivity.kt
@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.extension.pt.slimeread
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+/**
+ * Springboard that accepts https://slimeread.com/manga// intents
+ * and redirects them to the main Tachiyomi process.
+ */
+class SlimeReadUrlActivity : 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", "${SlimeRead.PREFIX_SEARCH}$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)
+ }
+}
diff --git a/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/dto/SlimeReadDto.kt b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/dto/SlimeReadDto.kt
new file mode 100644
index 000000000..17cebaa73
--- /dev/null
+++ b/src/pt/slimeread/src/eu/kanade/tachiyomi/extension/pt/slimeread/dto/SlimeReadDto.kt
@@ -0,0 +1,72 @@
+package eu.kanade.tachiyomi.extension.pt.slimeread.dto
+
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class PopularMangaDto(
+ @SerialName("book_image") val thumbnail_url: String?,
+ @SerialName("book_id") val id: Int,
+ @SerialName("book_name_original") val name: String,
+)
+
+@Serializable
+data class LatestResponseDto(
+ val pages: Int,
+ val page: Int,
+ val data: List,
+)
+
+fun List.toSMangaList(): List = map { item ->
+ SManga.create().apply {
+ thumbnail_url = item.thumbnail_url
+ title = item.name
+ url = "/book/${item.id}"
+ }
+}
+
+@Serializable
+data class MangaInfoDto(
+ @SerialName("book_image") val thumbnail_url: String?,
+ @SerialName("book_name_original") val name: String,
+ @SerialName("book_status") val status: Int,
+ @SerialName("book_synopsis") val description: String?,
+ @SerialName("book_categories") private val _categories: List,
+) {
+ @Serializable
+ data class CategoryDto(val categories: CatDto)
+
+ @Serializable
+ data class CatDto(@SerialName("cat_name_ptBR") val name: String)
+
+ val categories = _categories.map { it.categories.name }
+}
+
+@Serializable
+data class ChapterDto(
+ @SerialName("btc_cap") val number: Float,
+ val scan: ScanDto?,
+) {
+ @Serializable
+ data class ScanDto(val scan_name: String?)
+}
+
+@Serializable
+data class PageListDto(@SerialName("book_temp_cap_unit") val pages: List)
+
+@Serializable
+data class PageDto(
+ @SerialName("btcu_image") private val path: String,
+ @SerialName("btcu_provider_host") private val hostId: Int,
+) {
+ val url by lazy {
+ val baseUrl = when (hostId) {
+ 2 -> "https://cdn.slimeread.com/"
+ 5 -> "https://black.slimeread.com/"
+ else -> "https://objects.slimeread.com/"
+ }
+
+ baseUrl + path
+ }
+}