diff --git a/src/en/inkr/AndroidManifest.xml b/src/en/inkr/AndroidManifest.xml
new file mode 100644
index 000000000..b4571bfa8
--- /dev/null
+++ b/src/en/inkr/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/en/inkr/build.gradle b/src/en/inkr/build.gradle
new file mode 100644
index 000000000..da62c2ac2
--- /dev/null
+++ b/src/en/inkr/build.gradle
@@ -0,0 +1,19 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'INKR'
+ pkgNameSuffix = 'en.inkr'
+ extClass = '.Inkr'
+ extVersionCode = 3
+ libVersion = '1.2'
+ containsNsfw = true
+}
+
+dependencies {
+ implementation project(':lib-ratelimit')
+}
+
+apply from: "$rootDir/common.gradle"
+
diff --git a/src/en/inkr/res/mipmap-hdpi/ic_launcher.png b/src/en/inkr/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..161a7729c
Binary files /dev/null and b/src/en/inkr/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/inkr/res/mipmap-mdpi/ic_launcher.png b/src/en/inkr/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..6a0af18a1
Binary files /dev/null and b/src/en/inkr/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/inkr/res/mipmap-xhdpi/ic_launcher.png b/src/en/inkr/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..0bd16ca94
Binary files /dev/null and b/src/en/inkr/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/inkr/res/mipmap-xxhdpi/ic_launcher.png b/src/en/inkr/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..9ec914d7f
Binary files /dev/null and b/src/en/inkr/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/inkr/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/inkr/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..e13b5e95c
Binary files /dev/null and b/src/en/inkr/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/inkr/res/web_hi_res_512.png b/src/en/inkr/res/web_hi_res_512.png
new file mode 100644
index 000000000..611839749
Binary files /dev/null and b/src/en/inkr/res/web_hi_res_512.png differ
diff --git a/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/Inkr.kt b/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/Inkr.kt
new file mode 100644
index 000000000..32a5d4b8d
--- /dev/null
+++ b/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/Inkr.kt
@@ -0,0 +1,335 @@
+package eu.kanade.tachiyomi.extension.en.inkr
+
+import eu.kanade.tachiyomi.annotations.Nsfw
+import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
+import eu.kanade.tachiyomi.network.GET
+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 eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.encodeToJsonElement
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonArray
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+@Nsfw
+class Inkr : HttpSource() {
+
+ override val name = "INKR"
+
+ override val baseUrl = "https://inkr.com"
+
+ override val lang = "en"
+
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .addInterceptor(::buildIdIntercept)
+ .addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS))
+ .build()
+
+ private val json: Json by injectLazy()
+
+ private var buildId: String? = null
+
+ override fun headersBuilder(): Headers.Builder = Headers.Builder()
+ .add("Origin", baseUrl)
+ .add("Referer", "$baseUrl/")
+
+ override fun popularMangaRequest(page: Int): Request {
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .build()
+
+ return GET("$baseUrl/_next/data/buildId/index.json", newHeaders)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val result = json.decodeFromString>(response.body!!.string())
+
+ if (result.pageProps == null) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ val comicList = result.pageProps.topCharts!!.topTrending.map(::popularMangaFromObject)
+
+ return MangasPage(comicList, hasNextPage = false)
+ }
+
+ private fun popularMangaFromObject(comic: InkrComic) = SManga.create().apply {
+ title = comic.name
+ thumbnail_url = comic.thumbnailURL
+ url = "/${comic.oid}"
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request = popularMangaRequest(page)
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val result = json.decodeFromString>(response.body!!.string())
+
+ if (result.pageProps == null) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ val comicList = result.pageProps.latestUpdateDetails.map(::latestMangaFromObject)
+
+ return MangasPage(comicList, hasNextPage = false)
+ }
+
+ private fun latestMangaFromObject(comic: InkrComic) = popularMangaFromObject(comic)
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val payload = buildJsonObject { put("query", query) }
+
+ val requestBody = payload.toString().toRequestBody(JSON_MEDIA_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .add("Cf-Ipcountry", "VN")
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .add("Ikc-Platform", "android")
+ .build()
+
+ return POST("$ICQ_API_URL/title/search", newHeaders, requestBody)
+ }
+
+ private fun searchDetailsRequest(oids: List): Request {
+ val payload = buildJsonObject {
+ putJsonArray("fields") {
+ add("oid")
+ add("name")
+ add("thumbnailURL")
+ }
+ put("oids", json.encodeToJsonElement(oids))
+ put("url", "$ICD_API_URL/content_json")
+ }
+
+ val requestBody = payload.toString().toRequestBody(JSON_MEDIA_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .add("Cf-Ipcountry", "VN")
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .add("Ikc-Platform", "android")
+ .build()
+
+ return POST("$ICD_API_URL/content_json", newHeaders, requestBody)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val result = json.decodeFromString>(response.body!!.string())
+
+ if (result.data == null) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ val searchResults = result.data.title.take(SEARCH_LIMIT)
+
+ val detailsRequest = searchDetailsRequest(searchResults)
+ val detailsResponse = client.newCall(detailsRequest).execute()
+ val detailsJson = detailsResponse.body!!.string()
+ val detailsResult = json.decodeFromString>>(detailsJson)
+
+ if (detailsResult.data == null) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ // Use the searchResults to iterate to keep the result order.
+ val comicList = searchResults.map { oid ->
+ searchMangaFromObject(detailsResult.data[oid]!!)
+ }
+
+ return MangasPage(comicList, hasNextPage = false)
+ }
+
+ private fun searchMangaFromObject(comic: InkrComic) = popularMangaFromObject(comic)
+
+ // Workaround to allow "Open in browser" use the real URL.
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return client.newCall(mangaDetailsApiRequest(manga.url))
+ .asObservableSuccess()
+ .map { response ->
+ mangaDetailsParse(response).apply { initialized = true }
+ }
+ }
+
+ private fun mangaDetailsApiRequest(mangaUrl: String): Request {
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_JSON)
+ .build()
+
+ val comicId = mangaUrl.substringAfterLast("/")
+
+ return GET("$baseUrl/_next/data/buildId/$comicId.json", newHeaders)
+ }
+
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val newHeaders = headersBuilder()
+ .removeAll("Accept")
+ .build()
+
+ return GET(baseUrl + manga.url, newHeaders)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
+ val result = json.decodeFromString>(response.body!!.string())
+
+ if (result.pageProps == null) {
+ throw Exception(COULD_NOT_PARSE_RESPONSE)
+ }
+
+ val comic = result.pageProps.titleInfo!!
+
+ title = comic.name
+ author = comic.creators
+ .filter { it.role == "story" }
+ .joinToString(", ") { it.name }
+ artist = comic.creators
+ .filter { it.role == "art" }
+ .joinToString(", ") { it.name }
+ description = comic.summary.joinToString("\n\n")
+ .plus(if (comic.extras?.containsKey("Copyright") == true) "\n\n${comic.extras["Copyright"]}" else "")
+ genre = comic.listGenre?.jsonArray?.joinToString(", ") { it.jsonPrimitive.content }
+ status = comic.releaseStatus.toStatus()
+ thumbnail_url = comic.thumbnailURL
+ }
+
+ override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga.url)
+
+ override fun chapterListParse(response: Response): List {
+ val result = json.decodeFromString>(response.body!!.string())
+
+ if (result.pageProps == null) {
+ throw Exception(COULD_NOT_PARSE_RESPONSE)
+ }
+
+ val comic = result.pageProps.titleInfo!!
+
+ if (comic.webPreviewingPages.isEmpty()) {
+ return emptyList()
+ }
+
+ val previewChapter = SChapter.create().apply {
+ name = "Preview"
+ scanlator = comic.creators.firstOrNull { it.role == "publisher" }?.name
+ date_upload = comic.firstChapterFirstPublishedDate.toDate()
+ url = "/${comic.oid}"
+ }
+
+ return listOf(previewChapter)
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request = mangaDetailsApiRequest(chapter.url)
+
+ override fun pageListParse(response: Response): List {
+ val result = json.decodeFromString>(response.body!!.string())
+
+ if (result.pageProps == null) {
+ throw Exception(COULD_NOT_PARSE_RESPONSE)
+ }
+
+ val comic = result.pageProps.titleInfo!!
+ val referer = "$baseUrl/"
+
+ return comic.webPreviewingPages
+ .mapIndexed { i, page -> Page(i, referer, page.url) }
+ }
+
+ override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!)
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ override fun imageRequest(page: Page): Request {
+ val newHeaders = headersBuilder()
+ .set("Accept", ACCEPT_IMAGE)
+ .set("Referer", page.url)
+ .build()
+
+ return GET(page.imageUrl!!, newHeaders)
+ }
+
+ private fun buildIdIntercept(chain: Interceptor.Chain): Response {
+ if (chain.request().url.toString().contains("/buildId/").not()) {
+ return chain.proceed(chain.request())
+ }
+
+ if (buildId == null) {
+ val buildIdRequest = GET(baseUrl, headers)
+ val buildIdResponse = chain.proceed(buildIdRequest)
+ val document = buildIdResponse.asJsoup()
+
+ val nextData = document.select("script#__NEXT_DATA__")
+ .firstOrNull()?.data() ?: throw IOException(COULD_NOT_FIND_BUILD_ID)
+ val nextJson = json.parseToJsonElement(nextData).jsonObject
+
+ buildId = nextJson["buildId"]!!.jsonPrimitive.content
+
+ buildIdResponse.close()
+ }
+
+ val newRequestUrl = chain.request().url.toString()
+ .replace("buildId", buildId!!)
+ .toHttpUrl()
+
+ val newRequest = chain.request().newBuilder()
+ .url(newRequestUrl)
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+
+ private fun String.toDate(): Long {
+ return runCatching { DATE_FORMATTER.parse(substringBefore("T"))?.time }
+ .getOrNull() ?: 0L
+ }
+
+ private fun String.toStatus(): Int = when (this) {
+ "ongoing", "hold" -> SManga.ONGOING
+ "completed" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+
+ companion object {
+ private const val ICQ_API_URL = "https://icq-api.inkr.com/v1"
+ private const val ICD_API_URL = "https://icd-api.inkr.com/v1"
+
+ private const val ACCEPT_JSON = "application/json, text/plain, */*"
+ private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"
+
+ private val JSON_MEDIA_TYPE = "application/json; charset=UTF-8".toMediaType()
+
+ private const val COULD_NOT_FIND_BUILD_ID = "Could not find the API token."
+ private const val COULD_NOT_PARSE_RESPONSE = "Could not parse the API response."
+
+ private const val SEARCH_LIMIT = 30
+
+ private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
+ }
+}
diff --git a/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/InkrDto.kt b/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/InkrDto.kt
new file mode 100644
index 000000000..88ccac24a
--- /dev/null
+++ b/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/InkrDto.kt
@@ -0,0 +1,61 @@
+package eu.kanade.tachiyomi.extension.en.inkr
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+
+@Serializable
+data class NextJsWrapper(
+ val pageProps: T? = null
+)
+
+@Serializable
+data class InkrResult(
+ val code: Int = -1,
+ val data: T? = null
+)
+
+@Serializable
+data class InkrHome(
+ val latestUpdateDetails: List = emptyList(),
+ val topCharts: InkrHomeCharts? = null
+)
+
+@Serializable
+data class InkrHomeCharts(
+ val topTrending: List = emptyList()
+)
+
+@Serializable
+data class InkrSearch(
+ val title: List = emptyList()
+)
+
+@Serializable
+data class InkrTitleInfo(
+ val titleInfo: InkrComic? = null
+)
+
+@Serializable
+data class InkrComic(
+ val creators: List = emptyList(),
+ val extras: Map? = emptyMap(),
+ val firstChapterFirstPublishedDate: String = "",
+ val listGenre: JsonElement? = null,
+ val name: String = "",
+ val oid: String = "",
+ val releaseStatus: String = "",
+ val summary: List = emptyList(),
+ val thumbnailURL: String = "",
+ val webPreviewingPages: List = emptyList()
+)
+
+@Serializable
+data class InkrPerson(
+ val name: String = "",
+ val role: String = ""
+)
+
+@Serializable
+data class InkrPage(
+ val url: String = ""
+)