diff --git a/src/all/cubari/AndroidManifest.xml b/src/all/cubari/AndroidManifest.xml
new file mode 100644
index 000000000..a28e3cbe3
--- /dev/null
+++ b/src/all/cubari/AndroidManifest.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/all/cubari/build.gradle b/src/all/cubari/build.gradle
new file mode 100644
index 000000000..afc7c9fd6
--- /dev/null
+++ b/src/all/cubari/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Cubari'
+ pkgNameSuffix = "all.cubari"
+ extClass = '.Cubari'
+ extVersionCode = 1
+ libVersion = '1.2'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/cubari/res/mipmap-hdpi/ic_launcher.png b/src/all/cubari/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..a43b765ef
Binary files /dev/null and b/src/all/cubari/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/cubari/res/mipmap-mdpi/ic_launcher.png b/src/all/cubari/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..d2a531cfc
Binary files /dev/null and b/src/all/cubari/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/cubari/res/mipmap-xhdpi/ic_launcher.png b/src/all/cubari/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..b8ce2733b
Binary files /dev/null and b/src/all/cubari/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/cubari/res/mipmap-xxhdpi/ic_launcher.png b/src/all/cubari/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..804782509
Binary files /dev/null and b/src/all/cubari/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/cubari/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/cubari/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..84438c81a
Binary files /dev/null and b/src/all/cubari/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/cubari/res/web_hi_res_512.png b/src/all/cubari/res/web_hi_res_512.png
new file mode 100644
index 000000000..1dfd0c08c
Binary files /dev/null and b/src/all/cubari/res/web_hi_res_512.png differ
diff --git a/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/Cubari.kt b/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/Cubari.kt
new file mode 100644
index 000000000..d8e0c4f03
--- /dev/null
+++ b/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/Cubari.kt
@@ -0,0 +1,353 @@
+package eu.kanade.tachiyomi.extension.all.cubari
+
+import android.os.Build
+import eu.kanade.tachiyomi.extension.BuildConfig
+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 okhttp3.Headers
+import okhttp3.Request
+import okhttp3.Response
+import org.json.JSONArray
+import org.json.JSONObject
+import rx.Observable
+
+open class Cubari : HttpSource() {
+
+ final override val name = "Cubari"
+ final override val baseUrl = "https://cubari.moe"
+ final override val supportsLatest = true
+ final override val lang = "all"
+
+ override fun headersBuilder() = Headers.Builder().apply {
+ add(
+ "User-Agent",
+ "(Android ${Build.VERSION.RELEASE}; " +
+ "${Build.MANUFACTURER} ${Build.MODEL}) " +
+ "Tachiyomi/${BuildConfig.VERSION_NAME} " +
+ Build.ID
+ )
+ }
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ return GET("$baseUrl/", headers)
+ }
+
+ override fun fetchLatestUpdates(page: Int): Observable {
+ return client.newBuilder()
+ .addInterceptor(RemoteStorageUtils.HomeInterceptor())
+ .build()!!
+ .newCall(latestUpdatesRequest(page))
+ .asObservableSuccess()
+ .map { response -> latestUpdatesParse(response) }
+ }
+
+ override fun latestUpdatesParse(response: Response): MangasPage {
+ return parseMangaList(JSONArray(response.body()!!.string()), SortType.UNPINNED)
+ }
+
+ override fun popularMangaRequest(page: Int): Request {
+ return GET("$baseUrl/", headers)
+ }
+
+ override fun fetchPopularManga(page: Int): Observable {
+ return client.newBuilder()
+ .addInterceptor(RemoteStorageUtils.HomeInterceptor())
+ .build()!!
+ .newCall(popularMangaRequest(page))
+ .asObservableSuccess()
+ .map { response -> popularMangaParse(response) }
+ }
+
+ override fun popularMangaParse(response: Response): MangasPage {
+ return parseMangaList(JSONArray(response.body()!!.string()), SortType.PINNED)
+ }
+
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return client.newCall(chapterListRequest(manga))
+ .asObservableSuccess()
+ .map { response -> mangaDetailsParse(response, manga) }
+ }
+
+ // Called when the series is loaded, or when opening in browser
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ return GET("$baseUrl${manga.url}", headers)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ throw Exception("Unused")
+ }
+
+ private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
+ return parseMangaFromApi(JSONObject(response.body()!!.string()), manga)
+ }
+
+ override fun fetchChapterList(manga: SManga): Observable> {
+ return client.newCall(chapterListRequest(manga))
+ .asObservableSuccess()
+ .map { response -> chapterListParse(response, manga) }
+ }
+
+ // Gets the chapter list based on the series being viewed
+ override fun chapterListRequest(manga: SManga): Request {
+ val urlComponents = manga.url.split("/")
+ val source = urlComponents[2]
+ val slug = urlComponents[3]
+
+ return GET("$baseUrl/read/api/$source/series/$slug/", headers)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ throw Exception("Unused")
+ }
+
+ // Called after the request
+ private fun chapterListParse(response: Response, manga: SManga): List {
+ val res = response.body()!!.string()
+ return parseChapterList(res, manga)
+ }
+
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ return when {
+ chapter.url.contains("/chapter/") -> {
+ client.newCall(pageListRequest(chapter))
+ .asObservableSuccess()
+ .map { response ->
+ directPageListParse(response)
+ }
+ }
+ else -> {
+ client.newCall(pageListRequest(chapter))
+ .asObservableSuccess()
+ .map { response ->
+ seriesJsonPageListParse(response, chapter)
+ }
+ }
+ }
+ }
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ return when {
+ chapter.url.contains("/chapter/") -> {
+ GET("$baseUrl${chapter.url}", headers)
+ }
+ else -> {
+ var url = chapter.url.split("/")
+ val source = url[2]
+ val slug = url[3]
+
+ GET("$baseUrl/read/api/$source/series/$slug/", headers)
+ }
+ }
+ }
+
+ private fun directPageListParse(response: Response): List {
+ val res = response.body()!!.string()
+ val pages = JSONArray(res)
+ val pageArray = ArrayList()
+
+ for (i in 0 until pages.length()) {
+ val page = if (pages.optJSONObject(i) != null) {
+ pages.getJSONObject(i).getString("src")
+ } else {
+ pages[i]
+ }
+ pageArray.add(Page(i + 1, "", page.toString()))
+ }
+ return pageArray
+ }
+
+ private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List {
+ val res = response.body()!!.string()
+ val json = JSONObject(res)
+ val groups = json.getJSONObject("groups")
+ val groupIter = groups.keys()
+ val groupMap = HashMap()
+
+ while (groupIter.hasNext()) {
+ val groupKey = groupIter.next()
+ groupMap[groups.getString(groupKey)] = groupKey
+ }
+
+ val chapters = json.getJSONObject("chapters")
+
+ val pages = if (chapters.has(chapter.chapter_number.toString())) {
+ chapters
+ .getJSONObject(chapter.chapter_number.toString())
+ .getJSONObject("groups")
+ .getJSONArray(groupMap[chapter.scanlator])
+ } else {
+ chapters
+ .getJSONObject(chapter.chapter_number.toInt().toString())
+ .getJSONObject("groups")
+ .getJSONArray(groupMap[chapter.scanlator])
+ }
+ val pageArray = ArrayList()
+ for (i in 0 until pages.length()) {
+ val page = if (pages.optJSONObject(i) != null) {
+ pages.getJSONObject(i).getString("src")
+ } else {
+ pages[i]
+ }
+ pageArray.add(Page(i + 1, "", page.toString()))
+ }
+ return pageArray
+ }
+
+ // Stub
+ override fun pageListParse(response: Response): List {
+ throw Exception("Unused")
+ }
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return when {
+ query.startsWith(PROXY_PREFIX) -> {
+ val trimmedQuery = query.removePrefix(PROXY_PREFIX)
+ // Only tag for recently read on search
+ client.newBuilder()
+ .addInterceptor(RemoteStorageUtils.TagInterceptor())
+ .build()!!
+ .newCall(searchMangaRequest(page, trimmedQuery, filters))
+ .asObservableSuccess()
+ .map { response ->
+ searchMangaParse(response, trimmedQuery)
+ }
+ }
+ else -> Observable.just(MangasPage(ArrayList(), false))
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ try {
+ val queryFragments = query.split("/")
+ val source = queryFragments[0]
+ val slug = queryFragments[1]
+
+ return GET("$baseUrl/read/api/$source/series/$slug/", headers)
+ } catch (e: Exception) {
+ throw Exception("Unable to parse. Is your query in the format of ${Cubari.PROXY_PREFIX}/?")
+ }
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ throw Exception("Unused")
+ }
+
+ private fun searchMangaParse(response: Response, query: String): MangasPage {
+ return parseSearchList(JSONObject(response.body()!!.string()), query)
+ }
+
+ // ------------- Helpers and whatnot ---------------
+
+ private fun parseChapterList(payload: String, manga: SManga): List {
+ val json = JSONObject(payload)
+ val groups = json.getJSONObject("groups")
+ val chapters = json.getJSONObject("chapters")
+
+ val chapterList = ArrayList()
+
+ val iter = chapters.keys()
+
+ while (iter.hasNext()) {
+ val chapterNum = iter.next()
+ val chapterObj = chapters.getJSONObject(chapterNum)
+ val chapterGroups = chapterObj.getJSONObject("groups")
+ val groupsIter = chapterGroups.keys()
+
+ while (groupsIter.hasNext()) {
+ val groupNum = groupsIter.next()
+ val chapter = SChapter.create()
+
+ chapter.scanlator = groups.getString(groupNum)
+ if (chapterObj.has("release_date")) {
+ chapter.date_upload =
+ chapterObj.getJSONObject("release_date").getLong(groupNum) * 1000
+ }
+ chapter.name = chapterNum + " - " + chapterObj.getString("title")
+ chapter.chapter_number = chapterNum.toFloat()
+ chapter.url =
+ if (chapterGroups.optJSONArray(groupNum) != null) {
+ "${manga.url}/$chapterNum/$groupNum"
+ } else {
+ chapterGroups.getString(groupNum)
+ }
+ chapterList.add(chapter)
+ }
+ }
+
+ return chapterList.reversed()
+ }
+
+ private fun parseMangaList(payload: JSONArray, sortType: SortType): MangasPage {
+ val mangas = ArrayList()
+
+ for (i in 0 until payload.length()) {
+ val json = payload.getJSONObject(i)
+ val pinned = json.getBoolean("pinned")
+
+ if (sortType == SortType.PINNED && pinned) {
+ mangas.add(parseMangaFromRemoteStorage(json))
+ } else if (sortType == SortType.UNPINNED && !pinned) {
+ mangas.add(parseMangaFromRemoteStorage(json))
+ }
+ }
+
+ return MangasPage(mangas, false)
+ }
+
+ private fun parseSearchList(payload: JSONObject, query: String): MangasPage {
+ val mangas = ArrayList()
+ val tempManga = SManga.create()
+ tempManga.url = "/read/$query"
+ mangas.add(parseMangaFromApi(payload, tempManga))
+ return MangasPage(mangas, false)
+ }
+
+ private fun parseMangaFromRemoteStorage(json: JSONObject): SManga {
+ val manga = SManga.create()
+ manga.title = json.getString("title")
+ manga.artist = json.optString("artist", ARTIST_FALLBACK)
+ manga.author = json.optString("author", AUTHOR_FALLBACK)
+ manga.description = json.optString("description", DESCRIPTION_FALLBACK)
+ manga.url = json.getString("url")
+ manga.thumbnail_url = json.getString("coverUrl")
+
+ return manga
+ }
+
+ private fun parseMangaFromApi(json: JSONObject, mangaReference: SManga): SManga {
+ val manga = SManga.create()
+ manga.title = json.getString("title")
+ manga.artist = json.optString("artist", ARTIST_FALLBACK)
+ manga.author = json.optString("author", AUTHOR_FALLBACK)
+ manga.description = json.optString("description", DESCRIPTION_FALLBACK)
+ manga.url = mangaReference.url
+ manga.thumbnail_url = json.optString("cover", "")
+
+ return manga
+ }
+
+ // ----------------- Things we aren't supporting -----------------
+
+ override fun imageUrlParse(response: Response): String {
+ throw Exception("imageUrlParse not supported.")
+ }
+
+ companion object {
+ const val PROXY_PREFIX = "cubari:"
+
+ const val AUTHOR_FALLBACK = "Unknown"
+ const val ARTIST_FALLBACK = "Unknown"
+ const val DESCRIPTION_FALLBACK = "No description."
+
+ enum class SortType {
+ PINNED,
+ UNPINNED
+ }
+ }
+}
diff --git a/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/CubariUrlActivity.kt b/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/CubariUrlActivity.kt
new file mode 100644
index 000000000..4d06f04e9
--- /dev/null
+++ b/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/CubariUrlActivity.kt
@@ -0,0 +1,64 @@
+package eu.kanade.tachiyomi.extension.all.cubari
+
+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 CubariUrlActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val host = intent?.data?.host
+ val pathSegments = intent?.data?.pathSegments
+
+ if (host != null && pathSegments != null) {
+ val query = when (host) {
+ "m.imgur.com", "imgur.com" -> fromImgur(pathSegments)
+ else -> fromCubari(pathSegments)
+ }
+
+ if (query == null) {
+ Log.e("CubariUrlActivity", "Unable to parse URI from intent $intent")
+ finish()
+ exitProcess(1)
+ }
+
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", query)
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("CubariUrlActivity", e.toString())
+ }
+ }
+
+ finish()
+ exitProcess(0)
+ }
+
+ private fun fromImgur(pathSegments: List): String? {
+ if (pathSegments.size >= 2) {
+ val id = pathSegments[1]
+
+ return "${Cubari.PROXY_PREFIX}imgur/$id"
+ }
+ return null
+ }
+
+ private fun fromCubari(pathSegments: MutableList): String? {
+ return if (pathSegments.size >= 3) {
+ val source = pathSegments[1]
+ val slug = pathSegments[2]
+ "${Cubari.PROXY_PREFIX}$source/$slug"
+ } else {
+ null
+ }
+ }
+}
diff --git a/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/RemoteStorageUtils.kt b/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/RemoteStorageUtils.kt
new file mode 100644
index 000000000..3e4fbd060
--- /dev/null
+++ b/src/all/cubari/src/eu/kanade/tachiyomi/extension/all/cubari/RemoteStorageUtils.kt
@@ -0,0 +1,147 @@
+package eu.kanade.tachiyomi.extension.all.cubari
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.os.Handler
+import android.os.Looper
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.io.IOException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class RemoteStorageUtils {
+ abstract class GenericInterceptor(private val transparent: Boolean) : Interceptor {
+ private val handler = Handler(Looper.getMainLooper())
+
+ abstract val jsScript: String
+
+ abstract fun urlModifier(originalUrl: String): String
+
+ internal class JsInterface(private val latch: CountDownLatch, var payload: String = "") {
+ @JavascriptInterface
+ fun passPayload(passedPayload: String) {
+ payload = passedPayload
+ latch.countDown()
+ }
+ }
+
+ @Synchronized
+ override fun intercept(chain: Interceptor.Chain): Response {
+ try {
+ val originalRequest = chain.request()
+ val originalResponse = chain.proceed(originalRequest)
+ return proceedWithWebView(originalRequest, originalResponse)
+ } catch (e: Exception) {
+ throw IOException(e)
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
+ private fun proceedWithWebView(request: Request, response: Response): Response {
+ val latch = CountDownLatch(1)
+
+ var webView: WebView? = null
+
+ val origRequestUrl = request.url().toString()
+ val headers = request.headers().toMultimap().mapValues {
+ it.value.getOrNull(0) ?: ""
+ }.toMutableMap()
+ val jsInterface = JsInterface(latch)
+
+ handler.post {
+ val webview = WebView(Injekt.get())
+ webView = webview
+ with(webview.settings) {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ databaseEnabled = true
+ useWideViewPort = false
+ loadWithOverviewMode = false
+ userAgentString = request.header("User-Agent")
+ }
+
+ webview.addJavascriptInterface(jsInterface, "android")
+
+ webview.webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView, url: String) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
+ view.evaluateJavascript(jsScript) {}
+ }
+ if (transparent) {
+ latch.countDown()
+ }
+ }
+ }
+
+ webview.loadUrl(urlModifier(origRequestUrl), headers)
+ }
+
+ latch.await(TIMEOUT_SEC, TimeUnit.SECONDS)
+
+ handler.postDelayed(
+ { webView?.destroy() },
+ DELAY_MILLIS * (if (transparent) 2 else 1)
+ )
+
+ return if (transparent) {
+ response
+ } else {
+ response.newBuilder().body(ResponseBody.create(response.body()?.contentType(), jsInterface.payload)).build()
+ }
+ }
+ }
+
+ class TagInterceptor : GenericInterceptor(true) {
+ override val jsScript: String = """
+ let dispatched = false;
+ window.addEventListener('history-ready', function () {
+ if (!dispatched) {
+ dispatched = true;
+ Promise.all(
+ [globalHistoryHandler.getAllPinnedSeries(), globalHistoryHandler.getAllUnpinnedSeries()]
+ ).then(e => {
+ window.android.passPayload(JSON.stringify(e.flatMap(e => e)))
+ });
+ }
+ });
+ tag();
+ """
+
+ override fun urlModifier(originalUrl: String): String {
+ return originalUrl.replace("/api/", "/").replace("/series/", "/")
+ }
+ }
+
+ class HomeInterceptor : GenericInterceptor(false) {
+ override val jsScript: String = """
+ let dispatched = false;
+ (function () {
+ if (!dispatched) {
+ dispatched = true;
+ Promise.all(
+ [globalHistoryHandler.getAllPinnedSeries(), globalHistoryHandler.getAllUnpinnedSeries()]
+ ).then(e => {
+ window.android.passPayload(JSON.stringify(e.flatMap(e => e) ) )
+ });
+ }
+ })();
+ """
+
+ override fun urlModifier(originalUrl: String): String {
+ return originalUrl
+ }
+ }
+
+ companion object {
+ const val TIMEOUT_SEC: Long = 10
+ const val DELAY_MILLIS: Long = 10000
+ }
+}