diff --git a/src/en/hentainexus/AndroidManifest.xml b/src/en/hentainexus/AndroidManifest.xml
new file mode 100644
index 000000000..64db527dc
--- /dev/null
+++ b/src/en/hentainexus/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/hentainexus/build.gradle b/src/en/hentainexus/build.gradle
new file mode 100644
index 000000000..8a01920cc
--- /dev/null
+++ b/src/en/hentainexus/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = "HentaiNexus"
+ extClass = ".HentaiNexus"
+ extVersionCode = 5
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/hentainexus/res/mipmap-hdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..ce0177c2d
Binary files /dev/null and b/src/en/hentainexus/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/hentainexus/res/mipmap-mdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..972f68631
Binary files /dev/null and b/src/en/hentainexus/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/hentainexus/res/mipmap-xhdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..d7d062dd7
Binary files /dev/null and b/src/en/hentainexus/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/hentainexus/res/mipmap-xxhdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..b6a2745b0
Binary files /dev/null and b/src/en/hentainexus/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/hentainexus/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/hentainexus/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..3d95cadfa
Binary files /dev/null and b/src/en/hentainexus/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexus.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexus.kt
new file mode 100644
index 000000000..85e467c7d
--- /dev/null
+++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexus.kt
@@ -0,0 +1,181 @@
+package eu.kanade.tachiyomi.extension.en.hentainexus
+
+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.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.model.UpdateStrategy
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.HttpUrl.Companion.toHttpUrl
+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 HentaiNexus : ParsedHttpSource() {
+
+ override val name = "HentaiNexus"
+
+ override val lang = "en"
+
+ override val baseUrl = "https://hentainexus.com"
+
+ override val supportsLatest = false
+
+ // Images on this site goes through the free Jetpack Photon CDN.
+ override val client = network.cloudflareClient.newBuilder()
+ .rateLimitHost(baseUrl.toHttpUrl(), 1)
+ .build()
+
+ override fun headersBuilder() = super.headersBuilder()
+ .add("Referer", "$baseUrl/")
+
+ private val json: Json by injectLazy()
+
+ override fun popularMangaRequest(page: Int) = GET(
+ baseUrl + (if (page > 1) "/page/$page" else ""),
+ headers,
+ )
+
+ override fun popularMangaSelector() = ".container .column"
+
+ override fun popularMangaFromElement(element: Element) = SManga.create().apply {
+ setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
+ title = element.selectFirst(".card-header-title")!!.text()
+ thumbnail_url = element.selectFirst(".card-image img")?.absUrl("src")
+ }
+
+ override fun popularMangaNextPageSelector() = "a.pagination-next[href]"
+
+ override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
+
+ override fun latestUpdatesSelector() = throw UnsupportedOperationException()
+
+ override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException()
+
+ override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
+
+ 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(GET("$baseUrl/view/$id", headers)).asObservableSuccess()
+ .map { MangasPage(listOf(mangaDetailsParse(it).apply { url = "/view/$id" }), false) }
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = baseUrl.toHttpUrl().newBuilder().apply {
+ val actualPage = page + (filters.filterIsInstance().firstOrNull()?.state?.toIntOrNull() ?: 0)
+ if (actualPage > 1) {
+ addPathSegments("page/$actualPage")
+ }
+
+ addQueryParameter("q", (combineQuery(filters) + query).trim())
+ }.build()
+
+ return GET(url, headers)
+ }
+
+ override fun searchMangaSelector() = popularMangaSelector()
+
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
+
+ private val tagCountRegex = Regex("""\s*\([\d,]+\)$""")
+
+ override fun mangaDetailsParse(document: Document) = SManga.create().apply {
+ val table = document.selectFirst(".view-page-details")!!
+
+ title = document.selectFirst("h1.title")!!.text()
+ artist = table.select("td.viewcolumn:contains(Artist) + td a").joinToString { it.ownText() }
+ author = table.select("td.viewcolumn:contains(Author) + td a").joinToString { it.ownText() }
+ description = buildString {
+ listOf("Circle", "Event", "Magazine", "Parody", "Publisher", "Pages", "Favorites").forEach { key ->
+ val cell = table.selectFirst("td.viewcolumn:contains($key) + td")
+
+ cell
+ ?.ownText()
+ ?.ifEmpty { cell.selectFirst("a")!!.ownText() }
+ ?.let { appendLine("$key: $it") }
+ }
+ appendLine()
+
+ table.selectFirst("td.viewcolumn:contains(Description) + td")?.text()?.let {
+ appendLine(it)
+ }
+ }
+ genre = table.select("span.tag a").joinToString {
+ it.text().replace(tagCountRegex, "")
+ }
+ update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
+ }
+
+ override fun fetchChapterList(manga: SManga): Observable> {
+ val id = manga.url.split("/").last()
+
+ return Observable.just(
+ listOf(
+ SChapter.create().apply {
+ url = "/read/$id"
+ name = "Chapter"
+ },
+ ),
+ )
+ }
+
+ override fun chapterListSelector() = throw UnsupportedOperationException()
+
+ override fun chapterFromElement(element: Element) = throw UnsupportedOperationException()
+
+ override fun pageListParse(document: Document): List {
+ val script = document.selectFirst("script:containsData(initReader)")?.data()
+ ?: throw Exception("Could not find chapter data")
+ val encoded = script.substringAfter("initReader(\"").substringBefore("\",")
+ val data = HentaiNexusUtils.decryptData(encoded)
+
+ return json.parseToJsonElement(data).jsonArray.mapIndexed { i, it ->
+ Page(i, imageUrl = it.jsonObject["image"]!!.jsonPrimitive.content)
+ }
+ }
+
+ override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
+
+ override fun getFilterList() = FilterList(
+ Filter.Header(
+ """
+ Separate items with commas (,)
+ Prepend with dash (-) to exclude
+ For items with multiple words, surround them with double quotes (")
+ """.trimIndent(),
+ ),
+ TagFilter(),
+ ArtistFilter(),
+ AuthorFilter(),
+ CircleFilter(),
+ EventFilter(),
+ ParodyFilter(),
+ MagazineFilter(),
+ PublisherFilter(),
+
+ Filter.Separator(),
+ OffsetPageFilter(),
+ )
+
+ companion object {
+ const val PREFIX_ID_SEARCH = "id:"
+ }
+}
diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusActivity.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusActivity.kt
new file mode 100644
index 000000000..5ef34cb8b
--- /dev/null
+++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusActivity.kt
@@ -0,0 +1,38 @@
+package eu.kanade.tachiyomi.extension.en.hentainexus
+
+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://hentainexus.com/view/xxxx intents
+ * and redirects them to the main Tachiyomi process.
+ */
+class HentaiNexusActivity : 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", "${HentaiNexus.PREFIX_ID_SEARCH}$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("HentaiNexusActivity", e.toString())
+ }
+ } else {
+ Log.e("HentaiNexusActivity", "Could not parse URI from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}
diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusFilters.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusFilters.kt
new file mode 100644
index 000000000..4043f16df
--- /dev/null
+++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusFilters.kt
@@ -0,0 +1,44 @@
+package eu.kanade.tachiyomi.extension.en.hentainexus
+
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+
+class OffsetPageFilter : Filter.Text("Offset results by # pages")
+
+class TagFilter : AdvSearchEntryFilter("Tags")
+class ArtistFilter : AdvSearchEntryFilter("Artists")
+class AuthorFilter : AdvSearchEntryFilter("Authors")
+class CircleFilter : AdvSearchEntryFilter("Circles")
+class EventFilter : AdvSearchEntryFilter("Events")
+class ParodyFilter : AdvSearchEntryFilter("Parodies", "parody")
+class MagazineFilter : AdvSearchEntryFilter("Magazines")
+class PublisherFilter : AdvSearchEntryFilter("Publishers")
+open class AdvSearchEntryFilter(
+ name: String,
+ val key: String = name.lowercase().removeSuffix("s"),
+) : Filter.Text(name)
+
+data class AdvSearchEntry(val key: String, val text: String, val exclude: Boolean)
+
+internal fun combineQuery(filters: FilterList): String {
+ val advSearch = filters.filterIsInstance().flatMap { filter ->
+ val splitState = filter.state.split(",").map(String::trim).filterNot(String::isBlank)
+
+ splitState.map {
+ AdvSearchEntry(filter.key, it.removePrefix("-"), it.startsWith("-"))
+ }
+ }
+
+ return buildString {
+ advSearch.forEach { entry ->
+ if (entry.exclude) {
+ append("-")
+ }
+
+ append(entry.key)
+ append(":")
+ append(entry.text)
+ append(" ")
+ }
+ }
+}
diff --git a/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusUtils.kt b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusUtils.kt
new file mode 100644
index 000000000..ecb9547b8
--- /dev/null
+++ b/src/en/hentainexus/src/eu/kanade/tachiyomi/extension/en/hentainexus/HentaiNexusUtils.kt
@@ -0,0 +1,59 @@
+package eu.kanade.tachiyomi.extension.en.hentainexus
+
+import android.util.Base64
+
+object HentaiNexusUtils {
+ fun decryptData(data: String): String = decryptData(Base64.decode(data, Base64.DEFAULT))
+
+ private val primeNumbers = listOf(2, 3, 5, 7, 11, 13, 17)
+
+ private fun decryptData(data: ByteArray): String {
+ val keyStream = data.slice(0 until 64).map { it.toUByte().toInt() }
+ val ciphertext = data.slice(64 until data.size).map { it.toUByte().toInt() }
+ val digest = (0..255).toMutableList()
+
+ var primeIdx = 0
+ for (i in 0 until 64) {
+ primeIdx = primeIdx xor keyStream[i]
+
+ for (j in 0 until 8) {
+ primeIdx = if (primeIdx and 1 != 0) {
+ primeIdx ushr 1 xor 12
+ } else {
+ primeIdx ushr 1
+ }
+ }
+ }
+ primeIdx = primeIdx and 7
+
+ var temp: Int
+ var key = 0
+ for (i in 0..255) {
+ key = (key + digest[i] + keyStream[i % 64]) % 256
+
+ temp = digest[i]
+ digest[i] = digest[key]
+ digest[key] = temp
+ }
+
+ val q = primeNumbers[primeIdx]
+ var k = 0
+ var n = 0
+ var p = 0
+ var xorKey = 0
+ return buildString(ciphertext.size) {
+ for (i in ciphertext.indices) {
+ k = (k + q) % 256
+ n = (p + digest[(n + digest[k]) % 256]) % 256
+ p = (p + k + digest[k]) % 256
+
+ temp = digest[k]
+ digest[k] = digest[n]
+ digest[n] = temp
+
+ xorKey = digest[(n + digest[(k + digest[(xorKey + p) % 256]) % 256]) % 256]
+ append((ciphertext[i].toUByte().toInt() xor xorKey).toChar())
+ }
+ }
+ }
+}