diff --git a/src/en/flixscans/AndroidManifest.xml b/src/en/flixscans/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/en/flixscans/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/en/flixscans/build.gradle b/src/en/flixscans/build.gradle
new file mode 100644
index 000000000..77b14d881
--- /dev/null
+++ b/src/en/flixscans/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'Flix Scans'
+ pkgNameSuffix = 'en.flixscans'
+ extClass = '.FlixScans'
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/flixscans/res/mipmap-hdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..96992a185
Binary files /dev/null and b/src/en/flixscans/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/flixscans/res/mipmap-mdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..bf4daa417
Binary files /dev/null and b/src/en/flixscans/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/flixscans/res/mipmap-xhdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..8beccb9bc
Binary files /dev/null and b/src/en/flixscans/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/flixscans/res/mipmap-xxhdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..d09cf7a56
Binary files /dev/null and b/src/en/flixscans/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/flixscans/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/flixscans/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..9067ecd9c
Binary files /dev/null and b/src/en/flixscans/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/flixscans/res/web_hi_res_512.png b/src/en/flixscans/res/web_hi_res_512.png
new file mode 100644
index 000000000..8ac2f0947
Binary files /dev/null and b/src/en/flixscans/res/web_hi_res_512.png differ
diff --git a/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScans.kt b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScans.kt
new file mode 100644
index 000000000..f23c88dc1
--- /dev/null
+++ b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScans.kt
@@ -0,0 +1,317 @@
+package eu.kanade.tachiyomi.extension.en.flixscans
+
+import android.util.Log
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
+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.HttpSource
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+class FlixScans : HttpSource() {
+
+ override val name = "Flix Scans"
+
+ override val lang = "en"
+
+ override val baseUrl = "https://flixscans.net"
+
+ private val apiUrl = "https://api.flixscans.net/api/v1"
+
+ override val supportsLatest = true
+
+ private val json: Json by injectLazy()
+
+ override val client = network.cloudflareClient.newBuilder()
+ .rateLimit(2)
+ .build()
+
+ // only returns 15 chapters each request, so using higher rate limit
+ private val chapterClient = network.cloudflareClient.newBuilder()
+ .rateLimitHost(apiUrl.toHttpUrl(), 1, 2)
+ .build()
+
+ override fun headersBuilder() = super.headersBuilder()
+ .add("Referer", baseUrl)
+
+ override fun fetchPopularManga(page: Int): Observable {
+ runCatching { fetchGenre() }
+
+ return super.fetchPopularManga(page)
+ }
+
+ override fun popularMangaRequest(page: Int): Request {
+ return GET("$apiUrl/webtoon/homepage/home", headers)
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ val result = response.parseAs()
+
+ val entries = (result.hot + result.topAll + result.topMonth + result.topWeek)
+ .distinctBy { it.id }
+ .map(BrowseSeries::toSManga)
+
+ return MangasPage(entries, false)
+ }
+
+ override fun fetchLatestUpdates(page: Int): Observable {
+ runCatching { fetchGenre() }
+
+ return super.fetchLatestUpdates(page)
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ return GET("$apiUrl/search/advance?page=$page&serie_type=webtoon", headers)
+ }
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val result = response.parseAs>()
+ val currentPage = response.request.url.queryParameter("page")
+ ?.toIntOrNull() ?: 1
+
+ val entries = result.data.map(BrowseSeries::toSManga)
+ val hasNextPage = result.meta.lastPage > currentPage
+
+ return MangasPage(entries, hasNextPage)
+ }
+
+ private var fetchGenreList: List = emptyList()
+ private var fetchGenreCallOngoing = false
+ private var fetchGenreFailed = false
+ private var fetchGenreAttempt = 0
+
+ private fun fetchGenre() {
+ if (fetchGenreAttempt < 3 && (fetchGenreList.isEmpty() || fetchGenreFailed) && !fetchGenreCallOngoing) {
+ fetchGenreCallOngoing = true
+
+ // fetch genre asynchronously as it sometimes hangs
+ client.newCall(fetchGenreRequest()).enqueue(fetchGenreCallback)
+ }
+ }
+
+ private val fetchGenreCallback = object : Callback {
+ override fun onFailure(call: Call, e: okio.IOException) {
+ fetchGenreAttempt++
+ fetchGenreFailed = true
+ fetchGenreCallOngoing = false
+
+ e.message?.let { Log.e("$name Filters", it) }
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ fetchGenreCallOngoing = false
+ fetchGenreAttempt++
+
+ if (!response.isSuccessful) {
+ fetchGenreFailed = true
+ response.close()
+
+ return
+ }
+
+ val parsed = runCatching {
+ response.use(::fetchGenreParse)
+ }
+
+ fetchGenreFailed = parsed.isFailure
+ fetchGenreList = parsed.getOrElse {
+ Log.e("$name Filters", it.stackTraceToString())
+ emptyList()
+ }
+ }
+ }
+
+ private fun fetchGenreRequest(): Request {
+ return GET("$apiUrl/search/genres", headers)
+ }
+
+ private fun fetchGenreParse(response: Response): List {
+ return response.parseAs>()
+ }
+
+ override fun getFilterList(): FilterList {
+ val filters: MutableList> = mutableListOf(
+ Filter.Header("Ignored when using Text Search"),
+ MainGenreFilter(),
+ TypeFilter(),
+ StatusFilter(),
+ )
+
+ filters += if (fetchGenreList.isNotEmpty()) {
+ listOf(
+ GenreFilter("Genre", fetchGenreList),
+ )
+ } else {
+ listOf(
+ Filter.Separator(),
+ Filter.Header("Press 'reset' to attempt to show Genres"),
+ )
+ }
+
+ return FilterList(filters)
+ }
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ runCatching { fetchGenre() }
+
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ if (query.isNotEmpty()) {
+ val requestBody = SearchInput(query.trim())
+ .let(json::encodeToString)
+ .toRequestBody(JSON_MEDIA_TYPE)
+
+ val newHeaders = headersBuilder()
+ .add("Content-Length", requestBody.contentLength().toString())
+ .add("Content-Type", requestBody.contentType().toString())
+ .build()
+
+ return POST("$apiUrl/search/serie?page=$page", newHeaders, requestBody)
+ }
+
+ val advSearchUrl = apiUrl.toHttpUrl().newBuilder().apply {
+ addPathSegments("search/advance")
+ addQueryParameter("page", page.toString())
+ addQueryParameter("serie_type", "webtoon")
+
+ filters.forEach { filter ->
+ when (filter) {
+ is GenreFilter -> {
+ filter.checked.let {
+ if (it.isNotEmpty()) {
+ addQueryParameter("genres", it.joinToString(","))
+ }
+ }
+ }
+ is MainGenreFilter -> {
+ if (filter.state > 0) {
+ addQueryParameter("main_genres", filter.selected)
+ }
+ }
+ is TypeFilter -> {
+ if (filter.state > 0) {
+ addQueryParameter("type", filter.selected)
+ }
+ }
+ is StatusFilter -> {
+ if (filter.state > 0) {
+ addQueryParameter("status", filter.selected)
+ }
+ }
+ else -> {}
+ }
+ }
+ }.build()
+
+ return GET(advSearchUrl, headers)
+ }
+
+ override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
+
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val id = manga.url.split("-")[1]
+
+ return GET("$apiUrl/webtoon/series/$id", headers)
+ }
+
+ override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val result = response.parseAs()
+
+ return result.serie.toSManga()
+ }
+
+ override fun fetchChapterList(manga: SManga): Observable> {
+ return chapterClient.newCall(chapterListRequest(manga))
+ .asObservableSuccess()
+ .map(::chapterListParse)
+ }
+
+ override fun chapterListRequest(manga: SManga): Request {
+ val id = manga.url.split("-")[1]
+
+ return paginatedChapterListRequest(id)
+ }
+
+ private fun paginatedChapterListRequest(seriesID: String, page: Int = 1): Request {
+ return GET("$apiUrl/webtoon/chapters/$seriesID-asc?page=$page", headers)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val result = response.parseAs>()
+
+ val id = response.request.url.toString()
+ .substringAfterLast("/")
+ .substringBefore("-")
+
+ val chapters = result.data.toMutableList()
+
+ var page = 1
+
+ while (page < result.meta.lastPage) {
+ page++
+
+ val newResponse = chapterClient.newCall(paginatedChapterListRequest(id, page)).execute()
+
+ if (!newResponse.isSuccessful) {
+ newResponse.close()
+ continue
+ }
+
+ val newResult = newResponse.parseAs>()
+
+ chapters.addAll(newResult.data)
+ }
+
+ return chapters.map(Chapter::toSChapter).reversed()
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val id = chapter.url
+ .substringAfterLast("/")
+ .substringBefore("-")
+
+ return GET("$apiUrl/webtoon/chapters/chapter/$id", headers)
+ }
+
+ override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url
+
+ override fun pageListParse(response: Response): List {
+ val result = response.parseAs()
+
+ return result.chapter.chapterData.webtoon.mapIndexed { i, img ->
+ Page(i, "", cdnUrl + img)
+ }
+ }
+
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not Used")
+
+ private inline fun Response.parseAs(): T =
+ use { body.string() }.let(json::decodeFromString)
+
+ companion object {
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
+ const val cdnUrl = "https://media.flixscans.net/"
+ }
+}
diff --git a/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansDto.kt b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansDto.kt
new file mode 100644
index 000000000..b706977e8
--- /dev/null
+++ b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansDto.kt
@@ -0,0 +1,145 @@
+package eu.kanade.tachiyomi.extension.en.flixscans
+
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+@Serializable
+data class ApiResponse(
+ val data: List,
+ val meta: PageInfo,
+)
+
+@Serializable
+data class PageInfo(
+ @SerialName("last_page") val lastPage: Int,
+)
+
+@Serializable
+data class HomeDto(
+ val hot: List,
+ val topWeek: List,
+ val topMonth: List,
+ val topAll: List,
+)
+
+@Serializable
+data class BrowseSeries(
+ val id: Int,
+ val title: String,
+ val slug: String,
+ val prefix: Int,
+ val thumbnail: String?,
+) {
+ fun toSManga() = SManga.create().apply {
+ title = this@BrowseSeries.title
+ url = "/series/$prefix-$id-$slug"
+ thumbnail_url = thumbnail?.let { FlixScans.cdnUrl + it }
+ }
+}
+
+@Serializable
+data class SearchInput(
+ val title: String,
+)
+
+@Serializable
+data class GenreHolder(
+ val name: String,
+ val id: Int,
+)
+
+@Serializable
+data class SeriesResponse(
+ val serie: Series,
+)
+
+@Serializable
+data class Series(
+ val id: Int,
+ val title: String,
+ val slug: String,
+ val prefix: Int,
+ val thumbnail: String?,
+ val story: String?,
+ val serieType: String?,
+ val mainGenres: String?,
+ val otherNames: List? = emptyList(),
+ val status: String?,
+ val type: String?,
+ val authors: List? = emptyList(),
+ val artists: List? = emptyList(),
+ val genres: List? = emptyList(),
+) {
+ fun toSManga() = SManga.create().apply {
+ title = this@Series.title
+ url = "/series/$prefix-$id-$slug"
+ thumbnail_url = FlixScans.cdnUrl + thumbnail
+ author = authors?.joinToString { it.name.trim() }
+ artist = artists?.joinToString { it.name.trim() }
+ genre = (otherGenres + genres?.map { it.name.trim() }.orEmpty())
+ .distinct().joinToString { it.trim() }
+ description = story
+ if (otherNames?.isNotEmpty() == true) {
+ if (description.isNullOrEmpty()) {
+ description = "Alternative Names:\n"
+ } else {
+ description += "\n\nAlternative Names:\n"
+ }
+ description += otherNames.joinToString("\n") { "• ${it.trim()}" }
+ }
+ status = when (this@Series.status?.trim()) {
+ "ongoing" -> SManga.ONGOING
+ "completed" -> SManga.COMPLETED
+ "onhold" -> SManga.ON_HIATUS
+ else -> SManga.UNKNOWN
+ }
+ }
+
+ private val otherGenres = listOfNotNull(serieType, mainGenres, type)
+ .map { word ->
+ word.trim().replaceFirstChar {
+ if (it.isLowerCase()) {
+ it.titlecase(Locale.getDefault())
+ } else {
+ it.toString()
+ }
+ }
+ }
+}
+
+@Serializable
+data class Chapter(
+ val id: Int,
+ val name: String,
+ val slug: String,
+ val createdAt: String? = null,
+) {
+ fun toSChapter() = SChapter.create().apply {
+ url = "/read/webtoon/$id-$slug"
+ name = this@Chapter.name
+ date_upload = runCatching { dateFormat.parse(createdAt!!)!!.time }.getOrDefault(0L)
+ }
+
+ companion object {
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
+ }
+}
+
+@Serializable
+data class PageListResponse(
+ val chapter: ChapterPages,
+)
+
+@Serializable
+data class ChapterPages(
+ val chapterData: ChapterPageData,
+)
+
+@Serializable
+data class ChapterPageData(
+ val webtoon: List,
+)
diff --git a/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansGenre.kt b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansGenre.kt
new file mode 100644
index 000000000..aaa91cb0b
--- /dev/null
+++ b/src/en/flixscans/src/eu/kanade/tachiyomi/extension/en/flixscans/FlixScansGenre.kt
@@ -0,0 +1,62 @@
+package eu.kanade.tachiyomi.extension.en.flixscans
+
+import eu.kanade.tachiyomi.source.model.Filter
+
+abstract class SelectFilter(
+ name: String,
+ private val options: List,
+) : Filter.Select(
+ name,
+ options.toTypedArray(),
+) {
+ val selected get() = options[state]
+}
+
+class CheckBoxFilter(
+ name: String,
+ val id: String,
+) : Filter.CheckBox(name)
+
+class GenreFilter(
+ name: String,
+ private val genres: List,
+) : Filter.Group(
+ name,
+ genres.map { CheckBoxFilter(it.name.trim(), it.id.toString()) },
+) {
+ val checked get() = state.filter { it.state }.map { it.id }
+}
+
+class MainGenreFilter : SelectFilter(
+ "Main Genre",
+ listOf(
+ "",
+ "fantasy",
+ "romance",
+ "action",
+ "drama",
+ ),
+)
+
+class TypeFilter : SelectFilter(
+ "Type",
+ listOf(
+ "",
+ "manhwa",
+ "manhua",
+ "manga",
+ "comic",
+ ),
+)
+
+class StatusFilter : SelectFilter(
+ "Status",
+ listOf(
+ "",
+ "ongoing",
+ "completed",
+ "droped",
+ "onhold",
+ "soon",
+ ),
+)