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) + } +}