diff --git a/src/en/bilibilicomics/AndroidManifest.xml b/src/en/bilibilicomics/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/en/bilibilicomics/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/en/bilibilicomics/build.gradle b/src/en/bilibilicomics/build.gradle
new file mode 100644
index 000000000..82fa0a90d
--- /dev/null
+++ b/src/en/bilibilicomics/build.gradle
@@ -0,0 +1,17 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Bilibili Comics'
+ pkgNameSuffix = 'en.bilibilicomics'
+ extClass = '.BilibiliComics'
+ extVersionCode = 1
+ libVersion = '1.2'
+ containsNsfw = true
+}
+
+dependencies {
+ implementation project(':lib-ratelimit')
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/bilibilicomics/res/mipmap-hdpi/ic_launcher.png b/src/en/bilibilicomics/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..e5ef6094c
Binary files /dev/null and b/src/en/bilibilicomics/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/bilibilicomics/res/mipmap-mdpi/ic_launcher.png b/src/en/bilibilicomics/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..cdd2c53a7
Binary files /dev/null and b/src/en/bilibilicomics/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/bilibilicomics/res/mipmap-xhdpi/ic_launcher.png b/src/en/bilibilicomics/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..a19a967d7
Binary files /dev/null and b/src/en/bilibilicomics/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/bilibilicomics/res/mipmap-xxhdpi/ic_launcher.png b/src/en/bilibilicomics/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..86ec87fde
Binary files /dev/null and b/src/en/bilibilicomics/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/bilibilicomics/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/bilibilicomics/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..c0a5e8c93
Binary files /dev/null and b/src/en/bilibilicomics/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/bilibilicomics/res/web_hi_res_512.png b/src/en/bilibilicomics/res/web_hi_res_512.png
new file mode 100644
index 000000000..a7e5653eb
Binary files /dev/null and b/src/en/bilibilicomics/res/web_hi_res_512.png differ
diff --git a/src/en/bilibilicomics/src/eu/kanade/tachiyomi/extension/en/bilibilicomics/BilibiliComics.kt b/src/en/bilibilicomics/src/eu/kanade/tachiyomi/extension/en/bilibilicomics/BilibiliComics.kt
new file mode 100644
index 000000000..039681c96
--- /dev/null
+++ b/src/en/bilibilicomics/src/eu/kanade/tachiyomi/extension/en/bilibilicomics/BilibiliComics.kt
@@ -0,0 +1,305 @@
+package eu.kanade.tachiyomi.extension.en.bilibilicomics
+
+import com.github.salomonbrys.kotson.array
+import com.github.salomonbrys.kotson.bool
+import com.github.salomonbrys.kotson.float
+import com.github.salomonbrys.kotson.get
+import com.github.salomonbrys.kotson.int
+import com.github.salomonbrys.kotson.jsonArray
+import com.github.salomonbrys.kotson.jsonObject
+import com.github.salomonbrys.kotson.obj
+import com.github.salomonbrys.kotson.string
+import com.google.gson.JsonElement
+import com.google.gson.JsonParser
+import eu.kanade.tachiyomi.annotations.Nsfw
+import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
+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 okhttp3.Headers
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import rx.Observable
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+@Nsfw
+class BilibiliComics : HttpSource() {
+
+ override val name = "BILIBILI COMICS"
+
+ override val baseUrl = "https://www.bilibilicomics.com"
+
+ override val lang = "en"
+
+ override val supportsLatest = false
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .addInterceptor(RateLimitInterceptor(1, 1, TimeUnit.SECONDS))
+ .build()
+
+ private val comicList: MutableList = mutableListOf()
+
+ override fun headersBuilder(): Headers.Builder = Headers.Builder()
+ .add("Accept", ACCEPT_JSON)
+ .add("Origin", baseUrl)
+ .add("Referer", "$baseUrl/")
+
+ override fun popularMangaRequest(page: Int): Request {
+ val requestPayload = jsonObject(
+ "id" to FEATURED_ID,
+ "isAll" to 0,
+ "page_num" to 1,
+ "page_size" to 6
+ )
+ val requestBody = requestPayload.toString().toRequestBody(JSON_CONTENT_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .build()
+
+ return POST(
+ "$baseUrl/$BASE_API_ENDPOINT/GetClassPageSixComics?device=pc&platform=web",
+ headers = newHeaders,
+ body = requestBody
+ )
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val jsonResponse = response.asJson().obj
+
+ if (jsonResponse["code"].int != 0) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ val comicList = jsonResponse["data"]["roll_six_comics"].array
+ .map(::popularMangaFromObject)
+
+ return MangasPage(comicList, hasNextPage = false)
+ }
+
+ private fun popularMangaFromObject(obj: JsonElement): SManga = SManga.create().apply {
+ title = obj["title"].string
+ thumbnail_url = obj["vertical_cover"].string
+ url = "/detail/mc" + obj["comic_id"].int
+ }
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return if (comicList.isEmpty()) {
+ super.fetchSearchManga(page, query, filters)
+ .map { result ->
+ val filteredComics = result.mangas.filter { it.title.contains(query, true) }
+ MangasPage(filteredComics, result.hasNextPage)
+ }
+ } else {
+ val filteredComics = comicList.filter { it.title.contains(query, true) }
+ Observable.just(MangasPage(filteredComics, hasNextPage = false))
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val jsonPayload = jsonObject(
+ "area_id" to -1,
+ "is_finish" to -1,
+ "is_free" to 1,
+ "order" to 0,
+ "page_num" to page,
+ "page_size" to 18,
+ "style_id" to -1
+ )
+ val requestBody = jsonPayload.toString().toRequestBody(JSON_CONTENT_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .add("X-Page", page.toString())
+ .set("Referer", "$baseUrl/genre")
+ .build()
+
+ return POST(
+ "$baseUrl/$BASE_API_ENDPOINT/ClassPage?device=pc&platform=web",
+ headers = newHeaders,
+ body = requestBody
+ )
+ }
+
+ // Site does not have search in the API, so we need to fetch all the pages
+ // and then filter to find the query provided by the user.
+ override fun searchMangaParse(response: Response): MangasPage {
+ var request = response.request
+ var currentPage = request.headers["X-Page"]!!.toInt()
+ var jsonResponse = response.asJson().obj
+
+ if (jsonResponse["code"].int != 0) {
+ return MangasPage(emptyList(), hasNextPage = false)
+ }
+
+ while (jsonResponse["data"].array.size() > 0) {
+ comicList += jsonResponse["data"].array
+ .map(::searchMangaFromObject)
+
+ request = searchMangaRequest(++currentPage, "", FilterList())
+ jsonResponse = client.newCall(request).execute().asJson().obj
+ }
+
+ return MangasPage(comicList, hasNextPage = false)
+ }
+
+ private fun searchMangaFromObject(obj: JsonElement): SManga = SManga.create().apply {
+ title = obj["title"].string
+ thumbnail_url = obj["vertical_cover"].string
+ url = "/detail/mc" + obj["season_id"].int
+ }
+
+ // 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 comicId = manga.url.substringAfterLast("/mc").toInt()
+
+ val jsonPayload = jsonObject("comic_id" to comicId)
+ val requestBody = jsonPayload.toString().toRequestBody(JSON_CONTENT_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .set("Referer", baseUrl + manga.url)
+ .build()
+
+ return POST(
+ "$baseUrl/$BASE_API_ENDPOINT/ComicDetail?device=pc&platform=web",
+ headers = newHeaders,
+ body = requestBody
+ )
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
+ val jsonResponse = response.asJson().obj
+
+ title = jsonResponse["data"]["title"].string
+ author = jsonResponse["data"]["author_name"].array.joinToString { it.string }
+ status = if (jsonResponse["data"]["is_finish"].int == 1) SManga.COMPLETED else SManga.ONGOING
+ genre = jsonResponse["data"]["styles"].array.joinToString { it.string }
+ description = jsonResponse["data"]["classic_lines"].string
+ thumbnail_url = jsonResponse["data"]["vertical_cover"].string
+ }
+
+ // Chapters are available in the same url of the manga details.
+ override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga)
+
+ override fun chapterListParse(response: Response): List {
+ val jsonResponse = response.asJson().obj
+
+ if (jsonResponse["code"].int != 0)
+ return emptyList()
+
+ return jsonResponse["data"]["ep_list"].array
+ .filter { ep -> ep["is_locked"].bool.not() }
+ .map { ep -> chapterFromObject(ep, jsonResponse["data"]["id"].int) }
+ }
+
+ private fun chapterFromObject(obj: JsonElement, comicId: Int): SChapter = SChapter.create().apply {
+ name = "Ep. " + obj["ord"].float.toString().removeSuffix(".0") +
+ " - " + obj["title"].string
+ chapter_number = obj["ord"].float
+ scanlator = this@BilibiliComics.name
+ date_upload = obj["pub_time"].string.substringBefore("T").toDate()
+ url = "/mc" + comicId + "/" + obj["id"].int
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val comicId = chapter.url.substringAfterLast("/").toInt()
+
+ val jsonPayload = jsonObject("ep_id" to comicId)
+ val requestBody = jsonPayload.toString().toRequestBody(JSON_CONTENT_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .set("Referer", baseUrl + chapter.url)
+ .build()
+
+ return POST(
+ "$baseUrl/$BASE_API_ENDPOINT/GetImageIndex?device=pc&platform=web",
+ headers = newHeaders,
+ body = requestBody
+ )
+ }
+
+ override fun pageListParse(response: Response): List {
+ val jsonResponse = response.asJson().obj
+
+ if (jsonResponse["code"].int != 0) {
+ return emptyList()
+ }
+
+ return jsonResponse["data"]["images"].array
+ .mapIndexed { i, page -> Page(i, page["path"].string, "") }
+ }
+
+ override fun imageUrlRequest(page: Page): Request {
+ val jsonPayload = jsonObject("urls" to jsonArray(page.url).toString())
+ val requestBody = jsonPayload.toString().toRequestBody(JSON_CONTENT_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .build()
+
+ return POST(
+ "$baseUrl/$BASE_API_ENDPOINT/ImageToken?device=pc&platform=web",
+ headers = newHeaders,
+ body = requestBody
+ )
+ }
+
+ override fun imageUrlParse(response: Response): String {
+ val jsonResponse = response.asJson().obj
+
+ return jsonResponse["data"][0]["url"].string
+ .plus("?token=" + jsonResponse["data"][0]["token"].string)
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException("Not used")
+
+ override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException("Not used")
+
+ private fun String.toDate(): Long {
+ return try {
+ DATE_FORMATTER.parse(this)?.time ?: 0L
+ } catch (e: ParseException) {
+ 0L
+ }
+ }
+
+ private fun Response.asJson(): JsonElement = JsonParser.parseString(body!!.string())
+
+ companion object {
+ private const val BASE_API_ENDPOINT = "twirp/comic.v1.Comic"
+
+ private const val ACCEPT_JSON = "application/json, text/plain, */*"
+
+ private val JSON_CONTENT_TYPE = "application/json;charset=UTF-8".toMediaType()
+
+ private const val FEATURED_ID = 3
+
+ private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
+ }
+}