diff --git a/src/en/oppaistream/AndroidManifest.xml b/src/en/oppaistream/AndroidManifest.xml
new file mode 100644
index 000000000..26a0a07c9
--- /dev/null
+++ b/src/en/oppaistream/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/oppaistream/build.gradle b/src/en/oppaistream/build.gradle
new file mode 100644
index 000000000..95f367358
--- /dev/null
+++ b/src/en/oppaistream/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Oppai Stream'
+ pkgNameSuffix = 'en.oppaistream'
+ extClass = '.OppaiStream'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/oppaistream/res/mipmap-hdpi/ic_launcher.png b/src/en/oppaistream/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..4a626ef63
Binary files /dev/null and b/src/en/oppaistream/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/oppaistream/res/mipmap-mdpi/ic_launcher.png b/src/en/oppaistream/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..33a2663a0
Binary files /dev/null and b/src/en/oppaistream/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/oppaistream/res/mipmap-xhdpi/ic_launcher.png b/src/en/oppaistream/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..78c7836c8
Binary files /dev/null and b/src/en/oppaistream/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/oppaistream/res/mipmap-xxhdpi/ic_launcher.png b/src/en/oppaistream/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..f96f14795
Binary files /dev/null and b/src/en/oppaistream/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/oppaistream/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/oppaistream/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..dae6df97d
Binary files /dev/null and b/src/en/oppaistream/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/oppaistream/res/web_hi_res_512.png b/src/en/oppaistream/res/web_hi_res_512.png
new file mode 100644
index 000000000..9a13c4f4d
Binary files /dev/null and b/src/en/oppaistream/res/web_hi_res_512.png differ
diff --git a/src/en/oppaistream/src/eu/kanade/tachiyomi/extension/en/oppaistream/OppaiStream.kt b/src/en/oppaistream/src/eu/kanade/tachiyomi/extension/en/oppaistream/OppaiStream.kt
new file mode 100644
index 000000000..fb25cf2d5
--- /dev/null
+++ b/src/en/oppaistream/src/eu/kanade/tachiyomi/extension/en/oppaistream/OppaiStream.kt
@@ -0,0 +1,340 @@
+package eu.kanade.tachiyomi.extension.en.oppaistream
+
+import eu.kanade.tachiyomi.network.GET
+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 okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import java.util.Calendar
+
+class OppaiStream : ParsedHttpSource() {
+
+ override val name = "Oppai Stream"
+
+ override val baseUrl = "https://read.oppai.stream"
+
+ private val cdnUrl = "https://myspacecat.pictures"
+
+ override val lang = "en"
+
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ override fun headersBuilder(): Headers.Builder = super.headersBuilder()
+ .add("Referer", baseUrl)
+
+ // popular
+ override fun popularMangaRequest(page: Int): Request {
+ return searchMangaRequest(page, "", FilterList(OrderByFilter("views")))
+ }
+
+ override fun popularMangaSelector() = searchMangaSelector()
+
+ override fun popularMangaParse(response: Response) = searchMangaParse(response)
+
+ override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element)
+
+ override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
+
+ // latest
+ override fun latestUpdatesRequest(page: Int): Request {
+ return searchMangaRequest(page, "", FilterList(OrderByFilter("uploaded")))
+ }
+
+ override fun latestUpdatesSelector() = searchMangaSelector()
+
+ override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
+
+ override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
+
+ override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
+
+ // search
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ if (!query.startsWith(SLUG_SEARCH_PREFIX)) {
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ val url = "/manhwa?m=${query.substringAfter(SLUG_SEARCH_PREFIX)}"
+ return fetchMangaDetails(SManga.create().apply { this.url = url }).map {
+ it.url = url
+ MangasPage(listOf(it), false)
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = "$baseUrl/api-search.php".toHttpUrl().newBuilder().apply {
+ addQueryParameter("text", query)
+ filters.forEach { filter ->
+ when (filter) {
+ is OrderByFilter -> {
+ addQueryParameter("order", filter.selectedValue())
+ }
+ is GenreListFilter -> {
+ val genresInclude = filter.state.filter { it.state == Filter.TriState.STATE_INCLUDE }.map { genre -> genre.value }
+ val genresExclude = filter.state.filter { it.state == Filter.TriState.STATE_EXCLUDE }.map { genre -> genre.value }
+ addQueryParameter("genres", genresInclude.joinToString(",") { it })
+ addQueryParameter("blacklist", genresExclude.joinToString(",") { it })
+ }
+ else -> {}
+ }
+ }
+ addQueryParameter("page", "$page")
+ addQueryParameter("limit", "$searchLimit")
+ }.build()
+
+ return GET(url, headers)
+ }
+
+ override fun searchMangaSelector() = "div.in-grid"
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ val elements = document.select(searchMangaSelector())
+
+ val mangas = elements.map { element ->
+ searchMangaFromElement(element)
+ }
+
+ val hasNextPage = elements.size >= searchLimit
+
+ return MangasPage(mangas, hasNextPage)
+ }
+
+ override fun searchMangaFromElement(element: Element): SManga {
+ return SManga.create().apply {
+ thumbnail_url = element.select(".split-1 img").attr("src")
+ title = element.select("a div h3").text()
+ setUrlWithoutDomain(element.select("a").attr("href"))
+ }
+ }
+
+ override fun searchMangaNextPageSelector() = null
+
+ // manga details
+ override fun mangaDetailsParse(document: Document): SManga {
+ return SManga.create().apply {
+ thumbnail_url = document.select(".cover-img").attr("src")
+ document.select(".manhwa-info-in").let { it ->
+ it.select("h1").text().let {
+ title = it.substringBeforeLast("By").trim()
+ author = it.substringAfterLast("By").trim()
+ artist = author
+ }
+ genre = it.select(".genres h5").joinToString { it.text() }
+ description = it.select(".description").text()
+ }
+ }
+ }
+
+ // chapter list
+ override fun chapterListSelector() = ".sort-chapters > a"
+
+ override fun chapterFromElement(element: Element): SChapter {
+ return SChapter.create().apply {
+ setUrlWithoutDomain(element.attr("href"))
+ name = element.select("div > h4").text()
+ date_upload = element.select("div > h6").text().parseRelativeDate()
+ }
+ }
+
+ override fun getChapterUrl(chapter: SChapter): String {
+ return "$baseUrl${chapter.url}"
+ }
+
+ // page list
+ override fun pageListRequest(chapter: SChapter): Request {
+ val chapterUrl = "$baseUrl${chapter.url}".toHttpUrl()
+ val slug = chapterUrl.queryParameter("m")
+ val chapNo = chapterUrl.queryParameter("c")
+
+ return GET("$cdnUrl/manhwa/im.php?f-m=$slug&c=$chapNo", headers)
+ }
+
+ override fun pageListParse(document: Document): List {
+ return document.select("img").mapIndexed { index, img ->
+ Page(index = index, imageUrl = img.attr("src"))
+ }
+ }
+
+ // filters
+ open class SelectFilter(
+ displayName: String,
+ private val vals: Array>,
+ defaultValue: String? = null,
+ ) : Filter.Select(
+ displayName,
+ vals.map { it.first }.toTypedArray(),
+ vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
+ ) {
+ fun selectedValue() = vals[state].second
+ }
+
+ private class OrderByFilter(defaultOrder: String? = null) : SelectFilter(
+ "Sort By",
+ arrayOf(
+ Pair("A-Z", "az"),
+ Pair("Z-A", "za"),
+ Pair("Recently Released", "recent"),
+ Pair("Oldest Releases", "old"),
+ Pair("Most Views", "views"),
+ Pair("Highest Rated", "rating"),
+ Pair("Recently Uploaded", "uploaded"),
+ ),
+ defaultOrder,
+ )
+
+ internal class TriState(name: String, val value: String) : Filter.TriState(name)
+
+ private fun getGenreList(): List = listOf(
+ TriState("Adventure", "adventure"),
+ TriState("Beach", "beach"),
+ TriState("Blackmail", "blackmail"),
+ TriState("Cheating", "cheating"),
+ TriState("Comedy", "comedy"),
+ TriState("Cooking", "cooking"),
+ TriState("Drama", "drama"),
+ TriState("Fantasy", "fantasy"),
+ TriState("Harem", "harem"),
+ TriState("Historical", "historical"),
+ TriState("Horror", "horror"),
+ TriState("Incest", "incest"),
+ TriState("Mind Break", "mindbreak"),
+ TriState("Mind Control", "mindcontrol"),
+ TriState("Monster", "monster"),
+ TriState("Mystery", "mystery"),
+ TriState("NTR", "ntr"),
+ TriState("Psychological", "psychological"),
+ TriState("Rape", "rape"),
+ TriState("Reverse Rape", "reverserape"),
+ TriState("Romance", "romance"),
+ TriState("School Life", "schoollife"),
+ TriState("Sci-fi", "sci-fi"),
+ TriState("Secret Relationship", "secretrelationship"),
+ TriState("Slice of Life", "sliceoflife"),
+ TriState("Smut", "smut"),
+ TriState("Sports", "sports"),
+ TriState("Supernatural", "supernatural"),
+ TriState("Tragedy", "tragedy"),
+ TriState("Yaoi", "yaoi"),
+ TriState("Yuri", "yuri"),
+ TriState("Big Boobs", "bigboobs"),
+ TriState("Black Hair", "blackhair"),
+ TriState("Blonde Hair", "blondehair"),
+ TriState("Blue Hair", "bluehair"),
+ TriState("Brown Hair", "brownhair"),
+ TriState("Cosplay", "cosplay"),
+ TriState("Dark Skin", "darkskin"),
+ TriState("Demon", "demon"),
+ TriState("Dominant Girl", "dominantgirl"),
+ TriState("Elf", "elf"),
+ TriState("Futanari", "futanari"),
+ TriState("Glasses", "glasses"),
+ TriState("Green Hair", "greenhair"),
+ TriState("Gyaru", "gyaru"),
+ TriState("Inverted Nipples", "invertednipples"),
+ TriState("Loli", "loli"),
+ TriState("Maid", "maid"),
+ TriState("Milf", "milf"),
+ TriState("Nekomimi", "nekomimi"),
+ TriState("Nurse", "nurse"),
+ TriState("Pink Hair", "pinkhair"),
+ TriState("Pregnant", "pregnant"),
+ TriState("Purple Hair", "purplehair"),
+ TriState("Red Hair", "redhair"),
+ TriState("School Girl", "schoolgirl"),
+ TriState("Short Hair", "shorthair"),
+ TriState("Small Boobs", "smallboobs"),
+ TriState("Succubus", "succubus"),
+ TriState("Swimsuit", "swimsuit"),
+ TriState("Teacher", "teacher"),
+ TriState("Tsundere", "tsundere"),
+ TriState("Vampire", "vampire"),
+ TriState("Virgin", "virgin"),
+ TriState("White Hair", "whitehair"),
+ TriState("Old", "old"),
+ TriState("Shota", "shota"),
+ TriState("Trap", "trap"),
+ TriState("Ugly Bastard", "uglybastard"),
+ )
+
+ private class GenreListFilter(genres: List) : Filter.Group("Genre", genres)
+
+ override fun getFilterList() = FilterList(
+ OrderByFilter(),
+ GenreListFilter(getGenreList()),
+ )
+
+ // Unused
+ override fun imageUrlParse(document: Document): String {
+ throw UnsupportedOperationException("Not used")
+ }
+
+ // helpers
+ private fun String.parseRelativeDate(): Long {
+ val now = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+
+ var parsedDate = 0L
+
+ val relativeDate = try {
+ this.split(" ")[0].trim().toInt()
+ } catch (e: NumberFormatException) {
+ return 0L
+ }
+
+ when {
+ // parse: 30 seconds ago
+ "second" in this -> {
+ parsedDate = now.apply { add(Calendar.SECOND, -relativeDate) }.timeInMillis
+ }
+ // parses: "42 minutes ago"
+ "minute" in this -> {
+ parsedDate = now.apply { add(Calendar.MINUTE, -relativeDate) }.timeInMillis
+ }
+ // parses: "1 hour ago" and "2 hours ago"
+ "hour" in this -> {
+ parsedDate = now.apply { add(Calendar.HOUR, -relativeDate) }.timeInMillis
+ }
+ // parses: "2 days ago"
+ "day" in this -> {
+ parsedDate = now.apply { add(Calendar.DAY_OF_YEAR, -relativeDate) }.timeInMillis
+ }
+ // parses: "2 weeks ago"
+ "week" in this -> {
+ parsedDate = now.apply { add(Calendar.WEEK_OF_YEAR, -relativeDate) }.timeInMillis
+ }
+ // parses: "2 months ago"
+ "month" in this -> {
+ parsedDate = now.apply { add(Calendar.MONTH, -relativeDate) }.timeInMillis
+ }
+ // parse: "2 years ago"
+ "year" in this -> {
+ parsedDate = now.apply { add(Calendar.YEAR, -relativeDate) }.timeInMillis
+ }
+ }
+ return parsedDate
+ }
+
+ companion object {
+ const val searchLimit = 36
+ const val SLUG_SEARCH_PREFIX = "slug:"
+ }
+}
diff --git a/src/en/oppaistream/src/eu/kanade/tachiyomi/extension/en/oppaistream/OppaiStreamUrlActivity.kt b/src/en/oppaistream/src/eu/kanade/tachiyomi/extension/en/oppaistream/OppaiStreamUrlActivity.kt
new file mode 100644
index 000000000..d2d1c4607
--- /dev/null
+++ b/src/en/oppaistream/src/eu/kanade/tachiyomi/extension/en/oppaistream/OppaiStreamUrlActivity.kt
@@ -0,0 +1,34 @@
+package eu.kanade.tachiyomi.extension.en.oppaistream
+
+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 OppaiStreamUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val uri = intent?.data
+ val slug = uri?.getQueryParameter("m")
+ if (slug != null) {
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${OppaiStream.SLUG_SEARCH_PREFIX}$slug")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("OppaiStreamUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("OppaiStreamUrlActivity", "slug not found in uri $uri")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}