diff --git a/src/zh/komiic/AndroidManifest.xml b/src/zh/komiic/AndroidManifest.xml
new file mode 100644
index 000000000..5e9c2ef4d
--- /dev/null
+++ b/src/zh/komiic/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/zh/komiic/build.gradle b/src/zh/komiic/build.gradle
new file mode 100644
index 000000000..ed7745b7e
--- /dev/null
+++ b/src/zh/komiic/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Komiic'
+ extClass = '.Komiic'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/zh/komiic/res/mipmap-hdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..8b870210e
Binary files /dev/null and b/src/zh/komiic/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/zh/komiic/res/mipmap-mdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..807a945a9
Binary files /dev/null and b/src/zh/komiic/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/zh/komiic/res/mipmap-xhdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..672fe14ec
Binary files /dev/null and b/src/zh/komiic/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/zh/komiic/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..9fbeef067
Binary files /dev/null and b/src/zh/komiic/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/zh/komiic/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/komiic/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..b1b5eb591
Binary files /dev/null and b/src/zh/komiic/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Komiic.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Komiic.kt
new file mode 100644
index 000000000..5f407ea64
--- /dev/null
+++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Komiic.kt
@@ -0,0 +1,285 @@
+package eu.kanade.tachiyomi.extension.zh.komiic
+
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservableSuccess
+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.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+class Komiic : HttpSource() {
+ // Override variables
+ override var name = "Komiic"
+ override val baseUrl = "https://komiic.com"
+ override val lang = "zh"
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ // Variables
+ private val queryAPIUrl = "$baseUrl/api/query"
+ private val json: Json by injectLazy()
+
+ /**
+ * 解析漫畫列表
+ * Parse comic list
+ */
+ private inline fun parseComicList(response: Response): MangasPage {
+ val res = response.parseAs>()
+ val comics = res.data.comics
+
+ val entries = comics.map { comic ->
+ comic.toSManga()
+ }
+
+ val hasNextPage = comics.size == PAGE_SIZE
+ return MangasPage(entries, hasNextPage)
+ }
+
+ // Hot Comic
+ override fun popularMangaRequest(page: Int): Request {
+ val payload = Payload(
+ operationName = "hotComics",
+ variables = HotComicsVariables(
+ pagination = MangaListPagination(
+ PAGE_SIZE,
+ (page - 1) * PAGE_SIZE,
+ "MONTH_VIEWS",
+ "",
+ true,
+ ),
+ ),
+ query = QUERY_HOT_COMICS,
+ ).toJsonRequestBody()
+ return POST(queryAPIUrl, headers, payload)
+ }
+
+ override fun popularMangaParse(response: Response) = parseComicList(response)
+
+ // Recent update
+ override fun latestUpdatesRequest(page: Int): Request {
+ val payload = Payload(
+ operationName = "recentUpdate",
+ variables = RecentUpdateVariables(
+ pagination = MangaListPagination(
+ PAGE_SIZE,
+ (page - 1) * PAGE_SIZE,
+ "DATE_UPDATED",
+ "",
+ true,
+ ),
+ ),
+ query = QUERY_RECENT_UPDATE,
+ ).toJsonRequestBody()
+ return POST(queryAPIUrl, headers, payload)
+ }
+
+ override fun latestUpdatesParse(response: Response) = parseComicList(response)
+
+ /**
+ * 根據 ID 搜索漫畫
+ * Search the comic based on the ID.
+ */
+ private fun comicByIDRequest(id: String): Request {
+ val payload = Payload(
+ operationName = "comicById",
+ variables = ComicByIdVariables(id),
+ query = QUERY_COMIC_BY_ID,
+ ).toJsonRequestBody()
+ return POST(queryAPIUrl, headers, payload)
+ }
+
+ /**
+ * 根據 ID 解析搜索來的漫畫
+ * Parse the comic based on the ID.
+ */
+ private fun parseComicByID(response: Response): MangasPage {
+ val res = response.parseAs>()
+ val entries = mutableListOf()
+ val comic = res.data.comic.toSManga()
+ entries.add(comic)
+ val hasNextPage = entries.size == PAGE_SIZE
+ return MangasPage(entries, hasNextPage)
+ }
+
+ // Search
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val payload = Payload(
+ operationName = "searchComicAndAuthorQuery",
+ variables = SearchVariables(query),
+ query = QUERY_SEARCH,
+ ).toJsonRequestBody()
+ return POST(queryAPIUrl, headers, payload)
+ }
+
+ override fun fetchSearchManga(
+ page: Int,
+ query: String,
+ filters: FilterList,
+ ): Observable {
+ return if (query.startsWith(PREFIX_ID_SEARCH)) {
+ val mangaId = query.substringAfter(PREFIX_ID_SEARCH)
+ client.newCall(comicByIDRequest(mangaId))
+ .asObservableSuccess()
+ .map(::parseComicByID)
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val res = response.parseAs>()
+ val comics = res.data.action.comics
+
+ val entries = comics.map { comic ->
+ comic.toSManga()
+ }
+
+ val hasNextPage = comics.size == PAGE_SIZE
+ return MangasPage(entries, hasNextPage)
+ }
+
+ // Comic details
+ override fun mangaDetailsRequest(manga: SManga) = comicByIDRequest(manga.url.substringAfterLast("/"))
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val res = response.parseAs>()
+ val comic = res.data.comic.toSManga()
+ return comic
+ }
+
+ override fun getMangaUrl(manga: SManga) = "$baseUrl${manga.url}"
+
+ /**
+ * 解析日期
+ * Parse date
+ */
+ private fun parseDate(dateStr: String): Long {
+ return try {
+ DATE_FORMAT.parse(dateStr)?.time ?: 0L
+ } catch (e: ParseException) {
+ e.printStackTrace()
+ 0L
+ }
+ }
+
+ // Chapter list
+ override fun chapterListRequest(manga: SManga): Request {
+ val payload = Payload(
+ operationName = "chapterByComicId",
+ variables = ChapterByComicIdVariables(manga.url.substringAfterLast("/")),
+ query = QUERY_CHAPTER,
+ ).toJsonRequestBody()
+
+ return POST("$queryAPIUrl#${manga.url}", headers, payload)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val res = response.parseAs>()
+ val comics = res.data.chapters
+ val comicUrl = response.request.url.fragment
+
+ val tChapters = comics.filter { it.type == "chapter" }
+ val tBooks = comics.filter { it.type == "book" }
+
+ val entries = (tChapters + tBooks).map { chapter ->
+ SChapter.create().apply {
+ url = "$comicUrl/chapter/${chapter.id}/page/1"
+ name = when (chapter.type) {
+ "chapter" -> "第 ${chapter.serial} 話"
+ "book" -> "第 ${chapter.serial} 卷"
+ else -> chapter.serial
+ }
+ date_upload = parseDate(chapter.dateCreated)
+ chapter_number = chapter.serial.toFloatOrNull() ?: -1f
+ }
+ }.reversed()
+
+ return entries
+ }
+
+ /**
+ * 檢查 API 是否達到上限
+ * Check if the API has reached its limit.
+ *
+ * (Idk how to throw an exception in reading page)
+ */
+ // private fun fetchAPILimit(): Boolean {
+ // val payload = Payload("getImageLimit", "", QUERY_API_LIMIT).toJsonRequestBody()
+ // val response = client.newCall(POST(queryAPIUrl, headers, payload)).execute()
+ // val limit = response.parseAs().getImageLimit
+ // return limit.limit <= limit.usage
+ // }
+
+ // Page list
+ override fun pageListRequest(chapter: SChapter): Request {
+ val payload = Payload(
+ operationName = "imagesByChapterId",
+ variables = ImagesByChapterIdVariables(
+ chapter.url.substringAfter("/chapter/").substringBefore("/page/"),
+ ),
+ query = QUERY_PAGE_LIST,
+ ).toJsonRequestBody()
+
+ return POST("$queryAPIUrl#${chapter.url}", headers, payload)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val res = response.parseAs>()
+ val pages = res.data.images
+ val chapterUrl = response.request.url.toString().split("#")[1]
+
+ return pages.mapIndexed { index, image ->
+ Page(
+ index,
+ "${chapterUrl.substringBeforeLast("/")}/$index",
+ "$baseUrl/api/image/${image.kid}",
+ )
+ }
+ }
+
+ override fun imageRequest(page: Page): Request {
+ return super.imageRequest(page).newBuilder()
+ .addHeader("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'")
+ .addHeader("referer", page.url)
+ .build()
+ }
+
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
+
+ private inline fun String.parseAs(): T =
+ json.decodeFromString(this)
+
+ private inline fun Response.parseAs(): T =
+ use { body.string() }.parseAs()
+
+ private inline fun T.toJsonRequestBody(): RequestBody =
+ json.encodeToString(this)
+ .toRequestBody(JSON_MEDIA_TYPE)
+
+ companion object {
+ private const val PAGE_SIZE = 20
+ const val PREFIX_ID_SEARCH = "id:"
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
+ private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ }
+}
diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Payload.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Payload.kt
new file mode 100644
index 000000000..f4c02454b
--- /dev/null
+++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Payload.kt
@@ -0,0 +1,49 @@
+package eu.kanade.tachiyomi.extension.zh.komiic
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+class Payload(
+ val operationName: String,
+ val variables: T,
+ val query: String,
+)
+
+@Serializable
+data class MangaListPagination(
+ val limit: Int,
+ val offset: Int,
+ val orderBy: String,
+ val status: String,
+ val asc: Boolean,
+)
+
+@Serializable
+data class HotComicsVariables(
+ val pagination: MangaListPagination,
+)
+
+@Serializable
+data class RecentUpdateVariables(
+ val pagination: MangaListPagination,
+)
+
+@Serializable
+data class SearchVariables(
+ val keyword: String,
+)
+
+@Serializable
+data class ComicByIdVariables(
+ val comicId: String,
+)
+
+@Serializable
+data class ChapterByComicIdVariables(
+ val comicId: String,
+)
+
+@Serializable
+data class ImagesByChapterIdVariables(
+ val chapterId: String,
+)
diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Queries.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Queries.kt
new file mode 100644
index 000000000..a8a2704a7
--- /dev/null
+++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Queries.kt
@@ -0,0 +1,187 @@
+package eu.kanade.tachiyomi.extension.zh.komiic
+
+private fun buildQuery(queryAction: () -> String): String {
+ return queryAction()
+ .trimIndent()
+ .replace("%", "$")
+}
+
+val QUERY_HOT_COMICS: String = buildQuery {
+ """
+ query hotComics(%pagination: Pagination!) {
+ hotComics(pagination: %pagination) {
+ id
+ title
+ status
+ year
+ imageUrl
+ authors {
+ id
+ name
+ __typename
+ }
+ categories {
+ id
+ name
+ __typename
+ }
+ dateUpdated
+ monthViews
+ views
+ favoriteCount
+ lastBookUpdate
+ lastChapterUpdate
+ __typename
+ }
+ }
+ """
+}
+
+val QUERY_RECENT_UPDATE: String = buildQuery {
+ """
+ query recentUpdate(%pagination: Pagination!) {
+ recentUpdate(pagination: %pagination) {
+ id
+ title
+ status
+ year
+ imageUrl
+ authors {
+ id
+ name
+ __typename
+ }
+ categories {
+ id
+ name
+ __typename
+ }
+ dateUpdated
+ monthViews
+ views
+ favoriteCount
+ lastBookUpdate
+ lastChapterUpdate
+ __typename
+ }
+ }
+ """
+}
+
+val QUERY_SEARCH: String = buildQuery {
+ """
+ query searchComicAndAuthorQuery(%keyword: String!) {
+ searchComicsAndAuthors(keyword: %keyword) {
+ comics {
+ id
+ title
+ status
+ year
+ imageUrl
+ authors {
+ id
+ name
+ __typename
+ }
+ categories {
+ id
+ name
+ __typename
+ }
+ dateUpdated
+ monthViews
+ views
+ favoriteCount
+ lastBookUpdate
+ lastChapterUpdate
+ __typename
+ }
+ authors {
+ id
+ name
+ chName
+ enName
+ wikiLink
+ comicCount
+ views
+ __typename
+ }
+ __typename
+ }
+ }
+ """
+}
+
+val QUERY_CHAPTER: String = buildQuery {
+ """
+ query chapterByComicId(%comicId: ID!) {
+ chaptersByComicId(comicId: %comicId) {
+ id
+ serial
+ type
+ dateCreated
+ dateUpdated
+ size
+ __typename
+ }
+ }
+ """
+}
+
+val QUERY_COMIC_BY_ID = buildQuery {
+ """
+ query comicById(%comicId: ID!) {
+ comicById(comicId: %comicId) {
+ id
+ title
+ status
+ year
+ imageUrl
+ authors {
+ id
+ name
+ __typename
+ }
+ categories {
+ id
+ name
+ __typename
+ }
+ dateCreated
+ dateUpdated
+ views
+ favoriteCount
+ lastBookUpdate
+ lastChapterUpdate
+ __typename
+ }
+ }
+ """
+}
+
+val QUERY_PAGE_LIST = buildQuery {
+ """
+ query imagesByChapterId(%chapterId: ID!) {
+ imagesByChapterId(chapterId: %chapterId) {
+ id
+ kid
+ height
+ width
+ __typename
+ }
+ }
+ """
+}
+
+val QUERY_API_LIMIT = buildQuery {
+ """
+ query getImageLimit {
+ getImageLimit {
+ limit
+ usage
+ resetInSeconds
+ __typename
+ }
+ }
+ """
+}
diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Response.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Response.kt
new file mode 100644
index 000000000..14b33c015
--- /dev/null
+++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Response.kt
@@ -0,0 +1,160 @@
+package eu.kanade.tachiyomi.extension.zh.komiic
+
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Data(val data: T)
+
+interface ComicListResult {
+ val comics: List
+}
+
+@Serializable
+data class HotComicsResponse(
+ @SerialName("hotComics") override val comics: List,
+) : ComicListResult
+
+@Serializable
+data class RecentUpdateResponse(
+ @SerialName("recentUpdate") override val comics: List,
+) : ComicListResult
+
+interface SearchResult {
+ val action: ComicsAndAuthors
+}
+
+@Serializable
+data class SearchResponse(
+ @SerialName("searchComicsAndAuthors") override val action: ComicsAndAuthors,
+) : SearchResult
+
+@Serializable
+data class ComicsAndAuthors(
+ val comics: List,
+ val authors: List,
+ @SerialName("__typename") val typeName: String,
+)
+
+interface ComicResult {
+ val comic: Comic
+}
+
+@Serializable
+data class ComicByIDResponse(
+ @SerialName("comicById") override val comic: Comic,
+) : ComicResult
+
+@Serializable
+data class Comic(
+ val id: String,
+ val title: String,
+ val status: String,
+ val year: Int,
+ val imageUrl: String,
+ var authors: List,
+ val categories: List,
+ val dateCreated: String = "",
+ val dateUpdated: String,
+ val monthViews: Int = 0,
+ val views: Int,
+ val favoriteCount: Int,
+ val lastBookUpdate: String,
+ val lastChapterUpdate: String,
+ @SerialName("__typename") val typeName: String,
+) {
+ private val parseStatus = when (status) {
+ "ONGOING" -> SManga.ONGOING
+ "END" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+
+ fun toSManga() = SManga.create().apply {
+ url = "/comic/$id"
+ title = this@Comic.title
+ thumbnail_url = this@Comic.imageUrl
+ author = this@Comic.authors.joinToString { it.name }
+ genre = this@Comic.categories.joinToString { it.name }
+ description = buildString {
+ append("年份: $year | ")
+ append("點閱: ${simplifyNumber(views)} | ")
+ append("喜愛: ${simplifyNumber(favoriteCount)}\n")
+ }
+ status = parseStatus
+ initialized = true
+ }
+}
+
+@Serializable
+data class ComicCategory(
+ val id: String,
+ val name: String,
+ @SerialName("__typename") val typeName: String,
+)
+
+@Serializable
+data class ComicAuthor(
+ val id: String,
+ val name: String,
+ @SerialName("__typename") val typeName: String,
+)
+
+@Serializable
+data class Author(
+ val id: String,
+ val name: String,
+ val chName: String,
+ val enName: String,
+ val wikiLink: String,
+ val comicCount: Int,
+ val views: Int,
+ @SerialName("__typename") val typeName: String,
+)
+
+interface ChaptersResult {
+ val chapters: List
+}
+
+@Serializable
+data class ChaptersResponse(
+ @SerialName("chaptersByComicId") override val chapters: List,
+) : ChaptersResult
+
+@Serializable
+data class Chapter(
+ val id: String,
+ val serial: String,
+ val type: String,
+ val dateCreated: String,
+ val dateUpdated: String,
+ val size: Int,
+ @SerialName("__typename") val typeName: String,
+)
+
+@Serializable
+data class ImagesResponse(
+ @SerialName("imagesByChapterId") val images: List,
+)
+
+@Serializable
+data class Image(
+ val id: String,
+ val kid: String,
+ val height: Int,
+ val width: Int,
+ @SerialName("__typename") val typeName: String,
+)
+
+@Serializable
+data class APILimitData(
+ @SerialName("getImageLimit") val getImageLimit: APILimit,
+)
+
+@Serializable
+data class APILimit(
+ val limit: Int,
+ val usage: Int,
+ val resetInSeconds: String,
+ @SerialName("__typename") val typeName: String,
+)
diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/UrlActivity.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/UrlActivity.kt
new file mode 100644
index 000000000..54be52071
--- /dev/null
+++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/UrlActivity.kt
@@ -0,0 +1,34 @@
+package eu.kanade.tachiyomi.extension.zh.komiic
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class UrlActivity : 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", "${Komiic.PREFIX_ID_SEARCH}$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("KomiicUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("KomiicUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}
diff --git a/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Utils.kt b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Utils.kt
new file mode 100644
index 000000000..5f5aff299
--- /dev/null
+++ b/src/zh/komiic/src/eu/kanade/tachiyomi/extension/zh/komiic/Utils.kt
@@ -0,0 +1,15 @@
+package eu.kanade.tachiyomi.extension.zh.komiic
+
+import kotlin.math.abs
+
+/**
+ * 簡化數字顯示
+ */
+fun simplifyNumber(num: Int): String {
+ return when {
+ abs(num) < 1000 -> "$num"
+ abs(num) < 10000 -> "${num / 1000}千"
+ abs(num) < 100000000 -> "${num / 10000}萬"
+ else -> "${num / 100000000}億"
+ }
+}