diff --git a/src/all/comico/AndroidManifest.xml b/src/all/comico/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/comico/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/all/comico/build.gradle b/src/all/comico/build.gradle
new file mode 100644
index 000000000..1e456374d
--- /dev/null
+++ b/src/all/comico/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Comico'
+ pkgNameSuffix = 'all.comico'
+ extClass = '.ComicoFactory'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/comico/res/mipmap-hdpi/ic_launcher.png b/src/all/comico/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..c691fb27c
Binary files /dev/null and b/src/all/comico/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/comico/res/mipmap-mdpi/ic_launcher.png b/src/all/comico/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..a846eabc1
Binary files /dev/null and b/src/all/comico/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/comico/res/mipmap-xhdpi/ic_launcher.png b/src/all/comico/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..ec698790a
Binary files /dev/null and b/src/all/comico/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/comico/res/mipmap-xxhdpi/ic_launcher.png b/src/all/comico/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..f592ec627
Binary files /dev/null and b/src/all/comico/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/comico/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/comico/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..19ea10e56
Binary files /dev/null and b/src/all/comico/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/comico/res/web_hi_res_512.png b/src/all/comico/res/web_hi_res_512.png
new file mode 100644
index 000000000..4ead0a852
Binary files /dev/null and b/src/all/comico/res/web_hi_res_512.png differ
diff --git a/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/Comico.kt b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/Comico.kt
new file mode 100644
index 000000000..784b5062b
--- /dev/null
+++ b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/Comico.kt
@@ -0,0 +1,259 @@
+package eu.kanade.tachiyomi.extension.all.comico
+
+import android.webkit.CookieManager
+import com.squareup.duktape.Duktape
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+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.JsonElement
+import kotlinx.serialization.json.boolean
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.HttpUrl
+import okhttp3.Response
+import uy.kohesive.injekt.injectLazy
+import java.security.MessageDigest
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+open class Comico(
+ final override val baseUrl: String,
+ final override val name: String,
+ private val langCode: String
+) : HttpSource() {
+ final override val supportsLatest = true
+
+ override val lang = langCode.substring(0, 2)
+
+ protected open val apiUrl = baseUrl.replace("www", "api")
+
+ private val json by injectLazy()
+
+ private val cookieManager by lazy { CookieManager.getInstance() }
+
+ private val cryptoJs by lazy {
+ client.newCall(GET(CRYPTOJS)).execute().body!!.string()
+ }
+
+ private val imgHeaders by lazy {
+ headersBuilder().set("Accept", ACCEPT_IMAGE).build()
+ }
+
+ private val apiHeaders: Headers
+ get() = headersBuilder().apply {
+ val time = System.currentTimeMillis() / 1000L
+ this["X-comico-request-time"] = time.toString()
+ this["X-comico-check-sum"] = sha256(time)
+ this["X-comico-client-immutable-uid"] = ANON_IP
+ this["X-comico-client-accept-mature"] = "Y"
+ this["X-comico-client-platform"] = "web"
+ this["X-comico-client-store"] = "other"
+ this["X-comico-client-os"] = "aos"
+ this["Origin"] = baseUrl
+ }.build()
+
+ override val client = network.client.newBuilder()
+ .cookieJar(object : CookieJar {
+ override fun saveFromResponse(url: HttpUrl, cookies: List) =
+ cookies.filter { it.matches(url) }.forEach {
+ cookieManager.setCookie(url.toString(), it.toString())
+ }
+
+ override fun loadForRequest(url: HttpUrl) =
+ cookieManager.getCookie(url.toString())?.split("; ")
+ ?.mapNotNull { Cookie.parse(url, it) } ?: emptyList()
+ }).build()
+
+ override fun headersBuilder() = Headers.Builder()
+ .set("Accept-Language", langCode)
+ .set("User-Agent", userAgent)
+ .set("Referer", "$baseUrl/")
+
+ override fun latestUpdatesRequest(page: Int) =
+ paginate("all_comic/daily/$day", page)
+
+ override fun popularMangaRequest(page: Int) =
+ paginate("all_comic/ranking/trending", page)
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
+ if (query.isEmpty()) paginate("all_comic/read_for_free", page)
+ else POST("$apiUrl/search", apiHeaders, search(query, page))
+
+ override fun chapterListRequest(manga: SManga) =
+ GET(apiUrl + manga.url + "/episode", apiHeaders)
+
+ override fun pageListRequest(chapter: SChapter) =
+ GET(apiUrl + chapter.url, apiHeaders)
+
+ override fun imageRequest(page: Page) =
+ GET(page.imageUrl!!.also { android.util.Log.w("URL", it) }, imgHeaders)
+
+ override fun latestUpdatesParse(response: Response) =
+ popularMangaParse(response)
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val data = response.data
+ val hasNext = data["page"]["hasNext"]
+ val mangas = data.map("contents") {
+ SManga.create().apply {
+ title = it.name
+ url = "/comic/${it.id}"
+ thumbnail_url = it.cover
+ description = it.description
+ status = when (it.status) {
+ "completed" -> SManga.COMPLETED
+ else -> SManga.ONGOING
+ }
+ author = it.authors.filter { it.isAuthor }.joinToString()
+ artist = it.authors.filter { it.isArtist }.joinToString()
+ genre = buildString {
+ it.genres.joinTo(this)
+ if (it.mature) append(", Mature")
+ if (it.original) append(", Original")
+ if (it.exclusive) append(", Exclusive")
+ }
+ }
+ }
+ return MangasPage(mangas, hasNext.jsonPrimitive.boolean)
+ }
+
+ override fun searchMangaParse(response: Response) =
+ popularMangaParse(response)
+
+ override fun chapterListParse(response: Response): List {
+ val content = response.data["episode"]["content"]
+ val id = content["id"].jsonPrimitive.int
+ return content.map("chapters") {
+ SChapter.create().apply {
+ chapter_number = it.id.toFloat()
+ url = "/comic/$id/chapter/${it.id}/product"
+ name = it.name + if (it.isAvailable) "" else LOCK
+ date_upload = dateFormat.parse(it.publishedAt)?.time ?: 0L
+ }
+ }
+ }
+
+ override fun pageListParse(response: Response) =
+ response.data["chapter"].map("images") {
+ Page(it.sort, "", it.url.decrypt() + "?" + it.parameter)
+ }
+
+ override fun fetchMangaDetails(manga: SManga) =
+ rx.Observable.just(manga.apply { initialized = true })!!
+
+ override fun fetchPageList(chapter: SChapter) =
+ if (!chapter.name.endsWith(LOCK)) super.fetchPageList(chapter)
+ else throw Error("You are not authorized to view this!")
+
+ private fun search(query: String, page: Int) =
+ FormBody.Builder().add("query", query)
+ .add("pageNo", (page - 1).toString())
+ .add("pageSize", "25").build()
+
+ private fun paginate(route: String, page: Int) =
+ GET("$apiUrl/$route?pageNo=${page - 1}&pageSize=25", apiHeaders)
+
+ private fun String.decrypt() = Duktape.create().use {
+ // javax.crypto.Cipher does not support empty IV
+ val script = """
+ const key = CryptoJS.enc.Utf8.parse('$AES_KEY'), iv = {words: []}
+ CryptoJS.AES.decrypt('$this', key, {iv}).toString(CryptoJS.enc.Utf8)
+ """
+ it.evaluate(cryptoJs + script).toString()
+ }
+
+ private val Response.data: JsonElement?
+ get() = json.parseToJsonElement(body!!.string()).jsonObject.also {
+ val code = it["result"]["code"].jsonPrimitive.int
+ if (code != 200) throw Error(status(code))
+ }["data"]
+
+ private operator fun JsonElement?.get(key: String) =
+ this!!.jsonObject[key]!!
+
+ private inline fun JsonElement?.map(
+ key: String,
+ transform: (T) -> R
+ ) = json.decodeFromJsonElement>(this[key]).map(transform)
+
+ override fun mangaDetailsParse(response: Response) =
+ throw UnsupportedOperationException("Not used")
+
+ override fun imageUrlParse(response: Response) =
+ throw UnsupportedOperationException("Not used")
+
+ companion object {
+ private const val ANON_IP = "0.0.0.0"
+
+ private const val LOCK = " \uD83D\uDD12"
+
+ private const val ISO_DATE = "yyyy-MM-dd'T'HH:mm:ss'Z'"
+
+ private const val WEB_KEY = "9241d2f090d01716feac20ae08ba791a"
+
+ private const val AES_KEY = "a7fc9dc89f2c873d79397f8a0028a4cd"
+
+ private const val CRYPTOJS =
+ "https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js"
+
+ private const val ACCEPT_IMAGE =
+ "image/avif,image/jxl,image/webp,image/*,*/*"
+
+ private val userAgent = System.getProperty("http.agent")!!
+
+ private val dateFormat = SimpleDateFormat(ISO_DATE, Locale.ROOT)
+
+ private val SHA256 = MessageDigest.getInstance("SHA-256")
+
+ private val day by lazy {
+ when (Calendar.getInstance()[Calendar.DAY_OF_WEEK]) {
+ Calendar.SUNDAY -> "sunday"
+ Calendar.MONDAY -> "monday"
+ Calendar.TUESDAY -> "tuesday"
+ Calendar.WEDNESDAY -> "wednesday"
+ Calendar.THURSDAY -> "thursday"
+ Calendar.FRIDAY -> "friday"
+ Calendar.SATURDAY -> "saturday"
+ else -> "completed"
+ }
+ }
+
+ fun sha256(timestamp: Long) = buildString(64) {
+ SHA256.digest((WEB_KEY + ANON_IP + timestamp).toByteArray())
+ .joinTo(this, "") { "%02x".format(it) }
+ SHA256.reset()
+ }
+
+ private fun status(code: Int) = when (code) {
+ 400 -> "Bad Request"
+ 401 -> "Unauthorized"
+ 402 -> "Payment Required"
+ 403 -> "Forbidden"
+ 404 -> "Not Found"
+ 408 -> "Request Timeout"
+ 409 -> "Conflict"
+ 410 -> "DormantAccount"
+ 417 -> "Expectation Failed"
+ 426 -> "Upgrade Required"
+ 428 -> "성인 on/off 권한"
+ 429 -> "Too Many Requests"
+ 500 -> "Internal Server Error"
+ 503 -> "Service Unavailable"
+ 451 -> "성인 인증"
+ else -> "Error $code"
+ }
+ }
+}
diff --git a/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoFactory.kt b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoFactory.kt
new file mode 100644
index 000000000..a76b390ca
--- /dev/null
+++ b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoFactory.kt
@@ -0,0 +1,19 @@
+package eu.kanade.tachiyomi.extension.all.comico
+
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class ComicoFactory : SourceFactory {
+ open class PocketComics(langCode: String) :
+ Comico("https://www.pocketcomics.com", "POCKET COMICS", langCode)
+
+ class ComicoJP : Comico("https://www.comico.jp", "コミコ", "ja-JP")
+
+ class ComicoKR : Comico("https://www.comico.kr", "코미코", "ko-KR")
+
+ override fun createSources() = listOf(
+ PocketComics("en-US"),
+ PocketComics("zh-TW"),
+ ComicoJP(),
+ ComicoKR()
+ )
+}
diff --git a/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoModels.kt b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoModels.kt
new file mode 100644
index 000000000..fe6032af9
--- /dev/null
+++ b/src/all/comico/src/eu/kanade/tachiyomi/extension/all/comico/ComicoModels.kt
@@ -0,0 +1,75 @@
+package eu.kanade.tachiyomi.extension.all.comico
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ContentInfo(
+ val id: Int,
+ val name: String,
+ val description: String,
+ val authors: List,
+ val genres: List,
+ val original: Boolean,
+ val exclusive: Boolean,
+ val mature: Boolean,
+ val status: String? = null,
+ private val thumbnails: List,
+) {
+ val cover: String
+ get() = thumbnails[0].toString()
+}
+
+@Serializable
+data class Thumbnail(private val url: String) {
+ override fun toString() = url
+}
+
+@Serializable
+data class Author(private val name: String, private val role: String) {
+ val isAuthor: Boolean
+ get() = role == "creator" ||
+ role == "writer" ||
+ role == "original_creator"
+
+ val isArtist: Boolean
+ get() = role == "creator" ||
+ role == "artist" ||
+ role == "studio" ||
+ role == "assistant"
+
+ override fun toString() = name
+}
+
+@Serializable
+data class Genre(private val name: String) {
+ override fun toString() = name
+}
+
+@Serializable
+data class Chapter(
+ val id: Int,
+ val name: String,
+ val publishedAt: String,
+ private val salesConfig: SalesConfig,
+ private val hasTrial: Boolean,
+ private val activity: Activity
+) {
+ val isAvailable: Boolean
+ get() = salesConfig.free || hasTrial || activity.owned
+}
+
+@Serializable
+data class SalesConfig(val free: Boolean)
+
+@Serializable
+data class Activity(val rented: Boolean, val unlocked: Boolean) {
+ inline val owned: Boolean
+ get() = rented || unlocked
+}
+
+@Serializable
+data class ChapterImage(
+ val sort: Int,
+ val url: String,
+ val parameter: String
+)