diff --git a/src/en/cloudrecess/AndroidManifest.xml b/src/en/cloudrecess/AndroidManifest.xml
new file mode 100644
index 000000000..a4e695313
--- /dev/null
+++ b/src/en/cloudrecess/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/cloudrecess/build.gradle b/src/en/cloudrecess/build.gradle
new file mode 100644
index 000000000..50cb3333c
--- /dev/null
+++ b/src/en/cloudrecess/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'CloudRecess'
+ pkgNameSuffix = 'en.cloudrecess'
+ extClass = '.CloudRecess'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/cloudrecess/res/mipmap-hdpi/ic_launcher.png b/src/en/cloudrecess/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..7c811df20
Binary files /dev/null and b/src/en/cloudrecess/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/cloudrecess/res/mipmap-mdpi/ic_launcher.png b/src/en/cloudrecess/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..a0a18641d
Binary files /dev/null and b/src/en/cloudrecess/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/cloudrecess/res/mipmap-xhdpi/ic_launcher.png b/src/en/cloudrecess/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..d969e3b55
Binary files /dev/null and b/src/en/cloudrecess/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/cloudrecess/res/mipmap-xxhdpi/ic_launcher.png b/src/en/cloudrecess/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..ef0fe43f3
Binary files /dev/null and b/src/en/cloudrecess/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/cloudrecess/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/cloudrecess/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..32fa3ef6c
Binary files /dev/null and b/src/en/cloudrecess/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecess.kt b/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecess.kt
new file mode 100644
index 000000000..daabf14a7
--- /dev/null
+++ b/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecess.kt
@@ -0,0 +1,154 @@
+package eu.kanade.tachiyomi.extension.en.cloudrecess
+
+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.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+
+class CloudRecess : ParsedHttpSource() {
+
+ override val name = "CloudRecess"
+
+ override val baseUrl = "https://cloudrecess.io"
+
+ override val lang = "en"
+
+ override val supportsLatest = true
+
+ override val client by lazy {
+ network.cloudflareClient.newBuilder()
+ .rateLimitHost(baseUrl.toHttpUrl(), 2)
+ .build()
+ }
+
+ // To load images
+ override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/")
+
+ // ============================== Popular ===============================
+ override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
+
+ override fun popularMangaSelector() = "swiper-container#popular-cards div#card-real > a"
+
+ override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+ setUrlWithoutDomain(element.attr("href"))
+ title = element.selectFirst("h2.text-sm")?.text() ?: "Manga"
+ thumbnail_url = element.selectFirst("img")?.run {
+ absUrl("data-src").ifEmpty { absUrl("src") }
+ }
+ }
+
+ override fun popularMangaNextPageSelector() = null
+
+ // =============================== Latest ===============================
+ override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/?page=$page", headers)
+
+ override fun latestUpdatesSelector() = "section:has(h2:containsOwn(Recent Chapters)) div#card-real > a"
+
+ override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun latestUpdatesNextPageSelector() = "ul.pagination > li:last-child:not(.pagination-disabled)"
+
+ // =============================== 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("$baseUrl/manga/$id"))
+ .asObservableSuccess()
+ .map(::searchMangaByIdParse)
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ private fun searchMangaByIdParse(response: Response): MangasPage {
+ val details = mangaDetailsParse(response.use { it.asJsoup() })
+ return MangasPage(listOf(details), false)
+ }
+
+ override fun getFilterList() = CloudRecessFilters.FILTER_LIST
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = "$baseUrl/manga?title=$query&page=$page".toHttpUrl().newBuilder().apply {
+ val params = CloudRecessFilters.getSearchParameters(filters)
+ if (params.type.isNotEmpty()) addQueryParameter("type", params.type)
+ if (params.status.isNotEmpty()) addQueryParameter("status", params.status)
+ params.genres.forEach { addQueryParameter("genre[]", it) }
+ }.build()
+ return GET(url, headers)
+ }
+
+ override fun searchMangaSelector() = "main div#card-real > a"
+
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector()
+
+ // =========================== Manga Details ============================
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ // Absolutely required element, so throwing a NPE when it's not present
+ // seems reasonable.
+ with(document.selectFirst("main > section > div")!!) {
+ thumbnail_url = selectFirst("div.relative img")?.absUrl("src")
+ title = selectFirst("div.flex > h2")?.ownText() ?: "No name"
+ genre = select("div.flex > a.inline-block").eachText().joinToString()
+ description = selectFirst("div.comicInfoExtend__synopsis")?.text()
+ }
+
+ document.selectFirst("div#buttons + div.hidden")?.run {
+ status = when (getInfo("Status").orEmpty()) {
+ "Cancelled" -> SManga.CANCELLED
+ "Completed" -> SManga.COMPLETED
+ "Hiatus" -> SManga.ON_HIATUS
+ "Ongoing" -> SManga.ONGOING
+ else -> SManga.UNKNOWN
+ }
+
+ artist = getInfo("Artist")
+ author = getInfo("Author")
+ }
+ }
+
+ private fun Element.getInfo(text: String): String? =
+ selectFirst("p:has(span:containsOwn($text)) span.capitalize")
+ ?.ownText()
+ ?.trim()
+
+ // ============================== Chapters ==============================
+ override fun chapterListSelector() = "div#chapters-list > a[href]"
+
+ override fun chapterFromElement(element: Element) = SChapter.create().apply {
+ setUrlWithoutDomain(element.attr("href"))
+ name = element.selectFirst("span")?.ownText() ?: "Chapter"
+ }
+
+ // =============================== Pages ================================
+ override fun pageListParse(document: Document): List {
+ return document.select("div#chapter-container > img").map { element ->
+ val id = element.attr("data-id").toIntOrNull() ?: 0
+ val url = element.run {
+ absUrl("data-src").ifEmpty { absUrl("src") }
+ }
+ Page(id, "", url)
+ }
+ }
+
+ override fun imageUrlParse(document: Document): String {
+ throw UnsupportedOperationException("Not used.")
+ }
+
+ companion object {
+ const val PREFIX_SEARCH = "id:"
+ }
+}
diff --git a/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecessFilters.kt b/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecessFilters.kt
new file mode 100644
index 000000000..d0c78dcbf
--- /dev/null
+++ b/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecessFilters.kt
@@ -0,0 +1,163 @@
+package eu.kanade.tachiyomi.extension.en.cloudrecess
+
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+
+object CloudRecessFilters {
+ open class QueryPartFilter(
+ displayName: String,
+ val vals: Array>,
+ ) : Filter.Select(
+ displayName,
+ vals.map { it.first }.toTypedArray(),
+ ) {
+ fun toQueryPart() = vals[state].second
+ }
+
+ private inline fun FilterList.asQueryPart(): String {
+ return (first { it is R } as QueryPartFilter).toQueryPart()
+ }
+
+ open class CheckBoxFilterList(name: String, val items: List) :
+ Filter.Group(name, items.map(::CheckBoxVal))
+
+ private class CheckBoxVal(name: String) : Filter.CheckBox(name, false)
+
+ private inline fun FilterList.checkedItems(): List {
+ return (first { it is R } as CheckBoxFilterList).state
+ .filter { it.state }
+ .map { it.name }
+ }
+
+ internal class TypeFilter : QueryPartFilter("Type", FiltersData.TYPE_LIST)
+ internal class StatusFilter : QueryPartFilter("Status", FiltersData.STATUS_LIST)
+
+ internal class GenresFilter : CheckBoxFilterList("Genres", FiltersData.GENRES_LIST)
+
+ val FILTER_LIST get() = FilterList(
+ TypeFilter(),
+ StatusFilter(),
+ GenresFilter(),
+ )
+
+ data class FilterSearchParams(
+ val type: String = "",
+ val status: String = "",
+ val genres: List = emptyList(),
+ )
+
+ internal fun getSearchParameters(filters: FilterList): FilterSearchParams {
+ if (filters.isEmpty()) return FilterSearchParams()
+
+ return FilterSearchParams(
+ filters.asQueryPart(),
+ filters.asQueryPart(),
+ filters.checkedItems(),
+ )
+ }
+
+ private object FiltersData {
+ val TYPE_LIST = arrayOf(
+ Pair("All Types", ""),
+ Pair("Manga", "manga"),
+ Pair("Manhwa", "manhwa"),
+ Pair("OEL/Original", "oel"),
+ Pair("One Shot", "one-shot"),
+ Pair("Webtoon", "webtoon"),
+ )
+
+ val STATUS_LIST = arrayOf(
+ Pair("All Status", ""),
+ Pair("Cancelled", "cancelled"),
+ Pair("Completed", "completed"),
+ Pair("Hiatus", "hiatus"),
+ Pair("Ongoing", "ongoing"),
+ Pair("Pending", "pending"),
+ )
+
+ val GENRES_LIST = listOf(
+ "3P Relationship/s",
+ "Action",
+ "Adventure",
+ "Age Gap",
+ "Amnesia/Memory Loss",
+ "Art/s or Creative/s",
+ "BL",
+ "Bloody",
+ "Boss/Employee",
+ "Childhood Friend/s",
+ "Comedy",
+ "Coming of Age",
+ "Contractual Relationship",
+ "Crime",
+ "Cross Dressing",
+ "Crush",
+ "Depraved",
+ "Drama",
+ "Enemies to Lovers",
+ "Family Life",
+ "Fantasy",
+ "Fetish",
+ "First Love",
+ "Food",
+ "Friends to Lovers",
+ "Fxckbuddy",
+ "GL",
+ "Games",
+ "Guideverse",
+ "Hardcore",
+ "Harem",
+ "Historical",
+ "Horror",
+ "Idols/Celeb/Showbiz",
+ "Infidelity",
+ "Intense",
+ "Isekai",
+ "Josei",
+ "Light Hearted",
+ "Living Together",
+ "Love Triangle",
+ "Love/Hate",
+ "Manipulative",
+ "Master/Servant",
+ "Mature",
+ "Military",
+ "Music",
+ "Mystery",
+ "Nameverse",
+ "Obsessive",
+ "Omegaverse",
+ "On Campus/College Life",
+ "One Sided Love",
+ "Part Timer",
+ "Photography",
+ "Psychological",
+ "Rebirth/Reincarnation",
+ "Red Light",
+ "Retro",
+ "Revenge",
+ "Rich Kids",
+ "Romance",
+ "Royalty/Nobility/Gentry",
+ "SM/BDSM/SUB-DOM",
+ "School Life",
+ "Sci-Fi",
+ "Self-Discovery",
+ "Shounen Ai",
+ "Slice of Life",
+ "Smut",
+ "Sports",
+ "Step Family",
+ "Supernatural",
+ "Teacher/Student",
+ "Thriller",
+ "Tragedy",
+ "Tsundere",
+ "Uncensored",
+ "Violence",
+ "Voyeur",
+ "Work Place/Office Workers",
+ "Yakuza/Gangsters",
+ )
+ }
+}
diff --git a/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecessUrlActivity.kt b/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecessUrlActivity.kt
new file mode 100644
index 000000000..ea9194f49
--- /dev/null
+++ b/src/en/cloudrecess/src/eu/kanade/tachiyomi/extension/en/cloudrecess/CloudRecessUrlActivity.kt
@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.extension.en.cloudrecess
+
+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://cloudrecess.io/manga/- intents
+ * and redirects them to the main Tachiyomi process.
+ */
+class CloudRecessUrlActivity : 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", "${CloudRecess.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)
+ }
+}