diff --git a/src/all/mango/AndroidManifest.xml b/src/all/mango/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/mango/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/src/all/mango/CHANGELOG.md b/src/all/mango/CHANGELOG.md
new file mode 100644
index 000000000..c6987889b
--- /dev/null
+++ b/src/all/mango/CHANGELOG.md
@@ -0,0 +1,7 @@
+
+
+## 1.0.0
+
+### Features
+
+* First version
\ No newline at end of file
diff --git a/src/all/mango/build.gradle b/src/all/mango/build.gradle
new file mode 100644
index 000000000..4d3346927
--- /dev/null
+++ b/src/all/mango/build.gradle
@@ -0,0 +1,16 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Mango'
+ pkgNameSuffix = 'all.mango'
+ extClass = '.Mango'
+ extVersionCode = 1
+ libVersion = '1.2'
+}
+
+dependencies {
+ implementation 'info.debatty:java-string-similarity:2.0.0'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/mango/res/ic_launcher-web.png b/src/all/mango/res/ic_launcher-web.png
new file mode 100644
index 000000000..0123a0fbb
Binary files /dev/null and b/src/all/mango/res/ic_launcher-web.png differ
diff --git a/src/all/mango/res/mipmap-hdpi/ic_launcher.png b/src/all/mango/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..553da24a1
Binary files /dev/null and b/src/all/mango/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/mango/res/mipmap-ldpi/ic_launcher.png b/src/all/mango/res/mipmap-ldpi/ic_launcher.png
new file mode 100644
index 000000000..c0961357c
Binary files /dev/null and b/src/all/mango/res/mipmap-ldpi/ic_launcher.png differ
diff --git a/src/all/mango/res/mipmap-mdpi/ic_launcher.png b/src/all/mango/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..fd970f8cf
Binary files /dev/null and b/src/all/mango/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/mango/res/mipmap-xhdpi/ic_launcher.png b/src/all/mango/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..8600237ec
Binary files /dev/null and b/src/all/mango/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/mango/res/mipmap-xxhdpi/ic_launcher.png b/src/all/mango/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..960d7011e
Binary files /dev/null and b/src/all/mango/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/mango/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/mango/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..307f16eca
Binary files /dev/null and b/src/all/mango/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/mango/src/eu/kanade/tachiyomi/extension/all/mango/Mango.kt b/src/all/mango/src/eu/kanade/tachiyomi/extension/all/mango/Mango.kt
new file mode 100644
index 000000000..db18db4c6
--- /dev/null
+++ b/src/all/mango/src/eu/kanade/tachiyomi/extension/all/mango/Mango.kt
@@ -0,0 +1,325 @@
+package eu.kanade.tachiyomi.extension.all.mango
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.support.v7.preference.EditTextPreference
+import android.support.v7.preference.PreferenceScreen
+import android.text.InputType
+import android.widget.Toast
+import com.github.salomonbrys.kotson.fromJson
+import com.github.salomonbrys.kotson.get
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import com.google.gson.JsonSyntaxException
+import eu.kanade.tachiyomi.extension.BuildConfig
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.ConfigurableSource
+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 info.debatty.java.stringsimilarity.JaroWinkler
+import info.debatty.java.stringsimilarity.Levenshtein
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.io.IOException
+
+class Mango : ConfigurableSource, HttpSource() {
+
+ override fun popularMangaRequest(page: Int): Request =
+ GET("$baseUrl/api/library", headersBuilder().build())
+
+ // Our popular manga are just our library of manga
+ override fun popularMangaParse(response: Response): MangasPage {
+ val result = try {
+ gson.fromJson(response.body()!!.string())
+ } catch (e: JsonSyntaxException) {
+ apiCookies = ""
+ throw Exception("Login Likely Failed. Try Refreshing.")
+ }
+ val mangas = result["titles"].asJsonArray
+ return MangasPage(
+ mangas.asJsonArray.map {
+ SManga.create().apply {
+ url = "/book/" + it["id"].asString
+ title = it["display_name"].asString
+ thumbnail_url = baseUrl + it["cover_url"].asString
+ }
+ },
+ false
+ )
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request =
+ throw UnsupportedOperationException("Not used")
+
+ override fun latestUpdatesParse(response: Response): MangasPage =
+ throw UnsupportedOperationException("Not used")
+
+ // Default is to just return the whole library for searching
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = popularMangaRequest(1)
+
+ // Overridden fetch so that we use our overloaded method instead
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return client.newCall(searchMangaRequest(page, query, filters))
+ .asObservableSuccess()
+ .map { response ->
+ searchMangaParse(response, query)
+ }
+ }
+
+ // Here the best we can do is just match manga based on their titles
+ private fun searchMangaParse(response: Response, query: String): MangasPage {
+
+ val queryLower = query.toLowerCase()
+ val mangas = popularMangaParse(response).mangas
+ val exactMatch = mangas.firstOrNull { it.title.toLowerCase() == queryLower }
+ if (exactMatch != null) {
+ return MangasPage(listOf(exactMatch), false)
+ }
+
+ // Text distance algorithms
+ val textDistance = Levenshtein()
+ val textDistance2 = JaroWinkler()
+
+ // Take results that potentially start the same
+ val results = mangas.filter {
+ val title = it.title.toLowerCase()
+ val query2 = queryLower.take(7)
+ (title.startsWith(query2, true) || title.contains(query2, true))
+ }.sortedBy { textDistance.distance(queryLower, it.title.toLowerCase()) }
+
+ // Take similar results
+ val results2 = mangas.map { Pair(textDistance2.distance(it.title.toLowerCase(), query), it) }
+ .filter { it.first < 0.3 }.sortedBy { it.first }.map { it.second }
+ val combinedResults = results.union(results2)
+
+ // Finally return the list
+ return MangasPage(combinedResults.toList(), false)
+ }
+
+ // Stub
+ override fun searchMangaParse(response: Response): MangasPage =
+ throw UnsupportedOperationException("Not used")
+
+ override fun mangaDetailsRequest(manga: SManga): Request =
+ GET(baseUrl + "/api" + manga.url, headers)
+
+ // This will just return the same thing as the main library endpoint
+ override fun mangaDetailsParse(response: Response): SManga {
+ val result = try {
+ gson.fromJson(response.body()!!.string())
+ } catch (e: JsonSyntaxException) {
+ apiCookies = ""
+ throw Exception("Login Likely Failed. Try Refreshing.")
+ }
+ return SManga.create().apply {
+ url = "/book/" + result["id"].asString
+ title = result["display_name"].asString
+ thumbnail_url = baseUrl + result["cover_url"].asString
+ }
+ }
+
+ override fun chapterListRequest(manga: SManga): Request =
+ GET(baseUrl + "/api" + manga.url, headers)
+
+ // The chapter url will contain how many pages the chapter contains for our page list endpoint
+ override fun chapterListParse(response: Response): List {
+ val result = try {
+ gson.fromJson(response.body()!!.string())
+ } catch (e: JsonSyntaxException) {
+ apiCookies = ""
+ throw Exception("Login Likely Failed. Try Refreshing.")
+ }
+ return result["entries"].asJsonArray.map { obj ->
+ SChapter.create().apply {
+ name = obj["display_name"].asString
+ url = "/page/${obj["title_id"].asString}/${obj["id"].asString}/${obj["pages"].asString}/"
+ date_upload = 1000L * obj["mtime"].asLong
+ }
+ }.sortedByDescending { it.date_upload }
+ }
+
+ // Stub
+ override fun pageListRequest(chapter: SChapter): Request =
+ throw UnsupportedOperationException("Not used")
+
+ // Overridden fetch so that we use our overloaded method instead
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ val splitUrl = chapter.url.split("/").toMutableList()
+ val numPages = splitUrl.removeAt(splitUrl.size - 2).toInt()
+ val baseUrlChapter = splitUrl.joinToString("/")
+ val pages = mutableListOf()
+ for (i in 0..numPages) {
+ pages.add(
+ Page(
+ index = i,
+ imageUrl = "$baseUrl/api$baseUrlChapter$i"
+ )
+ )
+ }
+ return Observable.just(pages)
+ }
+
+ // Stub
+ override fun pageListParse(response: Response): List =
+ throw UnsupportedOperationException("Not used")
+
+ override fun imageUrlParse(response: Response): String = ""
+ override fun getFilterList(): FilterList = FilterList()
+
+ override val name = "Mango"
+ override val lang = "en"
+ override val supportsLatest = false
+
+ override val baseUrl by lazy { getPrefBaseUrl() }
+ private val username by lazy { getPrefUsername() }
+ private val password by lazy { getPrefPassword() }
+ private val gson by lazy { Gson() }
+ private var apiCookies: String = ""
+
+ override fun headersBuilder(): Headers.Builder =
+ Headers.Builder()
+ .add("User-Agent", "Tachiyomi Mango v${BuildConfig.VERSION_NAME}")
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ override val client: OkHttpClient =
+ network.client.newBuilder()
+ .addInterceptor { authIntercept(it) }
+ .build()
+
+ private fun authIntercept(chain: Interceptor.Chain): Response {
+
+ // Check that we have our username and password to login with
+ val request = chain.request()
+ if (username.isEmpty() || password.isEmpty()) {
+ throw IOException("Missing username or password")
+ }
+
+ // Do the login if we have not gotten the cookies yet
+ if (apiCookies.isEmpty() || !apiCookies.contains("mango-sessid-9000", true)) {
+ doLogin(chain)
+ }
+
+ // Append the new cookie from the api
+ val authRequest = request.newBuilder()
+ .addHeader("Cookie", apiCookies)
+ .build()
+
+ return chain.proceed(authRequest)
+ }
+
+ private fun doLogin(chain: Interceptor.Chain) {
+ // Try to login
+ val formHeaders: Headers = headersBuilder()
+ .add("ContentType", "application/x-www-form-urlencoded")
+ .build()
+ val formBody: RequestBody = FormBody.Builder()
+ .addEncoded("username", username)
+ .addEncoded("password", password)
+ .build()
+ val loginRequest = POST("$baseUrl/login", formHeaders, formBody)
+ val response = chain.proceed(loginRequest)
+ if (response.code() != 200 || response.header("Set-Cookie") == null) {
+ throw Exception("Login Failed. Check Address and Credentials")
+ }
+ // Save the cookies from the response
+ apiCookies = response.header("Set-Cookie")!!
+ response.close()
+ }
+
+ override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
+ screen.addPreference(screen.editTextPreference(ADDRESS_TITLE, ADDRESS_DEFAULT, baseUrl))
+ screen.addPreference(screen.editTextPreference(USERNAME_TITLE, USERNAME_DEFAULT, username))
+ screen.addPreference(screen.editTextPreference(PASSWORD_TITLE, PASSWORD_DEFAULT, password, true))
+ }
+
+ private fun androidx.preference.PreferenceScreen.editTextPreference(title: String, default: String, value: String, isPassword: Boolean = false): androidx.preference.EditTextPreference {
+ return androidx.preference.EditTextPreference(context).apply {
+ key = title
+ this.title = title
+ summary = value
+ this.setDefaultValue(default)
+ dialogTitle = title
+
+ if (isPassword) {
+ setOnBindEditTextListener {
+ it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ }
+ setOnPreferenceChangeListener { _, newValue ->
+ try {
+ val res = preferences.edit().putString(title, newValue as String).commit()
+ Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
+ apiCookies = ""
+ res
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+ }
+ }
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ screen.addPreference(screen.supportEditTextPreference(ADDRESS_TITLE, ADDRESS_DEFAULT, baseUrl))
+ screen.addPreference(screen.supportEditTextPreference(USERNAME_TITLE, USERNAME_DEFAULT, username))
+ screen.addPreference(screen.supportEditTextPreference(PASSWORD_TITLE, PASSWORD_DEFAULT, password))
+ }
+
+ private fun PreferenceScreen.supportEditTextPreference(title: String, default: String, value: String): EditTextPreference {
+ return EditTextPreference(context).apply {
+ key = title
+ this.title = title
+ summary = value
+ this.setDefaultValue(default)
+ dialogTitle = title
+ setOnPreferenceChangeListener { _, newValue ->
+ try {
+ val res = preferences.edit().putString(title, newValue as String).commit()
+ Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
+ apiCookies = ""
+ res
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+ }
+ }
+
+ // We strip the last slash since we will append it above
+ private fun getPrefBaseUrl(): String {
+ var path = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
+ if (path.last() == '/') {
+ path = path.substring(0, path.length - 1)
+ }
+ return path
+ }
+ private fun getPrefUsername(): String = preferences.getString(USERNAME_TITLE, USERNAME_DEFAULT)!!
+ private fun getPrefPassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
+
+ companion object {
+ private const val ADDRESS_TITLE = "Address"
+ private const val ADDRESS_DEFAULT = ""
+ private const val USERNAME_TITLE = "Username"
+ private const val USERNAME_DEFAULT = ""
+ private const val PASSWORD_TITLE = "Password"
+ private const val PASSWORD_DEFAULT = ""
+ }
+}