diff --git a/src/en/graphitecomics/AndroidManifest.xml b/src/en/graphitecomics/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/en/graphitecomics/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/en/graphitecomics/build.gradle b/src/en/graphitecomics/build.gradle
new file mode 100644
index 000000000..ea9efda35
--- /dev/null
+++ b/src/en/graphitecomics/build.gradle
@@ -0,0 +1,17 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Graphite Comics'
+ pkgNameSuffix = 'en.graphitecomics'
+ extClass = '.GraphiteComics'
+ extVersionCode = 1
+ libVersion = '1.2'
+}
+
+dependencies {
+ implementation project(':lib-ratelimit')
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/graphitecomics/res/mipmap-hdpi/ic_launcher.png b/src/en/graphitecomics/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..6987be8a9
Binary files /dev/null and b/src/en/graphitecomics/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/graphitecomics/res/mipmap-mdpi/ic_launcher.png b/src/en/graphitecomics/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..f1d52a210
Binary files /dev/null and b/src/en/graphitecomics/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/graphitecomics/res/mipmap-xhdpi/ic_launcher.png b/src/en/graphitecomics/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..5c6c35554
Binary files /dev/null and b/src/en/graphitecomics/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/graphitecomics/res/mipmap-xxhdpi/ic_launcher.png b/src/en/graphitecomics/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..8aabe911e
Binary files /dev/null and b/src/en/graphitecomics/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/graphitecomics/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/graphitecomics/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..a3f8b488d
Binary files /dev/null and b/src/en/graphitecomics/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/graphitecomics/res/web_hi_res_512.png b/src/en/graphitecomics/res/web_hi_res_512.png
new file mode 100644
index 000000000..190336413
Binary files /dev/null and b/src/en/graphitecomics/res/web_hi_res_512.png differ
diff --git a/src/en/graphitecomics/src/eu/kanade/tachiyomi/extension/en/graphitecomics/GraphiteComics.kt b/src/en/graphitecomics/src/eu/kanade/tachiyomi/extension/en/graphitecomics/GraphiteComics.kt
new file mode 100644
index 000000000..6b789bdcb
--- /dev/null
+++ b/src/en/graphitecomics/src/eu/kanade/tachiyomi/extension/en/graphitecomics/GraphiteComics.kt
@@ -0,0 +1,270 @@
+package eu.kanade.tachiyomi.extension.en.graphitecomics
+
+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 kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.lang.UnsupportedOperationException
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+class GraphiteComics : HttpSource() {
+
+ override val name = "Graphite Comics"
+
+ override val baseUrl = "http://graphitecomics.com"
+
+ override val lang = "en"
+
+ override val supportsLatest = false
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS))
+ .build()
+
+ private val json: Json by injectLazy()
+
+ override fun headersBuilder(): Headers.Builder = super.headersBuilder()
+ .add("Accept", ACCEPT_ALL)
+ .add("Origin", baseUrl)
+ .add("Referer", "$baseUrl/")
+
+ private fun genericComicBookFromObject(comic: GraphiteComic): SManga =
+ SManga.create().apply {
+ title = comic.name
+ url = "/title/${comic.publisherSlug}/${comic.slug}"
+ thumbnail_url = comic.logo?.url
+ }
+
+ override fun popularMangaRequest(page: Int): Request {
+ val query = buildQuery {
+ """
+ query (%limit: Int) {
+ topTitles(limit: %limit) {
+ name
+ slug
+ publisher_slug
+ logo { url }
+ }
+ }
+ """.trimIndent()
+ }
+
+ val payload = buildJsonObject {
+ put("query", query)
+ putJsonObject("variables") {
+ put("limit", POPULAR_LIMIT)
+ }
+ }
+
+ val body = payload.toString().toRequestBody(JSON_MEDIA_TYPE)
+
+ val newHeaders = headersBuilder()
+ .set("Accept", ACCEPT_JSON)
+ .add("Content-Length", body.contentLength().toString())
+ .add("Content-Type", body.contentType().toString())
+ .build()
+
+ return POST(GRAPHQL_URL, newHeaders, body)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val result = json.parseToJsonElement(response.body!!.string()).jsonObject
+
+ val comicList = result["data"]!!.jsonObject["topTitles"]!!
+ .let { json.decodeFromJsonElement>(it) }
+ .map(::genericComicBookFromObject)
+
+ return MangasPage(comicList, hasNextPage = false)
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException("Not used")
+
+ override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException("Not used")
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val searchUrl = "$baseUrl/api/title/search".toHttpUrl().newBuilder()
+ .addPathSegment(query)
+ .addQueryParameter("limit", POPULAR_LIMIT.toString())
+ .toString()
+
+ val refererUrl = "$baseUrl/s".toHttpUrl().newBuilder()
+ .addPathSegment(query)
+ .toString()
+
+ val newHeaders = headersBuilder()
+ .set("Accept", ACCEPT_JSON)
+ .set("Referer", refererUrl)
+ .build()
+
+ return GET(searchUrl, newHeaders)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val comicList = json.decodeFromString>(response.body!!.string())
+ .map(::genericComicBookFromObject)
+
+ return MangasPage(comicList, hasNextPage = false)
+ }
+
+ // Workaround to allow "Open in browser" use the real URL.
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return client.newCall(mangaDetailsApiRequest(manga))
+ .asObservableSuccess()
+ .map { response ->
+ mangaDetailsParse(response).apply { initialized = true }
+ }
+ }
+
+ private fun mangaDetailsApiRequest(manga: SManga): Request {
+ val newHeaders = headersBuilder()
+ .set("Accept", ACCEPT_JSON)
+ .set("Referer", baseUrl + manga.url)
+ .build()
+
+ val publisherSlug = manga.url
+ .substringAfter("/title/")
+ .substringBefore("/")
+
+ val comicSlug = manga.url.substringAfterLast("/")
+
+ val apiUrl = "$baseUrl/api/title/find/null/".toHttpUrl().newBuilder()
+ .addQueryParameter("publisher_slug", publisherSlug)
+ .addQueryParameter("slug", comicSlug)
+ .toString()
+
+ return GET(apiUrl, newHeaders)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
+ val comic = json.decodeFromString(response.body!!.string())
+
+ title = comic.name
+ author = comic.creator.joinToString(", ") { it.name }
+ description = comic.description
+ genre = comic.genres
+ .sortedBy { it.name }
+ .joinToString(", ") { it.name }
+ thumbnail_url = comic.logo?.url
+ }
+
+ override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga)
+
+ private fun issueListRequest(comicId: String, comicUrl: String): Request {
+ val newHeaders = headersBuilder()
+ .set("Accept", ACCEPT_JSON)
+ .set("Referer", baseUrl + comicUrl)
+ .build()
+
+ return GET("$baseUrl/api/title/issues/$comicId", newHeaders)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ // Need to get the comic id first to fetch the issues.
+ val comic = json.decodeFromString(response.body!!.string())
+ val comicUrl = "/title/${comic.publisherSlug}/${comic.slug}"
+
+ val issueRequest = issueListRequest(comic.id, comicUrl)
+ val issueResponse = client.newCall(issueRequest).execute()
+ val issues = json.decodeFromString>(issueResponse.body!!.string())
+
+ return issues
+ .sortedBy { issue -> issue.volumeNumber * 10 + issue.number }
+ .filter { issue -> issue.accessRule.isNullOrBlank() }
+ .map { issue -> chapterFromObject(issue, comic) }
+ .reversed()
+ }
+
+ private fun chapterFromObject(issue: GraphiteIssue, comic: GraphiteComic): SChapter =
+ SChapter.create().apply {
+ name = "${issue.number} - ${issue.name}"
+ scanlator = comic.publisher?.name
+ date_upload = issue.createdAt.toDate()
+ url = "/issue/${comic.publisherSlug}/${comic.slug}/${issue.slug}"
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val newHeaders = headersBuilder()
+ .set("Accept", ACCEPT_JSON)
+ .set("Referer", baseUrl + chapter.url)
+ .build()
+
+ val urlPaths = chapter.url
+ .removePrefix("/issue/")
+ .split("/")
+
+ val apiUrl = "$baseUrl/api/issue/find/null/".toHttpUrl().newBuilder()
+ .addQueryParameter("publisher_slug", urlPaths[0])
+ .addQueryParameter("title_slug", urlPaths[1])
+ .addQueryParameter("slug", urlPaths[2])
+ .toString()
+
+ return GET(apiUrl, newHeaders)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val issue = json.decodeFromString(response.body!!.string())
+ val issueUrl = "$baseUrl/issue/${issue.publisherSlug}/${issue.titleSlug}/${issue.slug}"
+
+ return issue.pages
+ .mapIndexed { i, page ->
+ Page(i, "$issueUrl/${i + 1}", "$baseUrl/api/page/image/${page.id}")
+ }
+ }
+
+ override fun imageUrlParse(response: Response): String = ""
+
+ override fun imageRequest(page: Page): Request {
+ val newHeaders = headersBuilder()
+ .add("Accept", ACCEPT_IMAGE)
+ .add("Host", baseUrl.toHttpUrl().host)
+ .set("Referer", page.url)
+ .build()
+
+ return GET(page.imageUrl!!, newHeaders)
+ }
+
+ private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$")
+
+ private fun String.toDate(): Long {
+ return runCatching { DATE_FORMATTER.parse(substringBefore("T"))?.time }
+ .getOrNull() ?: 0L
+ }
+
+ companion object {
+ private const val ACCEPT_ALL = "*/*"
+ private const val ACCEPT_JSON = "application/json, text/plain, */*"
+ private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
+
+ private const val GRAPHQL_URL = "https://graphitecomics.com/graphql"
+
+ private const val POPULAR_LIMIT = 50
+
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
+
+ private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
+ }
+}
diff --git a/src/en/graphitecomics/src/eu/kanade/tachiyomi/extension/en/graphitecomics/GraphiteComicsDto.kt b/src/en/graphitecomics/src/eu/kanade/tachiyomi/extension/en/graphitecomics/GraphiteComicsDto.kt
new file mode 100644
index 000000000..4a0cd147d
--- /dev/null
+++ b/src/en/graphitecomics/src/eu/kanade/tachiyomi/extension/en/graphitecomics/GraphiteComicsDto.kt
@@ -0,0 +1,56 @@
+package eu.kanade.tachiyomi.extension.en.graphitecomics
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class GraphiteComic(
+ val creator: List = emptyList(),
+ val description: String = "",
+ val genres: List = emptyList(),
+ @SerialName("objectId") val id: String = "",
+ val logo: GraphiteComicImage? = null,
+ val name: String = "",
+ val publisher: GraphitePublisher? = null,
+ @SerialName("publisher_slug") val publisherSlug: String = "",
+ val slug: String = ""
+)
+
+@Serializable
+data class GraphiteComicImage(
+ val url: String = ""
+)
+
+@Serializable
+data class GraphitePerson(
+ val name: String = ""
+)
+
+@Serializable
+data class GraphiteGenre(
+ @SerialName("genreName") val name: String = ""
+)
+
+@Serializable
+data class GraphitePublisher(
+ val name: String = ""
+)
+
+@Serializable
+data class GraphiteIssue(
+ val accessRule: String? = "",
+ val createdAt: String = "",
+ val name: String = "",
+ val number: Int = -1,
+ val pages: List = emptyList(),
+ @SerialName("publisher_slug") val publisherSlug: String = "",
+ val slug: String = "",
+ @SerialName("title_slug") val titleSlug: String = "",
+ @SerialName("volume_number") val volumeNumber: Int = -1
+)
+
+@Serializable
+data class GraphitePage(
+ @SerialName("objectId") val id: String = "",
+ val isEncrypted: Boolean = false
+)