diff --git a/src/en/hentaihere/AndroidManifest.xml b/src/en/hentaihere/AndroidManifest.xml
new file mode 100644
index 000000000..5fd2b3523
--- /dev/null
+++ b/src/en/hentaihere/AndroidManifest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/hentaihere/build.gradle b/src/en/hentaihere/build.gradle
new file mode 100644
index 000000000..bda2f28ce
--- /dev/null
+++ b/src/en/hentaihere/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'HentaiHere'
+ pkgNameSuffix = 'en.hentaihere'
+ extClass = '.HentaiHere'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/hentaihere/res/mipmap-hdpi/ic_launcher.png b/src/en/hentaihere/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..81e243047
Binary files /dev/null and b/src/en/hentaihere/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/hentaihere/res/mipmap-mdpi/ic_launcher.png b/src/en/hentaihere/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..48339746d
Binary files /dev/null and b/src/en/hentaihere/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/hentaihere/res/mipmap-xhdpi/ic_launcher.png b/src/en/hentaihere/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..6a7410dac
Binary files /dev/null and b/src/en/hentaihere/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/hentaihere/res/mipmap-xxhdpi/ic_launcher.png b/src/en/hentaihere/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..2a28b6457
Binary files /dev/null and b/src/en/hentaihere/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/hentaihere/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/hentaihere/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..39000c17a
Binary files /dev/null and b/src/en/hentaihere/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/hentaihere/res/web_hi_res_512.png b/src/en/hentaihere/res/web_hi_res_512.png
new file mode 100644
index 000000000..27870e5e4
Binary files /dev/null and b/src/en/hentaihere/res/web_hi_res_512.png differ
diff --git a/src/en/hentaihere/src/eu/kanade/tachiyomi/extension/en/hentaihere/HentaiHere.kt b/src/en/hentaihere/src/eu/kanade/tachiyomi/extension/en/hentaihere/HentaiHere.kt
new file mode 100644
index 000000000..027d8e187
--- /dev/null
+++ b/src/en/hentaihere/src/eu/kanade/tachiyomi/extension/en/hentaihere/HentaiHere.kt
@@ -0,0 +1,324 @@
+package eu.kanade.tachiyomi.extension.en.hentaihere
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+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 kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+class HentaiHere : ParsedHttpSource() {
+
+ override val name = "HentaiHere"
+
+ override val baseUrl = "https://hentaihere.com"
+
+ override val lang = "en"
+
+ override val supportsLatest = true
+
+ private val json: Json by injectLazy()
+
+ // Popular
+ override fun popularMangaRequest(page: Int) =
+ searchMangaRequest(page, "", getFilterList())
+
+ override fun popularMangaSelector() =
+ searchMangaSelector()
+
+ override fun popularMangaFromElement(element: Element): SManga =
+ searchMangaFromElement(element)
+
+ override fun popularMangaNextPageSelector() =
+ searchMangaNextPageSelector()
+
+ // Search
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return if (query.startsWith(PREFIX_ID_SEARCH)) {
+ val id = query.removePrefix(PREFIX_ID_SEARCH)
+ client.newCall(searchMangaByIdRequest(id))
+ .asObservableSuccess()
+ .map { response -> searchMangaByIdParse(response, id) }
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/m/$id")
+
+ private fun searchMangaByIdParse(response: Response, id: String): MangasPage {
+ val details = mangaDetailsParse(response)
+ details.url = "/m/$id"
+ return MangasPage(listOf(details), false)
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val filterList = if (filters.isEmpty()) getFilterList() else filters
+ val sortFilter = filterList.find { it is SortFilter } as SortFilter
+ val alphabetFilter = filterList.find { it is AlphabetFilter } as AlphabetFilter
+ val statusFilter = filterList.find { it is StatusFilter } as StatusFilter
+ val categoryFilter = filterList.find { it is CategoryFilter } as CategoryFilter
+
+ val sortIndex = sortFilter.state
+ val sortItem = sortFilterList[sortIndex]
+ val sortMin = if (sortIndex >= 5) "newest" else sortItem.first
+
+ val alphabetIndex = alphabetFilter.state
+ val alphabetItem = alphabetFilterList[alphabetIndex]
+ val alphabet = if (alphabetIndex != 0) "/${alphabetItem.first}" else ""
+
+ return when {
+ // query + sort_min ~ /search?s=ore&sort=most-popular
+ query.isNotBlank() -> {
+ GET("$baseUrl/search?s=$query&sort=$sortMin&page=$page")
+ }
+ // category + sort_min + alphabet (optional) ~ /search/t34/newest/a
+ categoryFilter.state != 0 -> {
+ val category = categoryFilterList[categoryFilter.state].first
+ GET("$baseUrl/search/$category/$sortMin$alphabet?page=$page")
+ }
+ // status + alphabet (optional) ~ /directory/ongoing/a
+ statusFilter.state != 0 -> {
+ val status = statusFilterList[statusFilter.state].first
+ GET("$baseUrl/directory/$status$alphabet?page=$page")
+ }
+ // sort + alphabet (optional) ~ /directory/staff-pick/a
+ else -> {
+ val sort = sortItem.first
+ GET("$baseUrl/directory/$sort$alphabet?page=$page")
+ }
+ }
+ }
+
+ override fun searchMangaSelector() = ".item"
+
+ override fun searchMangaFromElement(element: Element): SManga {
+ val a = element.select(".pos-rlt a")
+ val img = element.select(".pos-rlt img")
+ val mutedText = element.select("div:not(.pos-rtl) > .text-muted").text()
+ val artistName = mutedText
+ .substringAfter("by ")
+ .substringBefore(".")
+
+ return SManga.create().apply {
+ setUrlWithoutDomain(a.attr("href"))
+ title = img.attr("alt")
+ author = when (artistName) {
+ "-", "Unknown" -> null
+ else -> artistName
+ }
+ thumbnail_url = img.attr("src")
+ }
+ }
+
+ override fun searchMangaNextPageSelector() =
+ ".pagination > li:last-child:not(.disabled)"
+
+ // Latest
+ override fun latestUpdatesRequest(page: Int): Request =
+ GET("$baseUrl/directory/newest?page=$page")
+
+ override fun latestUpdatesSelector() = searchMangaSelector()
+
+ override fun latestUpdatesFromElement(element: Element): SManga =
+ searchMangaFromElement(element)
+
+ override fun latestUpdatesNextPageSelector() =
+ searchMangaNextPageSelector()
+
+ // Details
+ override fun mangaDetailsParse(document: Document): SManga {
+ val genres = document.select("#info > div:nth-child(10) > a")
+ val licensed = genres.find { it.text() == "Licensed" }
+
+ return SManga.create().apply {
+ title = document.select("*[itemprop='name']").text().trim()
+ author = document.select("#info > div:nth-child(9) > a").text()
+ description = document.select("#info > div:last-child").text()
+ .substringAfter("Brief Summary:")
+ .trim()
+ genre = genres.joinToString(", ") { it.text() }
+ status = if (licensed != null) {
+ SManga.LICENSED
+ } else {
+ document.select("#info > div:nth-child(4) > a").text().trim().toStatus()
+ }
+ thumbnail_url = document.select("#cover img").attr("src")
+ }
+ }
+
+ // Chapters
+ override fun chapterListSelector() = "li.sub-chp > a"
+
+ override fun chapterFromElement(element: Element): SChapter {
+ val chapterName = element
+ .text()
+ .substringBefore("(")
+ .trim()
+ val chapterNumber = chapterName
+ .substringBefore(" ")
+ .toFloatOrNull() ?: -1f
+
+ return SChapter.create().apply {
+ setUrlWithoutDomain(element.attr("href"))
+ name = chapterName
+ chapter_number = chapterNumber
+ }
+ }
+
+ // Pages
+ override fun pageListParse(response: Response): List =
+ json.decodeFromString>(
+ response.body!!.string()
+ .substringAfter("var rff_imageList = ")
+ .substringBefore(";")
+ ).mapIndexed { i, imagePath ->
+ Page(i, "", "$IMAGE_SERVER_URL/hentai$imagePath")
+ }
+
+ override fun pageListParse(document: Document): List =
+ throw UnsupportedOperationException("Not used.")
+
+ override fun imageUrlParse(document: Document) =
+ throw UnsupportedOperationException("Not used.")
+
+ // Filters
+ override fun getFilterList(): FilterList {
+ return FilterList(
+ Filter.Header("Note: Some ignored for text search, category!"),
+ Filter.Header("Note: Ignored when used with status!"),
+ SortFilter(sortFilterList.map { it.second }.toTypedArray()),
+ Filter.Separator(),
+ Filter.Header("Note: Ignored for text search!"),
+ AlphabetFilter(alphabetFilterList.map { it.second }.toTypedArray()),
+ Filter.Separator(),
+ Filter.Header("Note: Ignored for text search, category!"),
+ StatusFilter(statusFilterList.map { it.second }.toTypedArray()),
+ Filter.Separator(),
+ Filter.Header("Note: Ignored for text search!"),
+ CategoryFilter(categoryFilterList.map { it.second }.toTypedArray()),
+ )
+ }
+
+ val sortFilterList = listOf(
+ Pair("newest", "Newest"),
+ Pair("most-popular", "Most Popular"),
+ Pair("last-updated", "Last Updated"),
+ Pair("most-viewed", "Most Viewed"),
+ Pair("alphabetical", "Alphabetical"),
+ Pair("", "----"),
+ Pair("staff-pick", "Staff Pick"),
+ Pair("last-month", "Popular (Monthly)"),
+ Pair("last-week", "Popular (Weekly)"),
+ Pair("yesterday", "Popular (Daily)"),
+ Pair("trending", "Trending"),
+ )
+
+ val alphabetFilterList = listOf(
+ Pair("", "All"),
+ Pair("a", "A"),
+ Pair("b", "B"),
+ Pair("c", "C"),
+ Pair("d", "D"),
+ Pair("e", "E"),
+ Pair("f", "F"),
+ Pair("g", "G"),
+ Pair("h", "H"),
+ Pair("i", "I"),
+ Pair("j", "J"),
+ Pair("k", "K"),
+ Pair("l", "L"),
+ Pair("m", "M"),
+ Pair("n", "N"),
+ Pair("o", "O"),
+ Pair("p", "P"),
+ Pair("q", "Q"),
+ Pair("r", "R"),
+ Pair("s", "S"),
+ Pair("t", "T"),
+ Pair("u", "U"),
+ Pair("v", "V"),
+ Pair("w", "W"),
+ Pair("x", "X"),
+ Pair("y", "Y"),
+ Pair("z", "Z"),
+ )
+
+ val statusFilterList = listOf(
+ Pair("", "All"),
+ Pair("ongoing", "Ongoing"),
+ Pair("completed", "Completed"),
+ )
+
+ // /tags/category
+ // copy($$('.item > a').map(el => `Pair("t${/[^T]+$/.exec(el.href)[0]}", "${el.querySelector("span.clear > span").textContent}"),`).join("\r\n"))
+ val categoryFilterList = listOf(
+ Pair("", "All"),
+ Pair("t34", "Adult"),
+ Pair("t7", "Anal"),
+ Pair("t372", "Beastiality"),
+ Pair("t20", "Big Breasts"),
+ Pair("t43", "Comedy"),
+ Pair("t46", "Compilation"),
+ Pair("t42", "Doujinshi"),
+ Pair("t40", "Ecchi"),
+ Pair("t6", "Fantasy"),
+ Pair("t14", "Futanari"),
+ Pair("t302", "Guro"),
+ Pair("t31", "Harem"),
+ Pair("t15", "Incest"),
+ Pair("t2650", "Isekai (Otherworld)"),
+ Pair("t2158", "Korean Comic"),
+ Pair("t50", "Licensed"),
+ Pair("t17", "Lolicon"),
+ Pair("t30", "Mecha"),
+ Pair("t2503", "No Penetration"),
+ Pair("t33", "Oneshot"),
+ Pair("t23", "Rape"),
+ Pair("t567", "Reverse Harem"),
+ Pair("t41", "Romance"),
+ Pair("t432", "Scat"),
+ Pair("t48", "School Life"),
+ Pair("t5", "Sci-fi"),
+ Pair("t32", "Serialized"),
+ Pair("t44", "Shotacon"),
+ Pair("t49", "Tragedy"),
+ Pair("t47", "Uncensored"),
+ Pair("t27", "Yaoi"),
+ Pair("t28", "Yuri"),
+ )
+
+ class SortFilter(sortables: Array, state: Int = 1) :
+ Filter.Select("Sort", sortables, state)
+
+ class AlphabetFilter(alphabet: Array) :
+ Filter.Select("Starts With", alphabet, 0)
+
+ class StatusFilter(statuses: Array) :
+ Filter.Select("Status", statuses, 0)
+
+ class CategoryFilter(categories: Array) :
+ Filter.Select("Category", categories, 0)
+
+ private fun String.toStatus(): Int = when (this) {
+ "Completed" -> SManga.COMPLETED
+ "Ongoing" -> SManga.ONGOING
+ else -> SManga.UNKNOWN
+ }
+
+ companion object {
+ private const val IMAGE_SERVER_URL = "https://hentaicdn.com"
+ const val PREFIX_ID_SEARCH = "id:"
+ }
+}
diff --git a/src/en/hentaihere/src/eu/kanade/tachiyomi/extension/en/hentaihere/HentaiHereUrlActivity.kt b/src/en/hentaihere/src/eu/kanade/tachiyomi/extension/en/hentaihere/HentaiHereUrlActivity.kt
new file mode 100644
index 000000000..0fe6bd5ea
--- /dev/null
+++ b/src/en/hentaihere/src/eu/kanade/tachiyomi/extension/en/hentaihere/HentaiHereUrlActivity.kt
@@ -0,0 +1,38 @@
+package eu.kanade.tachiyomi.extension.en.hentaihere
+
+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://hentaihere.com/m/Sxxxxx intents and redirects them to
+ * the main Tachiyomi process.
+ */
+class HentaiHereUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val id = pathSegments[1]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${HentaiHere.PREFIX_ID_SEARCH}$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("HentaiHereUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("HentaiHereUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}