diff --git a/src/en/catmanga/AndroidManifest.xml b/src/en/catmanga/AndroidManifest.xml
new file mode 100644
index 000000000..308dd35c2
--- /dev/null
+++ b/src/en/catmanga/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/catmanga/build.gradle b/src/en/catmanga/build.gradle
new file mode 100644
index 000000000..8d75789f5
--- /dev/null
+++ b/src/en/catmanga/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'CatManga'
+ pkgNameSuffix = "en.catmanga"
+ extClass = '.CatManga'
+ extVersionCode = 1
+ libVersion = '1.2'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/catmanga/res/mipmap-hdpi/ic_launcher.png b/src/en/catmanga/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..4db917a88
Binary files /dev/null and b/src/en/catmanga/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/catmanga/res/mipmap-mdpi/ic_launcher.png b/src/en/catmanga/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..8637c4583
Binary files /dev/null and b/src/en/catmanga/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/catmanga/res/mipmap-xhdpi/ic_launcher.png b/src/en/catmanga/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..423723cf7
Binary files /dev/null and b/src/en/catmanga/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/catmanga/res/mipmap-xxhdpi/ic_launcher.png b/src/en/catmanga/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..f5cf760a0
Binary files /dev/null and b/src/en/catmanga/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/catmanga/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/catmanga/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..1e7256b0d
Binary files /dev/null and b/src/en/catmanga/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/catmanga/res/web_hi_res_512.png b/src/en/catmanga/res/web_hi_res_512.png
new file mode 100644
index 000000000..6e46ab8b8
Binary files /dev/null and b/src/en/catmanga/res/web_hi_res_512.png differ
diff --git a/src/en/catmanga/src/eu/kanade/tachiyomi/extension/en/catmanga/CatManga.kt b/src/en/catmanga/src/eu/kanade/tachiyomi/extension/en/catmanga/CatManga.kt
new file mode 100644
index 000000000..63470a8c0
--- /dev/null
+++ b/src/en/catmanga/src/eu/kanade/tachiyomi/extension/en/catmanga/CatManga.kt
@@ -0,0 +1,201 @@
+package eu.kanade.tachiyomi.extension.en.catmanga
+
+import eu.kanade.tachiyomi.network.GET
+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 okhttp3.Response
+import org.json.JSONArray
+import org.json.JSONObject
+import org.jsoup.nodes.Document
+import rx.Observable
+
+class CatManga : HttpSource() {
+
+ override val name = "CatManga"
+ override val baseUrl = "https://catmanga.org"
+ override val supportsLatest = true
+ override val lang = "en"
+
+ override fun popularMangaRequest(page: Int) = GET(baseUrl)
+
+ override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page)
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = popularMangaRequest(page)
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return client.newCall(popularMangaRequest(page))
+ .asObservableSuccess()
+ .map { response ->
+ val mangas = if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
+ getFilteredSeriesList(
+ response.asJsoup().getDataJsonObject(),
+ idFilter = query.removePrefix(SERIES_ID_SEARCH_PREFIX)
+ )
+ } else {
+ getFilteredSeriesList(
+ response.asJsoup().getDataJsonObject(),
+ titleFilter = query
+ )
+ }
+ MangasPage(mangas, false)
+ }
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val mangas = getFilteredSeriesList(response.asJsoup().getDataJsonObject())
+ return MangasPage(mangas, false)
+ }
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val latests = response.asJsoup().getDataJsonObject()
+ .getJSONObject("props")
+ .getJSONObject("pageProps")
+ .getJSONArray("latests")
+ val mangas = (0 until latests.length()).map { i ->
+ val manga = latests.getJSONArray(i).getJSONObject(0)
+ SManga.create().apply {
+ url = "/series/${manga.getString("series_id")}"
+ title = manga.getString("title")
+ thumbnail_url = manga.getJSONObject("cover_art").getString("source")
+ }
+ }
+ return MangasPage(mangas, false)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ return SManga.create().apply {
+ val series = response.asJsoup().getDataJsonObject()
+ .getJSONObject("props")
+ .getJSONObject("pageProps")
+ .getJSONObject("series")
+ title = series.getString("title")
+ author = series.getJSONArray("authors").joinToString(", ")
+ description = series.getString("description")
+ genre = series.getJSONArray("genres").joinToString(", ")
+ status = when (series.getString("status")) {
+ "ongoing" -> SManga.ONGOING
+ "completed" -> SManga.COMPLETED
+ else -> SManga.UNKNOWN
+ }
+ thumbnail_url = series.getJSONObject("cover_art").getString("source")
+ }
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val jsonObject = response.asJsoup().getDataJsonObject()
+
+ val querySeries = jsonObject.getJSONObject("query").getString("series")
+ val seriesUrl = jsonObject.getString("page").replace("[series]", querySeries)
+
+ val series = jsonObject.getJSONObject("props").getJSONObject("pageProps").getJSONObject("series")
+ val chapters = series.getJSONArray("chapters")
+ return (0 until chapters.length()).map { i ->
+ val chapter = chapters.getJSONObject(i)
+ val title = chapter.optString("title")
+ val groups = chapter.getJSONArray("groups").joinToString()
+ val number = chapter.getString("number")
+ val displayNumber = chapter.optString("display_number", number)
+ SChapter.create().apply {
+ url = "$seriesUrl/$number"
+ chapter_number = number.toFloat()
+ name = "Chapter $displayNumber" + if (title.isNotBlank()) " - $title" else ""
+ scanlator = groups
+ date_upload = System.currentTimeMillis()
+ }
+ }.reversed()
+ }
+
+ override fun pageListParse(response: Response): List {
+ val pages = response.asJsoup().getDataJsonObject()
+ .getJSONObject("props")
+ .getJSONObject("pageProps")
+ .getJSONArray("pages")
+ return (0 until pages.length()).map { i -> Page(i, "", pages.getString(i)) }
+ }
+
+ /**
+ * Returns json object of site data
+ */
+ private fun Document.getDataJsonObject(): JSONObject {
+ return JSONObject(getElementById("__NEXT_DATA__").html())
+ }
+
+ /**
+ * @return filtered series from home page
+ * @param data json data from [getDataJsonObject]
+ * @param titleFilter will be used to check against title and alt_titles, null to disable filter
+ * @param idFilter will be used to check against id, null to disable filter, only used when [titleFilter] is unset
+ */
+ private fun getFilteredSeriesList(
+ data: JSONObject,
+ titleFilter: String? = null,
+ idFilter: String? = null
+ ): List {
+ val series = data.getJSONObject("props").getJSONObject("pageProps").getJSONArray("series")
+ val mangas = mutableListOf()
+ for (i in 0 until series.length()) {
+ val manga = series.getJSONObject(i)
+ val mangaId = manga.getString("series_id")
+ val mangaTitle = manga.getString("title")
+ val mangaAltTitles = manga.getJSONArray("alt_titles")
+
+ // Filtering
+ if (titleFilter != null) {
+ if (!(mangaTitle.contains(titleFilter, true) || mangaAltTitles.contains(titleFilter))) {
+ continue
+ }
+ } else if (idFilter != null) {
+ if (!mangaId.contains(idFilter, true)) {
+ continue
+ }
+ }
+
+ mangas += SManga.create().apply {
+ url = "/series/$mangaId"
+ title = mangaTitle
+ thumbnail_url = manga.getJSONObject("cover_art").getString("source")
+ }
+ }
+ return mangas.toList()
+ }
+
+ private fun JSONArray.joinToString(separator: String = ", "): String {
+ val stringBuilder = StringBuilder()
+ for (i in 0 until length()) {
+ if (i > 0) stringBuilder.append(separator)
+ val item = getString(i)
+ stringBuilder.append(item)
+ }
+ return stringBuilder.toString()
+ }
+
+ /**
+ * For string objects
+ */
+ private operator fun JSONArray.contains(other: CharSequence): Boolean {
+ for (i in 0 until length()) {
+ if (optString(i, "").contains(other, true)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ throw UnsupportedOperationException("Not used.")
+ }
+
+ override fun imageUrlParse(response: Response): String {
+ throw UnsupportedOperationException("Not used.")
+ }
+
+ companion object {
+ const val SERIES_ID_SEARCH_PREFIX = "series_id:"
+ }
+}
diff --git a/src/en/catmanga/src/eu/kanade/tachiyomi/extension/en/catmanga/CatMangaUrlActivity.kt b/src/en/catmanga/src/eu/kanade/tachiyomi/extension/en/catmanga/CatMangaUrlActivity.kt
new file mode 100644
index 000000000..fc2a09fe1
--- /dev/null
+++ b/src/en/catmanga/src/eu/kanade/tachiyomi/extension/en/catmanga/CatMangaUrlActivity.kt
@@ -0,0 +1,38 @@
+package eu.kanade.tachiyomi.extension.en.catmanga
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+/**
+ * Springboard that accepts https://catmanga.org/series/xxxxxx intents and redirects them to
+ * the main Tachiyomi process.
+ */
+class CatMangaUrlActivity : 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", "${CatManga.SERIES_ID_SEARCH_PREFIX}$id")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("CatMangaUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("CatMangaUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}