diff --git a/src/all/littlegarden/AndroidManifest.xml b/src/all/littlegarden/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/littlegarden/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/all/littlegarden/build.gradle b/src/all/littlegarden/build.gradle
new file mode 100644
index 000000000..942bbfd7d
--- /dev/null
+++ b/src/all/littlegarden/build.gradle
@@ -0,0 +1,11 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Little Garden'
+ pkgNameSuffix = 'all.littlegarden'
+ extClass = '.LittleGarden'
+ extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/littlegarden/res/mipmap-hdpi/ic_launcher.png b/src/all/littlegarden/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..80e507766
Binary files /dev/null and b/src/all/littlegarden/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/littlegarden/res/mipmap-mdpi/ic_launcher.png b/src/all/littlegarden/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..a203236ee
Binary files /dev/null and b/src/all/littlegarden/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/littlegarden/res/mipmap-xhdpi/ic_launcher.png b/src/all/littlegarden/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..6c4c40083
Binary files /dev/null and b/src/all/littlegarden/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/littlegarden/res/mipmap-xxhdpi/ic_launcher.png b/src/all/littlegarden/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..1b80623e0
Binary files /dev/null and b/src/all/littlegarden/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/littlegarden/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/littlegarden/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..576601623
Binary files /dev/null and b/src/all/littlegarden/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/littlegarden/res/web_hi_res_512.png b/src/all/littlegarden/res/web_hi_res_512.png
new file mode 100644
index 000000000..2f4f9b0b4
Binary files /dev/null and b/src/all/littlegarden/res/web_hi_res_512.png differ
diff --git a/src/all/littlegarden/src/eu/kanade/tachiyomi/extension/all/littlegarden/LittleGarden.kt b/src/all/littlegarden/src/eu/kanade/tachiyomi/extension/all/littlegarden/LittleGarden.kt
new file mode 100644
index 000000000..3363ff9c7
--- /dev/null
+++ b/src/all/littlegarden/src/eu/kanade/tachiyomi/extension/all/littlegarden/LittleGarden.kt
@@ -0,0 +1,193 @@
+package eu.kanade.tachiyomi.extension.all.littlegarden
+
+import eu.kanade.tachiyomi.network.GET
+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 kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+
+class LittleGarden : ParsedHttpSource() {
+ override val name = "Little Garden"
+ override val baseUrl = "https://littlexgarden.com/"
+ override val lang = "all"
+ override val supportsLatest = true
+
+ companion object {
+ private const val cdnUrl = "https://littlexgarden.com/static/images/webp/"
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
+ private val slugRegex = Regex("\\\\\"slug\\\\\":\\\\\"(.*?(?=\\\\\"))")
+ private val oricolPageRegex = Regex("\\{colored:(?.*?(?=,)),original:(?.*?(?=,))")
+ private val oriPageRegex = Regex("""original:"(.*?(?="))""")
+ }
+
+ // Popular
+ override fun popularMangaRequest(page: Int) = GET(baseUrl)
+ override fun popularMangaSelector() = "div.listing div .col-md-6.col-lg-6.col-xl-4.col-12"
+ override fun popularMangaNextPageSelector(): String? = null
+ override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
+ title = element.select(".item-title .name").text().trim()
+ setUrlWithoutDomain(element.select("a").attr("href"))
+ thumbnail_url = element.select(".thumb").attr("style").substringAfter("(").substringBefore(")")
+ }
+
+ // Latest
+ override fun latestUpdatesRequest(page: Int) = GET(baseUrl)
+ override fun latestUpdatesSelector() = ".d-sm-block.col-sm-6.col-lg-6.col-xl-3.col-12"
+ override fun latestUpdatesNextPageSelector(): String? = null
+ override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply {
+ title = element.selectFirst("h3").text().trim()
+ setUrlWithoutDomain(element.select("a").attr("href").substringBeforeLast("/"))
+ thumbnail_url = element.select(".img.image-item").attr("style").substringAfter("(").substringBefore(")")
+ }
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ val mangas = document.select(latestUpdatesSelector()).map { element ->
+ latestUpdatesFromElement(element)
+ }.distinctBy { it.title }
+ return MangasPage(mangas, false)
+ }
+
+ // Search
+ override fun searchMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ var mangas = document.select(popularMangaSelector()).map { popularMangaFromElement(it) }
+ val query = response.request.headers["query"]
+ if (query != null) {
+ mangas = mangas.filter { it.title.contains(query, true) }
+ }
+ return MangasPage(mangas, false)
+ }
+ override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
+ override fun searchMangaNextPageSelector(): String? = null
+ override fun searchMangaSelector(): String = popularMangaSelector()
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val headers = headersBuilder()
+ .add("query", query)
+ .build()
+ return GET(baseUrl, headers)
+ }
+
+ // Manga details
+ override fun mangaDetailsParse(document: Document): SManga = SManga.create()
+
+ // Chapter list
+ override fun chapterListSelector() = throw Exception("Not used")
+ override fun chapterFromElement(element: Element): SChapter = throw Exception("Not used")
+ override fun chapterListParse(response: Response): List {
+ val document = response.asJsoup()
+ val slug = slugRegex.find(document.toString())?.groupValues?.get(1)
+ fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$")
+ val query = buildQuery {
+ """
+ query chapters(
+ %slug: String,
+ %limit: Float,
+ %skip: Float,
+ %order: Float!,
+ %isAdmin: Boolean!
+ ) {
+ chapters(
+ limit: %limit,
+ skip: %skip,
+ where: {
+ deleted: false,
+ published: %isAdmin,
+ manga: {
+ slug: %slug,
+ published: %isAdmin,
+ deleted: false
+ }
+ },
+ order: [{ field: "number", order: %order }]
+ ) {
+ published
+ likes
+ id
+ number
+ thumb
+ manga {
+ id
+ name
+ slug
+ __typename
+ }
+ __typename
+ }
+ }
+ """.trimIndent()
+ }
+ val payload = buildJsonObject {
+ put("operationName", "chapters")
+ put("query", query)
+ putJsonObject("variables") {
+ put("slug", slug)
+ put("order", -1)
+ put("limit", 2000)
+ put("skip", 0)
+ put("isAdmin", true)
+ }
+ }
+ val body = payload.toString().toRequestBody(JSON_MEDIA_TYPE)
+ val newHeaders = headersBuilder()
+ .add("Content-Length", body.contentLength().toString())
+ .add("Content-Type", body.contentType().toString())
+ .build()
+ val request = Request.Builder()
+ .method("POST", body)
+ .url("https://littlexgarden.com/graphql") // Request directly their data rather than scraping a page as chapters are dynamically loaded
+ .headers(newHeaders)
+ .build()
+ val resp = client.newCall(request).execute()
+ val chapters = Json.parseToJsonElement(resp.body?.string().toString()).jsonObject["data"]?.jsonObject?.get("chapters")?.jsonArray
+ if (chapters != null) {
+ return chapters.map {
+ SChapter.create().apply {
+ val chap = it.jsonObject["number"].toString()
+ val manga = it.jsonObject["manga"]?.jsonObject?.get("name").toString().replace("\"", "")
+ setUrlWithoutDomain("/$slug/$chap")
+ name = "$manga - $chap"
+ chapter_number = chap.toFloat()
+ date_upload = 0L
+ }
+ }
+ }
+ return mutableListOf()
+ }
+
+ // Pages
+ override fun pageListParse(document: Document): List {
+ val pages = mutableListOf()
+ val chapNb = document.selectFirst("div.chapter-number").text().trim().toInt()
+ val engChaps: IntArray = intArrayOf(970, 987, 992)
+ if (document.selectFirst("div.manga-name").text().trim() == "One Piece" && (engChaps.contains(chapNb) || chapNb > 994)) { // Permits to get French pages rather than English pages for some chapters
+ oricolPageRegex.findAll(document.select("script:containsData(pages)").toString()).asIterable().mapIndexed { i, it ->
+ if (it.groups["colored"]?.value?.contains("\"") == true) { // Their JS dict has " " around the link only when available. Also uses colored pages rather than B&W as it's the main strength of this site
+ pages.add(Page(i, "", cdnUrl + it.groups["colored"]?.value?.replace("\"", "") + ".webp"))
+ } else {
+ pages.add(Page(i, "", cdnUrl + it.groups["original"]?.value?.replace("\"", "") + ".webp"))
+ }
+ }
+ } else {
+ oriPageRegex.findAll(document.toString()).asIterable().mapIndexed { i, it ->
+ pages.add(Page(i, "", cdnUrl + it.groupValues[1] + ".webp"))
+ }
+ }
+ return pages
+ }
+ override fun imageUrlParse(document: Document): String = throw Exception("Not used")
+}