diff --git a/src/zh/tencentcomics/AndroidManifest.xml b/src/zh/tencentcomics/AndroidManifest.xml
new file mode 100644
index 000000000..5a719084a
--- /dev/null
+++ b/src/zh/tencentcomics/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/zh/tencentcomics/build.gradle b/src/zh/tencentcomics/build.gradle
new file mode 100644
index 000000000..f2f969ee0
--- /dev/null
+++ b/src/zh/tencentcomics/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Tencent Comics (ac.qq.com)'
+ pkgNameSuffix = 'zh.tencentcomics'
+ extClass = '.TencentComics'
+ extVersionCode = 1
+ libVersion = '1.2'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..fe3830e4f
Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..e0f65f368
Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..7900cd4b4
Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..c6c8a33cf
Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..78e56468b
Binary files /dev/null and b/src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/zh/tencentcomics/res/web_hi_res_512.png b/src/zh/tencentcomics/res/web_hi_res_512.png
new file mode 100644
index 000000000..0f7e71234
Binary files /dev/null and b/src/zh/tencentcomics/res/web_hi_res_512.png differ
diff --git a/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComics.kt b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComics.kt
new file mode 100644
index 000000000..8b1b593f9
--- /dev/null
+++ b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComics.kt
@@ -0,0 +1,298 @@
+package eu.kanade.tachiyomi.extension.zh.tencentcomics
+
+import android.util.Base64
+import com.squareup.duktape.Duktape
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.model.Filter
+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.ParsedHttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.json.JSONObject
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import rx.Observable
+import kotlin.collections.ArrayList
+
+class TencentComics : ParsedHttpSource() {
+
+ override val name = "Tencent Comics (ac.qq.com)"
+ // its easier to parse the mobile version of the website
+ override val baseUrl = "https://m.ac.qq.com"
+
+ private val desktopUrl = "https://ac.qq.com"
+
+ override val lang = "zh"
+
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ override fun chapterListSelector(): String = "ul.chapter-wrap-list.reverse > li > a"
+
+ override fun chapterFromElement(element: Element): SChapter {
+ return SChapter.create().apply {
+ url = element.attr("href").trim()
+ name = element.text().trim()
+ chapter_number = element.attr("data-seq").toFloat()
+ }
+ }
+
+ override fun popularMangaSelector(): String = "ul.ret-search-list.clearfix > li"
+
+ override fun popularMangaFromElement(element: Element): SManga {
+ return SManga.create().apply {
+ url = "/comic/index/" + element.select("div > a").attr("href").substringAfter("/Comic/comicInfo/")
+ title = element.select("div > a").attr("title").trim()
+ thumbnail_url = element.select("div > a > img").attr("data-original")
+ author = element.select("div > p.ret-works-author").text().trim()
+ description = element.select("div > p.ret-works-decs").text().trim()
+ }
+ }
+
+ override fun popularMangaNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.")
+
+ override fun popularMangaRequest(page: Int): Request = GET("$desktopUrl/Comic/all/search/hot/page/$page)", headers)
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ val mangas = document.select(popularMangaSelector()).map { element ->
+ popularMangaFromElement(element)
+ }
+ // next page buttons do not exist
+ // even if the total searches happen to be 12 the website fills the next page anyway
+ return MangasPage(mangas, mangas.size == 12)
+ }
+
+ override fun latestUpdatesSelector(): String = "ul.ret-search-list.clearfix > li"
+
+ override fun latestUpdatesFromElement(element: Element): SManga {
+ return popularMangaFromElement(element)
+ }
+
+ override fun latestUpdatesNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.")
+
+ override fun latestUpdatesRequest(page: Int): Request = GET("$desktopUrl/Comic/all/search/time/page/$page)", headers)
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ return popularMangaParse(response)
+ }
+
+ // desktop version of the site has more info
+ override fun mangaDetailsRequest(manga: SManga): Request = GET("$desktopUrl/Comic/comicInfo/" + manga.url.substringAfter("/index/"), headers)
+
+ override fun mangaDetailsParse(document: Document): SManga {
+ return SManga.create().apply {
+ thumbnail_url = document.select("div.works-cover.ui-left > a > img").attr("src")
+ title = document.select("h2.works-intro-title.ui-left > strong").text().trim()
+ description = document.select("p.works-intro-short").text().trim()
+ author = document.select("p.works-intro-digi > span > em").text().trim()
+ status = when (document.select("label.works-intro-status").text().trim()) {
+ "连载中" -> SManga.ONGOING
+ "已完结" -> SManga.COMPLETED
+ "連載中" -> SManga.ONGOING
+ "已完結" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+ }
+ }
+
+ // convert url to desktop since some chapters are blocked on mobile
+ override fun pageListRequest(chapter: SChapter): Request = GET("$desktopUrl/ComicView/" + chapter.url.substringAfter("/chapter/"), headers)
+
+ private val jsDecodeFunction = """
+ raw = raw.split('');
+ nonce = nonce.match(/\d+[a-zA-Z]+/g);
+ var len = nonce.length;
+ while (len--) {
+ var offset = parseInt(nonce[len]) & 255;
+ var noise = nonce[len].replace(/\d+/g, '');
+ raw.splice(offset, noise.length);
+ }
+ raw.join('');
+ """
+
+ override fun pageListParse(document: Document): List {
+ val duktape = Duktape.create()
+ val pages = ArrayList()
+ var html = document.html()
+
+ // Sometimes the nonce has commands that are unrunnable, just reload and hope
+ var nonce = html.substringAfterLast("window[").substringAfter("] = ").substringBefore("").trim()
+
+ while (nonce.contains("document") || nonce.contains("window")) {
+ html = client.newCall(GET(desktopUrl + document.select("li.now-reading > a").attr("href"), headers)).execute().body!!.string()
+ nonce = html.substringAfterLast("window[").substringAfter("] = ").substringBefore("").trim()
+ }
+
+ val raw = html.substringAfterLast("var DATA =").substringBefore("PRELOAD_NUM").trim().replace(Regex("^\'|\',$"), "")
+ val decodePrefix = "var raw = \"$raw\"; var nonce = $nonce"
+ val full = duktape.evaluate(decodePrefix + jsDecodeFunction).toString()
+ val chapterData = JSONObject(String(Base64.decode(full, Base64.DEFAULT)))
+
+ if (!chapterData.getJSONObject("chapter").getBoolean("canRead")) throw Exception("[此章节为付费内容]")
+
+ val pictures = chapterData.getJSONArray("picture")
+ for (i in 0 until pictures.length()) {
+ pages.add(Page(i, "", pictures.getJSONObject(i).getString("url")))
+ }
+ return pages
+ }
+
+ override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.")
+
+ override fun searchMangaSelector() = "ul > li.comic-item > a"
+
+ override fun searchMangaFromElement(element: Element): SManga {
+ return SManga.create().apply {
+ url = element.attr("href")
+ title = element.select("div > strong").text().trim()
+ thumbnail_url = element.select("div > img").attr("src")
+ description = element.select("div > small.comic-desc").text().trim()
+ genre = element.select("div > small.comic-tag").text().trim().replace(" ", ", ")
+ }
+ }
+
+ override fun searchMangaNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.")
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return if (query.startsWith(ID_SEARCH_PREFIX)) {
+ val id = query.removePrefix(ID_SEARCH_PREFIX)
+ client.newCall(searchMangaByIdRequest(id))
+ .asObservableSuccess()
+ .map { response -> searchMangaByIdParse(response, id) }
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/comic/index/id/$id", headers)
+
+ private fun searchMangaByIdParse(response: Response, id: String): MangasPage {
+ val sManga = mangaDetailsParse(response)
+ sManga.url = "/comic/index/id/$id"
+ return MangasPage(listOf(sManga), false)
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ // impossible to search a manga use the filters
+ return if (query.isNotEmpty()) {
+ GET("$baseUrl/search/result?word=$query&page=$page", headers)
+ } else {
+ lateinit var genre: String
+ lateinit var status: String
+ lateinit var popularity: String
+ lateinit var vip: String
+ filters.forEach { filter ->
+ when (filter) {
+ is GenreFilter -> {
+ genre = filter.toUriPart()
+ if (genre.isNotEmpty()) genre = "theme/$genre/"
+ }
+ is StatusFilter -> {
+ status = filter.toUriPart()
+ }
+ is PopularityFilter -> {
+ popularity = filter.toUriPart()
+ }
+ is VipFilter -> {
+ vip = filter.toUriPart()
+ }
+ }
+ }
+ GET("$desktopUrl/Comic/all/$genre${status}search/$popularity${vip}page/$page")
+ }
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ // Normal search
+ return if (response.request.url.host.contains("m.ac.qq.com")) {
+ val mangas = document.select(searchMangaSelector()).map { element ->
+ searchMangaFromElement(element)
+ }
+ MangasPage(mangas, mangas.size == 10)
+ // Filter search
+ } else {
+ val mangas = document.select(popularMangaSelector()).map { element ->
+ popularMangaFromElement(element)
+ }
+ // next page buttons do not exist
+ // even if the total searches happen to be 12 the website fills the next page anyway
+ MangasPage(mangas, mangas.size == 12)
+ }
+ }
+
+ override fun getFilterList() = FilterList(
+ Filter.Header("注意:不影響按標題搜索"),
+ PopularityFilter(),
+ VipFilter(),
+ StatusFilter(),
+ GenreFilter()
+ )
+
+ private open class UriPartFilter(displayName: String, val vals: Array>) :
+ Filter.Select(displayName, vals.map { it.first }.toTypedArray()) {
+ fun toUriPart() = vals[state].second
+ }
+
+ private class PopularityFilter : UriPartFilter(
+ "热门人气/更新时间",
+ arrayOf(
+ Pair("热门人气", "hot/"),
+ Pair("更新时间", "time/")
+ )
+ )
+
+ private class VipFilter : UriPartFilter(
+ "属性",
+ arrayOf(
+ Pair("全部", ""),
+ Pair("付费", "vip/2/"),
+ Pair("免费", "vip/1/")
+ )
+ )
+
+ private class StatusFilter : UriPartFilter(
+ "进度",
+ arrayOf(
+ Pair("全部", ""),
+ Pair("连载中", "finish/1/"),
+ Pair("已完结", "finish/2/")
+ )
+ )
+
+ private class GenreFilter : UriPartFilter(
+ "标签",
+ arrayOf(
+ Pair("全部", ""),
+ Pair("恋爱", "105"),
+ Pair("玄幻", "101"),
+ Pair("异能", "103"),
+ Pair("恐怖", "110"),
+ Pair("剧情", "106"),
+ Pair("科幻", "108"),
+ Pair("悬疑", "112"),
+ Pair("奇幻", "102"),
+ Pair("冒险", "104"),
+ Pair("犯罪", "111"),
+ Pair("动作", "109"),
+ Pair("日常", "113"),
+ Pair("竞技", "114"),
+ Pair("武侠", "115"),
+ Pair("历史", "116"),
+ Pair("战争", "117")
+ )
+ )
+
+ companion object {
+ const val ID_SEARCH_PREFIX = "id:"
+ }
+}
diff --git a/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComicsUrlActivity.kt b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComicsUrlActivity.kt
new file mode 100644
index 000000000..9224fbbd8
--- /dev/null
+++ b/src/zh/tencentcomics/src/eu/kanade/tachiyomi/extension/zh/tencentcomics/TencentComicsUrlActivity.kt
@@ -0,0 +1,34 @@
+package eu.kanade.tachiyomi.extension.zh.tencentcomics
+
+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 TencentComicsUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 3) {
+ val id = pathSegments[3]
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${TencentComics.ID_SEARCH_PREFIX}$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("TencentUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("TencentUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}