diff --git a/src/all/netcomics/AndroidManifest.xml b/src/all/netcomics/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/netcomics/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/all/netcomics/build.gradle b/src/all/netcomics/build.gradle
new file mode 100644
index 000000000..c41859c1d
--- /dev/null
+++ b/src/all/netcomics/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'NETCOMICS'
+ pkgNameSuffix = 'all.netcomics'
+ extClass = '.NetcomicsFactory'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/netcomics/res/mipmap-hdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..ddeba992f
Binary files /dev/null and b/src/all/netcomics/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/netcomics/res/mipmap-mdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..4175d4c59
Binary files /dev/null and b/src/all/netcomics/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/netcomics/res/mipmap-xhdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..f0f480027
Binary files /dev/null and b/src/all/netcomics/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/netcomics/res/mipmap-xxhdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..c804a4b73
Binary files /dev/null and b/src/all/netcomics/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/netcomics/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..47b63387c
Binary files /dev/null and b/src/all/netcomics/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/netcomics/res/web_hi_res_512.png b/src/all/netcomics/res/web_hi_res_512.png
new file mode 100644
index 000000000..8bacbd77c
Binary files /dev/null and b/src/all/netcomics/res/web_hi_res_512.png differ
diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/GenreFilter.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/GenreFilter.kt
new file mode 100644
index 000000000..7bafc68d5
--- /dev/null
+++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/GenreFilter.kt
@@ -0,0 +1,23 @@
+package eu.kanade.tachiyomi.extension.all.netcomics
+
+import eu.kanade.tachiyomi.source.model.Filter
+
+internal class GenreFilter(
+ values: Array = genres
+) : Filter.Select("Genre", values) {
+ override fun toString() = if (state == 0) "" else values[state]
+
+ companion object {
+ internal val NOTE = Header("NOTE: can't be used with text search!")
+
+ private val genres = arrayOf(
+ "All",
+ "BL",
+ "Action",
+ "Comedy",
+ "Romance",
+ "Thriller",
+ "Drama",
+ )
+ }
+}
diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/Netcomics.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/Netcomics.kt
new file mode 100644
index 000000000..4b5d08734
--- /dev/null
+++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/Netcomics.kt
@@ -0,0 +1,259 @@
+package eu.kanade.tachiyomi.extension.all.netcomics
+
+import android.app.Application
+import android.net.Uri
+import androidx.preference.EditTextPreference
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceScreen
+import androidx.preference.SwitchPreferenceCompat
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservable
+import eu.kanade.tachiyomi.source.ConfigurableSource
+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.HttpSource
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.Response
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.util.Calendar
+
+class Netcomics(
+ override val lang: String,
+ private val site: String
+) : ConfigurableSource, HttpSource() {
+ override val name = "NETCOMICS"
+
+ override val baseUrl = "https://www.netcomics.com"
+
+ override val supportsLatest = true
+
+ private val json by lazy { Injekt.get() }
+
+ private val preferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)!!
+ }
+
+ private val adult by lazy {
+ if (preferences.getBoolean("18+", false)) "Y" else "N"
+ }
+
+ private val token by lazy {
+ preferences.getString("token", "")!!
+ }
+
+ private val quality by lazy {
+ preferences.getString("quality", "625")!!
+ }
+
+ private val did by lazy {
+ System.currentTimeMillis().toString()
+ }
+
+ private val apiUri by lazy { Uri.parse(API_URL) }
+
+ private val apiHeaders by lazy {
+ headers.newBuilder()
+ .set("Origin", baseUrl)
+ .set("platform", "android")
+ .set("adult", adult)
+ .set("token", token)
+ .set("site", site)
+ .set("did", did)
+ .build()
+ }
+
+ private val day by lazy {
+ when (Calendar.getInstance()[Calendar.DAY_OF_WEEK]) {
+ Calendar.MONDAY -> "1"
+ Calendar.TUESDAY -> "2"
+ Calendar.WEDNESDAY -> "3"
+ Calendar.THURSDAY -> "4"
+ Calendar.FRIDAY -> "5"
+ else -> ""
+ }
+ }
+
+ // Request the real URL for the webview
+ override fun mangaDetailsRequest(manga: SManga) =
+ GET("$baseUrl/$site/comic/${manga.slug}", headers)
+
+ override fun searchMangaParse(response: Response) =
+ response.data>().ifEmpty {
+ error("No more pages")
+ }.map {
+ SManga.create().apply {
+ url = it.slug
+ title = it.toString()
+ genre = it.genres
+ author = it.authors
+ artist = it.artists
+ description = it.description
+ thumbnail_url = it.thumbnail
+ status = when {
+ it.isCompleted -> SManga.COMPLETED
+ else -> SManga.ONGOING
+ }
+ }
+ }.run { MangasPage(this, size == 20) }
+
+ override fun chapterListParse(response: Response) =
+ response.data>().map {
+ SChapter.create().apply {
+ url = it.path
+ name = it.toString()
+ date_upload = it.timestamp
+ chapter_number = it.number
+ }
+ }
+
+ override fun pageListParse(response: Response) =
+ response.data().map {
+ Page(it.seq, "", it.toString())
+ }
+
+ override fun fetchLatestUpdates(page: Int) =
+ apiUri.fetch("title", ::searchMangaParse) {
+ appendEncodedPath("new")
+ appendQueryParameter("no", page.toString())
+ appendQueryParameter("size", "20")
+ appendQueryParameter("day", day)
+ }
+
+ override fun fetchPopularManga(page: Int) =
+ apiUri.fetch("title", ::searchMangaParse) {
+ appendEncodedPath("free")
+ appendQueryParameter("no", page.toString())
+ appendQueryParameter("size", "20")
+ }
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
+ apiUri.fetch("title", ::searchMangaParse) {
+ if (query.isNotBlank()) {
+ appendEncodedPath("search/text")
+ appendQueryParameter("text", query)
+ } else {
+ appendEncodedPath("genre")
+ appendQueryParameter("genre", filters.genre)
+ }
+ appendQueryParameter("no", page.toString())
+ appendQueryParameter("size", "20")
+ }
+
+ override fun fetchMangaDetails(manga: SManga) =
+ rx.Observable.just(manga.apply { initialized = true })!!
+
+ override fun fetchChapterList(manga: SManga) =
+ apiUri.fetch("chapter", ::chapterListParse) {
+ appendEncodedPath("list")
+ appendEncodedPath(manga.id)
+ appendEncodedPath("rent")
+ }
+
+ override fun fetchPageList(chapter: SChapter) =
+ apiUri.fetch("chapter", ::pageListParse) {
+ appendEncodedPath("viewer")
+ appendEncodedPath(quality)
+ appendEncodedPath(chapter.url)
+ }
+
+ override fun getFilterList() =
+ FilterList(GenreFilter.NOTE, GenreFilter())
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ SwitchPreferenceCompat(screen.context).apply {
+ key = "18+"
+ title = "Show 18+"
+ summaryOff = "18+ OFF"
+ summaryOn = "18+ ON"
+ setDefaultValue(false)
+
+ setOnPreferenceChangeListener { _, newValue ->
+ preferences.edit().putBoolean(key, newValue as Boolean).commit()
+ }
+ }.let(screen::addPreference)
+
+ // TODO: grab from the webview somehow
+ EditTextPreference(screen.context).apply {
+ key = "token"
+ title = "API key"
+ dialogTitle = "localStorage['ncx.user.token']"
+
+ setOnPreferenceChangeListener { _, newValue ->
+ preferences.edit().putString(key, newValue as String).commit()
+ }
+ }.let(screen::addPreference)
+
+ ListPreference(screen.context).apply {
+ key = "quality"
+ title = "Image quality"
+ summary = "%s"
+ entries = arrayOf("HD", "Medium")
+ entryValues = arrayOf("1024", "625")
+ setDefaultValue("625")
+
+ setOnPreferenceChangeListener { _, newValue ->
+ preferences.edit().putString(key, newValue as String).commit()
+ }
+ }.let(screen::addPreference)
+ }
+
+ override fun latestUpdatesRequest(page: Int) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun popularMangaRequest(page: Int) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun chapterListRequest(manga: SManga) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun pageListRequest(chapter: SChapter) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun latestUpdatesParse(response: Response) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun popularMangaParse(response: Response) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun mangaDetailsParse(response: Response) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun imageUrlParse(response: Response) =
+ throw UnsupportedOperationException("Not used")
+
+ private inline val SManga.slug: String
+ get() = url.substringBefore('|')
+
+ private inline val SManga.id: String
+ get() = url.substringAfter('|')
+
+ private inline val FilterList.genre: String
+ get() = find { it is GenreFilter }?.toString() ?: ""
+
+ private inline fun Response.data() =
+ json.decodeFromJsonElement(
+ json.parseToJsonElement(body!!.string()).run {
+ jsonObject["data"] ?: throw Error(
+ jsonObject["message"]!!.jsonPrimitive.content
+ )
+ }
+ )
+
+ private inline fun Uri.fetch(
+ path: String,
+ noinline parse: (Response) -> R,
+ block: Uri.Builder.() -> Uri.Builder
+ ) = buildUpon().appendEncodedPath(path).let(block).toString().run {
+ client.newCall(GET(this, apiHeaders)).asObservable().map(parse)!!
+ }
+}
diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsAPI.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsAPI.kt
new file mode 100644
index 000000000..3f8b1ff85
--- /dev/null
+++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsAPI.kt
@@ -0,0 +1,101 @@
+package eu.kanade.tachiyomi.extension.all.netcomics
+
+import kotlinx.serialization.Serializable
+import org.jsoup.Jsoup
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+internal const val API_URL = "https://beta-api.netcomics.com/api/v1"
+
+private const val CDN_URL =
+ "https://cdn.netcomics.com/img/fill/324/0/sm/0/plain/s3://"
+
+private val isoDate by lazy {
+ SimpleDateFormat("yyyy-MM-d'T'HH:mm:ss.SSS'Z'", Locale.ROOT)
+}
+
+@Serializable
+data class Title(
+ private val title_id: Int,
+ private val site: String,
+ private val title_name: String,
+ private val title_slug: String,
+ private val story: String,
+ private val genre: String,
+ private val age_grade: String,
+ private val is_end: String,
+ private val v_cover_img: String,
+ private val author_story_arr: List,
+ private val author_picture_arr: List,
+ private val author_origin_arr: List
+) {
+ val slug: String
+ get() = "$title_slug|$title_id"
+
+ val description: String?
+ get() = Jsoup.parse(story)?.text()
+
+ val thumbnail: String
+ get() = CDN_URL + v_cover_img
+
+ val genres: String
+ get() = "$genre, $age_grade+"
+
+ val authors: String
+ get() = (author_story_arr + author_origin_arr).names
+
+ val artists: String
+ get() = author_picture_arr.names
+
+ val isCompleted: Boolean
+ get() = is_end == "Y"
+
+ override fun toString() = title_name
+
+ private inline val List.names: String
+ get() = joinToString { if (site == "KR") it.text else it.text_en }
+}
+
+@Serializable
+data class Author(val text: String, val text_en: String)
+
+@Serializable
+data class Chapter(
+ private val chapter_id: Int,
+ private val chapter_no: Int,
+ private val chapter_name: String,
+ private val created_at: String,
+ private val title_id: Int,
+ private val is_free: String,
+ private val is_order: String? = null
+) {
+ val path: String
+ get() = "$title_id/$chapter_id"
+
+ val number: Float
+ get() = chapter_no.toFloat()
+
+ val timestamp: Long
+ get() = isoDate.parse(created_at)?.time ?: 0L
+
+ private inline val isLocked: Boolean
+ get() = is_free == "N" && is_order != "Y"
+
+ override fun toString() = buildString {
+ if (chapter_name.isEmpty()) {
+ append("Ch.")
+ append(chapter_no)
+ } else {
+ append(chapter_name)
+ }
+ if (isLocked) append(" \uD83D\uDD12")
+ }
+}
+
+@Serializable
+data class PageList(private val images: List) : List by images
+
+@Serializable
+data class Image(val seq: Int, private val image_url: String) {
+ override fun toString() = image_url
+}
diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsFactory.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsFactory.kt
new file mode 100644
index 000000000..d0163e3df
--- /dev/null
+++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsFactory.kt
@@ -0,0 +1,18 @@
+package eu.kanade.tachiyomi.extension.all.netcomics
+
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class NetcomicsFactory : SourceFactory {
+ override fun createSources() = listOf(
+ Netcomics("en", "EN"),
+ Netcomics("ja", "JA"),
+ Netcomics("zh", "CN"),
+ Netcomics("ko", "KO"),
+ Netcomics("es", "ES"),
+ Netcomics("fr", "FR"),
+ Netcomics("de", "DE"),
+ Netcomics("id", "ID"),
+ Netcomics("vi", "VI"),
+ Netcomics("th", "TH"),
+ )
+}