diff --git a/src/all/asmhentai/AndroidManifest.xml b/src/all/asmhentai/AndroidManifest.xml
new file mode 100644
index 000000000..9de41aa38
--- /dev/null
+++ b/src/all/asmhentai/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/all/asmhentai/build.gradle b/src/all/asmhentai/build.gradle
new file mode 100644
index 000000000..e73783778
--- /dev/null
+++ b/src/all/asmhentai/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'AsmHentai'
+ extClass = '.ASMHFactory'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/asmhentai/res/mipmap-hdpi/ic_launcher.png b/src/all/asmhentai/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..727c9de39
Binary files /dev/null and b/src/all/asmhentai/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/asmhentai/res/mipmap-mdpi/ic_launcher.png b/src/all/asmhentai/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..20f59d1a4
Binary files /dev/null and b/src/all/asmhentai/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/asmhentai/res/mipmap-xhdpi/ic_launcher.png b/src/all/asmhentai/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..93451c687
Binary files /dev/null and b/src/all/asmhentai/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/asmhentai/res/mipmap-xxhdpi/ic_launcher.png b/src/all/asmhentai/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..18f5349ca
Binary files /dev/null and b/src/all/asmhentai/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/asmhentai/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/asmhentai/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..65939dca5
Binary files /dev/null and b/src/all/asmhentai/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt
new file mode 100644
index 000000000..41a2563d5
--- /dev/null
+++ b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt
@@ -0,0 +1,13 @@
+package eu.kanade.tachiyomi.extension.all.asmhentai
+
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class ASMHFactory : SourceFactory {
+ override fun createSources(): List = listOf(
+ AsmHentai("en", "english"),
+ AsmHentai("ja", "japanese"),
+ AsmHentai("zh", "chinese"),
+ AsmHentai("all", ""),
+ )
+}
diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt
new file mode 100644
index 000000000..c984eae77
--- /dev/null
+++ b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt
@@ -0,0 +1,37 @@
+package eu.kanade.tachiyomi.extension.all.asmhentai
+
+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://asmhentai.com/g/xxxxxx intents and redirects them to
+ * the main Tachiyomi process.
+ */
+class ASMHUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${AsmHentai.PREFIX_ID_SEARCH}${pathSegments[1]}")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("ASMHUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("ASMHUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}
diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt
new file mode 100644
index 000000000..f470fca67
--- /dev/null
+++ b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt
@@ -0,0 +1,274 @@
+package eu.kanade.tachiyomi.extension.all.asmhentai
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+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.model.UpdateStrategy
+import eu.kanade.tachiyomi.source.online.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.FormBody
+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
+
+open class AsmHentai(override val lang: String, private val tlTag: String) : ParsedHttpSource() {
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ override val baseUrl = "https://asmhentai.com"
+
+ override val name = "AsmHentai"
+
+ override val supportsLatest = false
+
+ // Popular
+
+ override fun popularMangaRequest(page: Int): Request {
+ val url = baseUrl.toHttpUrl().newBuilder().apply {
+ if (tlTag.isNotEmpty()) addPathSegments("language/$tlTag/")
+ if (page > 1) addQueryParameter("page", page.toString())
+ }
+ return GET(url.build(), headers)
+ }
+
+ override fun popularMangaSelector(): String = ".preview_item"
+
+ private fun Element.mangaTitle() = select("h2").text()
+
+ private fun Element.mangaUrl() = select(".image a").attr("abs:href")
+
+ private fun Element.mangaThumbnail() = select(".image img").attr("abs:src")
+
+ override fun popularMangaFromElement(element: Element): SManga {
+ return SManga.create().apply {
+ title = element.mangaTitle()
+ setUrlWithoutDomain(element.mangaUrl())
+ thumbnail_url = element.mangaThumbnail()
+ }
+ }
+
+ override fun popularMangaNextPageSelector(): String = "li.active + li:not(.disabled)"
+
+ // Latest
+
+ override fun latestUpdatesNextPageSelector(): String? {
+ throw UnsupportedOperationException()
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ throw UnsupportedOperationException()
+ }
+
+ override fun latestUpdatesFromElement(element: Element): SManga {
+ throw UnsupportedOperationException()
+ }
+
+ override fun latestUpdatesSelector(): String {
+ throw UnsupportedOperationException()
+ }
+
+ // Search
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return when {
+ query.startsWith(PREFIX_ID_SEARCH) -> {
+ val id = query.removePrefix(PREFIX_ID_SEARCH)
+ client.newCall(searchMangaByIdRequest(id))
+ .asObservableSuccess()
+ .map { response -> searchMangaByIdParse(response, id) }
+ }
+ query.toIntOrNull() != null -> {
+ client.newCall(searchMangaByIdRequest(query))
+ .asObservableSuccess()
+ .map { response -> searchMangaByIdParse(response, query) }
+ }
+ else -> super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ // any space except after a comma (we're going to replace spaces only between words)
+ private val spaceRegex = Regex("""(? query
+ query.isBlank() -> tags
+ else -> "$query,$tags"
+ }.replace(spaceRegex, "+")
+
+ val url = baseUrl.toHttpUrl().newBuilder().apply {
+ addPathSegments("search/")
+ addEncodedQueryParameter("q", q)
+ if (page > 1) addQueryParameter("page", page.toString())
+ }
+
+ return GET(url.build(), headers)
+ }
+
+ private class SMangaDto(
+ val title: String,
+ val url: String,
+ val thumbnail: String,
+ val lang: String,
+ )
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val doc = response.asJsoup()
+
+ val mangas = doc.select(searchMangaSelector())
+ .map {
+ SMangaDto(
+ title = it.mangaTitle(),
+ url = it.mangaUrl(),
+ thumbnail = it.mangaThumbnail(),
+ lang = it.select("a:has(.flag)").attr("href").removeSuffix("/").substringAfterLast("/"),
+ )
+ }
+ .let { unfiltered ->
+ if (tlTag.isNotEmpty()) unfiltered.filter { it.lang == tlTag } else unfiltered
+ }
+ .map {
+ SManga.create().apply {
+ title = it.title
+ setUrlWithoutDomain(it.url)
+ thumbnail_url = it.thumbnail
+ }
+ }
+
+ return MangasPage(mangas, doc.select(searchMangaNextPageSelector()).isNotEmpty())
+ }
+
+ private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/g/$id/", headers)
+
+ private fun searchMangaByIdParse(response: Response, id: String): MangasPage {
+ val details = mangaDetailsParse(response)
+ details.url = "/g/$id/"
+ return MangasPage(listOf(details), false)
+ }
+
+ override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
+
+ override fun searchMangaSelector() = popularMangaSelector()
+
+ override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
+
+ // Details
+
+ private fun Element.get(tag: String): String {
+ return select(".tags:contains($tag) .tag").joinToString { it.ownText() }
+ }
+
+ override fun mangaDetailsParse(document: Document): SManga {
+ return SManga.create().apply {
+ update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
+ document.select(".book_page").first()!!.let { element ->
+ thumbnail_url = element.select(".cover img").attr("abs:src")
+ title = element.select("h1").text()
+ genre = element.get("Tags")
+ artist = element.get("Artists")
+ author = artist
+ description = listOf("Parodies", "Groups", "Languages", "Category")
+ .mapNotNull { tag ->
+ element.get(tag).let { if (it.isNotEmpty()) "$tag: $it" else null }
+ }
+ .joinToString("\n", postfix = "\n") +
+ element.select(".pages h3").text() +
+ element.select("h1 + h2").text()
+ .let { altTitle -> if (altTitle.isNotEmpty()) "\nAlternate Title: $altTitle" else "" }
+ }
+ }
+ }
+
+ // Chapters
+
+ override fun fetchChapterList(manga: SManga): Observable> {
+ return Observable.just(
+ listOf(
+ SChapter.create().apply {
+ name = "Chapter"
+ url = manga.url
+ },
+ ),
+ )
+ }
+
+ override fun chapterListSelector(): String {
+ throw UnsupportedOperationException()
+ }
+
+ override fun chapterFromElement(element: Element): SChapter {
+ throw UnsupportedOperationException()
+ }
+
+ // Pages
+
+ // convert thumbnail URLs to full image URLs
+ private fun String.full(): String {
+ val fType = substringAfterLast("t")
+ return replace("t$fType", fType)
+ }
+
+ private fun Document.inputIdValueOf(string: String): String {
+ return select("input[id=$string]").attr("value")
+ }
+
+ override fun pageListParse(document: Document): List {
+ val thumbUrls = document.select(".preview_thumb img")
+ .map { it.attr("abs:data-src") }
+ .toMutableList()
+
+ // input only exists if pages > 10 and have to make a request to get the other thumbnails
+ val totalPages = document.inputIdValueOf("t_pages")
+
+ if (totalPages.isNotEmpty()) {
+ val token = document.select("[name=csrf-token]").attr("content")
+
+ val form = FormBody.Builder()
+ .add("_token", token)
+ .add("id", document.inputIdValueOf("load_id"))
+ .add("dir", document.inputIdValueOf("load_dir"))
+ .add("visible_pages", "10")
+ .add("t_pages", totalPages)
+ .add("type", "2") // 1 would be "more", 2 is "all remaining"
+ .build()
+
+ val xhrHeaders = headers.newBuilder()
+ .add("X-Requested-With", "XMLHttpRequest")
+ .build()
+
+ client.newCall(POST("$baseUrl/inc/thumbs_loader.php", xhrHeaders, form))
+ .execute()
+ .asJsoup()
+ .select("img")
+ .mapTo(thumbUrls) { it.attr("abs:data-src") }
+ }
+ return thumbUrls.mapIndexed { i, url -> Page(i, "", url.full()) }
+ }
+
+ override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
+
+ // Filters
+
+ override fun getFilterList(): FilterList = FilterList(
+ Filter.Header("Separate tags with commas (,)"),
+ TagFilter(),
+ )
+
+ class TagFilter : Filter.Text("Tags")
+
+ companion object {
+ const val PREFIX_ID_SEARCH = "id:"
+ }
+}