Add EH code.

This commit is contained in:
NerdNumber9
2017-01-02 18:02:10 -05:00
parent a7192e866f
commit caa1e1ef09
32 changed files with 1363 additions and 29 deletions

3
.gitignore vendored
View File

@ -6,4 +6,5 @@
.idea/
*iml
*.iml
*/build
*/build
/mainframer.sh

3
app/.gitignore vendored
View File

@ -1,4 +1,5 @@
/build
*iml
*.iml
custom.gradle
custom.gradle
google-services.json

View File

@ -28,18 +28,19 @@ ext {
}
}
android {
compileSdkVersion 25
buildToolsVersion "25.0.1"
publishNonDefault true
defaultConfig {
applicationId "eu.kanade.tachiyomi"
applicationId "eu.kanade.tachiyomi.eh"
minSdkVersion 16
targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 17
versionName "0.4.1"
versionCode 4000
versionName "v4.0.0-EH"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -190,15 +191,25 @@ dependencies {
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
compile 'de.hdodenhof:circleimageview:2.1.0'
//Firebase (EH)
final firebase_version = '10.0.1'
releaseCompile "com.google.firebase:firebase-core:$firebase_version"
releaseCompile "com.google.firebase:firebase-messaging:$firebase_version"
releaseCompile "com.google.firebase:firebase-crash:$firebase_version"
//SnappyDB (EH)
compile 'io.paperdb:paperdb:2.0'
// Tests
testCompile 'junit:junit:4.12'
//Paper DB screws up tests
/*testCompile 'junit:junit:4.12'
testCompile 'org.assertj:assertj-core:1.7.1'
testCompile 'org.mockito:mockito-core:1.10.19'
final robolectric_version = '3.1.4'
testCompile "org.robolectric:robolectric:$robolectric_version"
testCompile "org.robolectric:shadows-multidex:$robolectric_version"
testCompile "org.robolectric:shadows-play-services:$robolectric_version"
testCompile "org.robolectric:shadows-play-services:$robolectric_version"*/
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}
@ -216,3 +227,6 @@ buildscript {
repositories {
mavenCentral()
}
//Firebase (EH)
//apply plugin: 'com.google.gms.google-services'

View File

@ -95,4 +95,7 @@
-dontwarn org.yaml.snakeyaml.**
# Duktape
-keep class com.squareup.duktape.** { *; }
-keep class com.squareup.duktape.** { *; }
# [EH]
-keep class exh.** { *; }

View File

@ -98,6 +98,13 @@
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
android:value="GlideModule" />
<!-- EH -->
<activity
android:name="exh.ui.login.LoginActivity"
android:label="@string/label_login"
android:parentActivityName=".ui.setting.SettingsActivity" >
</activity>
</application>
</manifest>

View File

@ -8,6 +8,7 @@ import com.evernote.android.job.JobManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import eu.kanade.tachiyomi.util.LocaleHelper
import io.paperdb.Paper
import org.acra.ACRA
import org.acra.annotation.ReportsCrashes
import timber.log.Timber
@ -33,6 +34,7 @@ open class App : Application() {
setupAcra()
setupJobManager()
Paper.init(this) //Setup metadata DB (EH)
LocaleHelper.updateConfiguration(this, resources.configuration)
}

View File

@ -144,4 +144,13 @@ class PreferencesHelper(val context: Context) {
fun lang() = prefs.getString(keys.lang, "")
//EH
fun enableExhentai() = rxPrefs.getBoolean("enable_exhentai", false)
fun secureEXH() = rxPrefs.getBoolean("secure_exh", true)
//EH Cookies
fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", null)
fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", null)
fun igneousVal() = rxPrefs.getString("eh_igneous", null)
}

View File

@ -4,8 +4,12 @@ import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.content.Context
import android.os.Environment
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.YamlOnlineSource
import eu.kanade.tachiyomi.data.source.online.all.EHentai
import eu.kanade.tachiyomi.data.source.online.all.EHentaiMetadata
import eu.kanade.tachiyomi.data.source.online.english.*
import eu.kanade.tachiyomi.data.source.online.german.WieManga
import eu.kanade.tachiyomi.data.source.online.russian.Mangachan
@ -14,11 +18,14 @@ import eu.kanade.tachiyomi.data.source.online.russian.Readmanga
import eu.kanade.tachiyomi.util.hasPermission
import org.yaml.snakeyaml.Yaml
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
open class SourceManager(private val context: Context) {
private val sourcesMap = createSources()
private val prefs: PreferencesHelper by injectLazy()
private var sourcesMap = createSources()
open fun get(sourceKey: Int): Source? {
return sourcesMap[sourceKey]
@ -27,19 +34,39 @@ open class SourceManager(private val context: Context) {
fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java)
private fun createOnlineSourceList(): List<Source> = listOf(
Batoto(1),
Mangahere(2),
Mangafox(3),
Kissmanga(4),
Readmanga(5),
Mintmanga(6),
Mangachan(7),
Readmangatoday(8),
Mangasee(9),
WieManga(10)
Batoto(101),
Mangahere(102),
Mangafox(103),
Kissmanga(104),
Readmanga(105),
Mintmanga(106),
Mangachan(107),
Readmangatoday(108),
Mangasee(109),
WieManga(110)
)
private fun createEHSources(): List<Source> {
val exSrcs = mutableListOf(
EHentai(1, false, context),
EHentaiMetadata(3, false, context)
)
if(prefs.enableExhentai().getOrDefault()) {
exSrcs += EHentai(2, true, context)
exSrcs += EHentaiMetadata(4, true, context)
}
return exSrcs
}
init {
prefs.enableExhentai().asObservable().subscribe {
//Refresh sources when ExHentai enabled/disabled change
sourcesMap = createSources()
}
}
private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply {
createEHSources().forEach { put(it.id, it) }
createOnlineSourceList().forEach { put(it.id, it) }
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath +

View File

@ -7,7 +7,7 @@ import rx.subjects.Subject
class Page(
val index: Int,
val url: String = "",
var url: String = "",
var imageUrl: String? = null,
@Transient var uri: Uri? = null
) : ProgressListener {

View File

@ -0,0 +1,343 @@
package eu.kanade.tachiyomi.data.source.online.all
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.asObservableSuccess
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.*
import exh.metadata.models.ExGalleryMetadata
import exh.metadata.models.Tag
import exh.plusAssign
import okhttp3.Response
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.util.*
import exh.ui.login.LoginActivity
class EHentai(override val id: Int,
val exh: Boolean,
val context: Context) : OnlineSource() {
val schema: String
get() = if(prefs.secureEXH().getOrDefault())
"https"
else
"http"
override val baseUrl: String
get() = if(exh)
"$schema://exhentai.org"
else
"http://g.e-hentai.org"
override val lang = "all"
override val supportsLatest = true
val prefs: PreferencesHelper by injectLazy()
val metadataHelper = MetadataHelper()
/**
* Gallery list entry
*/
data class ParsedManga(val fav: String?, val manga: Manga)
/**
* Parse a list of galleries
*/
fun genericMangaParse(response: Response, page: MangasPage? = null)
= with(response.asJsoup()) {
//Parse mangas
val parsedMangas = select(".gtr0,.gtr1").map {
ParsedManga(
fav = it.select(".itd .it3 > .i[id]").first()?.attr("title"),
manga = Manga.create(id).apply {
//Get title
it.select(".itd .it5 a").first()?.apply {
title = text()
setUrlWithoutDomain(addParam(attr("href"), "nw", "always"))
}
//Get image
it.select(".itd .it2").first()?.apply {
children().first()?.let {
thumbnail_url = it.attr("src")
} ?: let {
text().split("~").apply {
thumbnail_url = "http://${this[1]}/${this[2]}"
}
}
}
})
}
//Add to page if required
page?.let { page ->
page.mangas += parsedMangas.map { it.manga }
select("a[onclick=return false]").last()?.let {
if(it.text() == ">") page.nextPageUrl = it.attr("href")
}
}
//Return parsed mangas anyways
parsedMangas
}
override fun fetchChapterList(manga: Manga): Observable<List<Chapter>>
= Observable.just(listOf(Chapter.create().apply {
manga_id = manga.id
url = manga.url
name = "Chapter"
chapter_number = 1f
}))
override fun fetchPageListFromNetwork(chapter: Chapter)
= fetchChapterPage(chapter, 0).map {
it.mapIndexed { i, s ->
Page(i, s)
}
}!!
private fun fetchChapterPage(chapter: Chapter, id: Int): Observable<List<String>> {
val urls = mutableListOf<String>()
return chapterPageCall(chapter, id).flatMap {
val jsoup = it.asJsoup()
urls += parseChapterPage(jsoup)
if(nextPageUrl(jsoup) != null) {
fetchChapterPage(chapter, id + 1)
} else {
Observable.just(urls)
}
}
}
private fun parseChapterPage(response: Element)
= with(response) {
select(".gdtm a").map {
Pair(it.child(0).attr("alt").toInt(), it.attr("href"))
}.sortedBy(Pair<Int, String>::first).map { it.second }
}
private fun chapterPageCall(chapter: Chapter, pn: Int) = client.newCall(chapterPageRequest(chapter, pn)).asObservableSuccess()
private fun chapterPageRequest(chapter: Chapter, pn: Int) = GET("$baseUrl${chapter.url}?p=$pn", headers)
private fun nextPageUrl(element: Element): String?
= element.select("a[onclick=return false]").last()?.let {
return if (it.text() == ">") it.attr("href") else null
}
private fun buildGenreString(filters: List<OnlineSource.Filter>): String {
val genreString = StringBuilder()
for (genre in GENRE_LIST) {
genreString += "&f_"
genreString += genre
genreString += "="
genreString += if (filters.isEmpty()
|| !filters
.map { it.id }
.find { it == genre }
.isNullOrEmpty())
"1"
else
"0"
}
return genreString.toString()
}
override fun popularMangaInitialUrl() = if(exh)
latestUpdatesInitialUrl()
else
"$baseUrl/toplist.php?tl=15"
override fun popularMangaParse(response: Response, page: MangasPage) {
genericMangaParse(response, page)
}
override fun searchMangaInitialUrl(query: String, filters: List<Filter>)
= "$baseUrl$QUERY_PREFIX${buildGenreString(filters)}&f_search=${URLEncoder.encode(query, "UTF-8")}"
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
genericMangaParse(response, page)
}
override fun latestUpdatesInitialUrl() = baseUrl
override fun latestUpdatesParse(response: Response, page: MangasPage) {
genericMangaParse(response, page)
}
/**
* Parse gallery page to metadata model
*/
override fun mangaDetailsParse(response: Response, manga: Manga) = with(response.asJsoup()) {
val metdata = ExGalleryMetadata()
with(metdata) {
url = manga.url
exh = this@EHentai.exh
title = select("#gn").text().nullIfBlank()
altTitle = select("#gj").text().nullIfBlank()
thumbnailUrl = select("#gd1 img").attr("src").nullIfBlank()
genre = select(".ic").attr("alt").nullIfBlank()
uploader = select("#gdn").text().nullIfBlank()
//Parse the table
select("#gdd tr").forEach {
it.select(".gdt1")
.text()
.nullIfBlank()
?.trim()
?.let { left ->
it.select(".gdt2")
.text()
.nullIfBlank()
?.trim()
?.let { right ->
ignore {
when (left.removeSuffix(":")
.toLowerCase()) {
"posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
"visible" -> visible = right.nullIfBlank()
"language" -> {
language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
translated = right.endsWith(TR_SUFFIX, true)
}
"file size" -> size = parseHumanReadableByteCount(right)?.toLong()
"length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
"favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
}
}
}
}
}
//Parse ratings
ignore {
averageRating = select("#rating_label")
.text()
.removePrefix("Average:")
.trim()
.nullIfBlank()
?.toDouble()
ratingCount = select("#rating_count")
.text()
.trim()
.nullIfBlank()
?.toInt()
}
//Parse tags
tags.clear()
select("#taglist tr").forEach {
val namespace = it.select(".tc").text().removeSuffix(":")
val currentTags = it.select("div").map {
Tag(it.text().trim(),
it.hasClass("gtl"))
}
tags.put(namespace, ArrayList(currentTags))
}
//Save metadata
metadataHelper.writeGallery(this)
//Copy metadata to manga
copyTo(manga)
}
}
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
throw UnsupportedOperationException()
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
throw UnsupportedOperationException()
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
//Copy and paste from OnlineSource as we need the page argument
override public fun fetchImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
return client
.newCall(imageUrlRequest(page))
.asObservableSuccess()
.map { imageUrlParse(it, page) }
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
fun imageUrlParse(response: Response, page: Page): String {
with(response.asJsoup()) {
val currentImage = select("img[onerror]").attr("src")
//Each press of the retry button will choose another server
select("#loadfail").attr("onclick").nullIfBlank()?.let {
page.url = addParam(page.url, "nl", it.substring(it.indexOf('\'') + 1 .. it.lastIndexOf('\'') - 1))
}
return currentImage
}
}
val cookiesHeader by lazy {
val cookies: MutableMap<String, String> = HashMap()
if(prefs.enableExhentai().getOrDefault()) {
cookies.put(LoginActivity.MEMBER_ID_COOKIE, prefs.memberIdVal().getOrDefault())
cookies.put(LoginActivity.PASS_HASH_COOKIE, prefs.passHashVal().getOrDefault())
cookies.put(LoginActivity.IGNEOUS_COOKIE, prefs.igneousVal().getOrDefault())
}
buildCookies(cookies)
}
//Headers
override fun headersBuilder()
= super.headersBuilder().add("Cookie", cookiesHeader)!!
fun buildCookies(cookies: Map<String, String>)
= cookies.entries.map {
"${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}"
}.joinToString(separator = "; ", postfix = ";")
fun addParam(url: String, param: String, value: String)
= Uri.parse(url)
.buildUpon()
.appendQueryParameter(param, value)
.toString()
override val client = super.client.newBuilder()
.addInterceptor { chain ->
val newReq = chain
.request()
.newBuilder()
.addHeader("Cookie", cookiesHeader)
.build()
chain.proceed(newReq)
}.build()!!
//Filters
val generatedFilters = GENRE_LIST.map { Filter(it, it) }
override fun getFilterList() = generatedFilters
override val name = if(exh)
"ExHentai"
else
"E-Hentai"
companion object {
val QUERY_PREFIX = "?f_apply=Apply+Filter"
val GENRE_LIST = arrayOf("doujinshi", "manga", "artistcg", "gamecg", "western", "non-h", "imageset", "cosplay", "asianporn", "misc")
val TR_SUFFIX = "TR"
}
}

View File

@ -0,0 +1,135 @@
package eu.kanade.tachiyomi.data.source.online.all
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import exh.metadata.MetadataHelper
import exh.metadata.copyTo
import exh.metadata.models.ExGalleryMetadata
import okhttp3.Response
import rx.Observable
/**
* Offline metadata store source
*/
class EHentaiMetadata(override val id: Int,
val exh: Boolean,
val context: Context) : OnlineSource() {
val metadataHelper = MetadataHelper()
val internalEx = EHentai(id - 2, exh, context)
override val baseUrl: String
get() = throw UnsupportedOperationException()
override val lang: String
get() = "advanced"
override val supportsLatest: Boolean
get() = true
override fun popularMangaInitialUrl(): String {
throw UnsupportedOperationException()
}
override fun popularMangaParse(response: Response, page: MangasPage) {
throw UnsupportedOperationException()
}
override fun searchMangaInitialUrl(query: String, filters: List<Filter>): String {
throw UnsupportedOperationException()
}
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
throw UnsupportedOperationException()
}
override fun latestUpdatesInitialUrl(): String {
throw UnsupportedOperationException()
}
override fun latestUpdatesParse(response: Response, page: MangasPage) {
throw UnsupportedOperationException()
}
override fun mangaDetailsParse(response: Response, manga: Manga) {
throw UnsupportedOperationException()
}
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
throw UnsupportedOperationException()
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
throw UnsupportedOperationException()
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
override fun fetchChapterList(manga: Manga): Observable<List<Chapter>>
= Observable.just(listOf(Chapter.create().apply {
manga_id = manga.id
url = manga.url
name = "ONLINE - Chapter"
chapter_number = 1f
}))
override fun fetchPageListFromNetwork(chapter: Chapter) = internalEx.fetchPageListFromNetwork(chapter)
override fun fetchImageUrl(page: Page) = internalEx.fetchImageUrl(page)
fun List<ExGalleryMetadata>.mapToManga() = filter { it.exh == exh }
.map {
Manga.create(id).apply {
it.copyTo(this)
source = this@EHentaiMetadata.id
}
}
fun sortedByTimeGalleries() = metadataHelper.getAllGalleries().sortedByDescending {
it.datePosted ?: 0
}
override fun fetchPopularManga(page: MangasPage)
= Observable.fromCallable {
page.mangas.addAll(metadataHelper.getAllGalleries().sortedByDescending {
it.ratingCount ?: 0
}.mapToManga())
page
}!!
override fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>)
= Observable.fromCallable {
page.mangas.addAll(sortedByTimeGalleries().filter { manga ->
filters.isEmpty() || filters.filter { it.id == manga.genre }.isNotEmpty()
}.mapToManga())
page
}!!
override fun fetchLatestUpdates(page: MangasPage)
= Observable.fromCallable {
page.mangas.addAll(sortedByTimeGalleries().mapToManga())
page
}!!
override fun fetchMangaDetails(manga: Manga) = Observable.fromCallable {
//Hack to convert the gallery into an online gallery when favoriting it or reading it
metadataHelper.fetchMetadata(manga.url, exh).copyTo(manga)
manga
}!!
override fun getFilterList() = internalEx.getFilterList()
override val name: String
get() = if(exh) {
"ExHentai"
} else {
"E-Hentai"
} + " - METADATA"
}

View File

@ -23,7 +23,7 @@ interface GithubService {
}
}
@GET("/repos/inorichi/tachiyomi/releases/latest")
@GET("/repos/NerdNumber9/tachiyomi/releases/latest")
fun getLatestVersion(): Observable<GithubRelease>
}

View File

@ -66,6 +66,7 @@ class SettingsActivity : BaseActivity(),
"tracking_screen" -> SettingsTrackingFragment.newInstance(key)
"advanced_screen" -> SettingsAdvancedFragment.newInstance(key)
"about_screen" -> SettingsAboutFragment.newInstance(key)
"eh_screen" -> SettingsEhFragment.newInstance(key) //EH
else -> SettingsFragment.newInstance(key)
}
}

View File

@ -0,0 +1,52 @@
package eu.kanade.tachiyomi.ui.setting
import android.content.Intent
import android.os.Bundle
import android.support.v7.preference.XpPreferenceFragment
import android.view.View
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.plusAssign
import exh.ui.login.LoginActivity
import net.xpece.android.support.preference.SwitchPreference
import uy.kohesive.injekt.injectLazy
/**
* EH Settings fragment
*/
class SettingsEhFragment : SettingsFragment() {
companion object {
fun newInstance(rootKey: String): SettingsEhFragment {
val args = Bundle()
args.putString(XpPreferenceFragment.ARG_PREFERENCE_ROOT, rootKey)
return SettingsEhFragment().apply { arguments = args }
}
}
private val preferences: PreferencesHelper by injectLazy()
val enableExhentaiPref by lazy {
findPreference("enable_exhentai") as SwitchPreference
}
override fun onViewCreated(view: View, savedState: Bundle?) {
super.onViewCreated(view, savedState)
subscriptions += preferences
.enableExhentai()
.asObservable().subscribe {
enableExhentaiPref.isChecked = it
}
enableExhentaiPref.setOnPreferenceChangeListener { preference, newVal ->
newVal as Boolean
if(!newVal) {
preferences.enableExhentai().set(false)
true
} else {
startActivity(Intent(context, LoginActivity::class.java))
false
}
}
}
}

View File

@ -29,6 +29,7 @@ open class SettingsFragment : XpPreferenceFragment() {
addPreferencesFromResource(R.xml.pref_downloads)
addPreferencesFromResource(R.xml.pref_sources)
addPreferencesFromResource(R.xml.pref_tracking)
addPreferencesFromResource(R.xml.eh_pref_eh) //EH
addPreferencesFromResource(R.xml.pref_advanced)
addPreferencesFromResource(R.xml.pref_about)

View File

@ -0,0 +1,191 @@
package exh;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.app.AlertDialog;
import com.pushtorefresh.storio.sqlite.operations.put.PutResult;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import eu.kanade.tachiyomi.data.database.DatabaseHelper;
import eu.kanade.tachiyomi.data.database.models.Category;
import eu.kanade.tachiyomi.data.database.models.Manga;
import eu.kanade.tachiyomi.data.database.models.MangaCategory;
//import eu.kanade.tachiyomi.data.source.online.english.EHentai;
public class FavoritesSyncManager {
/*
Context context;
DatabaseHelper db;
public FavoritesSyncManager(Context context, DatabaseHelper db) {
this.context = context;
this.db = db;
}
public void guiSyncFavorites(final Runnable onComplete) {
if(!DialogLogin.isLoggedIn(context, false)) {
new AlertDialog.Builder(context).setTitle("Error")
.setMessage("You are not logged in! Please log in and try again!")
.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
}).show();
return;
}
final ProgressDialog dialog = ProgressDialog.show(context, "Downloading Favorites", "Please wait...", true, false);
new Thread(new Runnable() {
@Override
public void run() {
Handler mainLooper = new Handler(Looper.getMainLooper());
try {
syncFavorites();
} catch (Exception e) {
mainLooper.post(new Runnable() {
@Override
public void run() {
new AlertDialog.Builder(context)
.setTitle("Error")
.setMessage("There was an error downloading your favorites, please try again later!")
.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
}).show();
}
});
e.printStackTrace();
}
dialog.dismiss();
mainLooper.post(onComplete);
}
}).start();
}
public void syncFavorites() throws IOException {
EHentai.FavoritesResponse favResponse = EHentai.fetchFavorites(context);
Map<String, List<Manga>> favorites = favResponse.favs;
List<Category> ourCategories = new ArrayList<>(db.getCategories().executeAsBlocking());
List<Manga> ourMangas = new ArrayList<>(db.getMangas().executeAsBlocking());
//Add required categories (categories do not sync upwards)
List<Category> categoriesToInsert = new ArrayList<>();
for (String theirCategory : favorites.keySet()) {
boolean haveCategory = false;
for (Category category : ourCategories) {
if (category.getName().endsWith(theirCategory)) {
haveCategory = true;
}
}
if (!haveCategory) {
Category category = Category.Companion.create(theirCategory);
ourCategories.add(category);
categoriesToInsert.add(category);
}
}
if (!categoriesToInsert.isEmpty()) {
for(Map.Entry<Category, PutResult> result : db.insertCategories(categoriesToInsert).executeAsBlocking().results().entrySet()) {
if(result.getValue().wasInserted()) {
result.getKey().setId(result.getValue().insertedId().intValue());
}
}
}
//Build category map
Map<String, Category> categoryMap = new HashMap<>();
for (Category category : ourCategories) {
categoryMap.put(category.getName(), category);
}
//Insert new mangas
List<Manga> mangaToInsert = new ArrayList<>();
Map<Manga, Category> mangaToSetCategories = new HashMap<>();
for (Map.Entry<String, List<Manga>> entry : favorites.entrySet()) {
Category category = categoryMap.get(entry.getKey());
for (Manga manga : entry.getValue()) {
boolean alreadyHaveManga = false;
for (Manga ourManga : ourMangas) {
if (ourManga.getUrl().equals(manga.getUrl())) {
alreadyHaveManga = true;
manga = ourManga;
break;
}
}
if (!alreadyHaveManga) {
ourMangas.add(manga);
mangaToInsert.add(manga);
}
mangaToSetCategories.put(manga, category);
manga.setFavorite(true);
}
}
for (Map.Entry<Manga, PutResult> results : db.insertMangas(mangaToInsert).executeAsBlocking().results().entrySet()) {
if(results.getValue().wasInserted()) {
results.getKey().setId(results.getValue().insertedId());
}
}
for(Map.Entry<Manga, Category> entry : mangaToSetCategories.entrySet()) {
db.setMangaCategories(Collections.singletonList(MangaCategory.Companion.create(entry.getKey(), entry.getValue())),
Collections.singletonList(entry.getKey()));
}
//Determines what
/*Map<Integer, List<Manga>> toUpload = new HashMap<>();
for (Manga manga : ourMangas) {
if(manga.getFavorite()) {
boolean remoteHasManga = false;
for (List<Manga> remoteMangas : favorites.values()) {
for (Manga remoteManga : remoteMangas) {
if (remoteManga.getUrl().equals(manga.getUrl())) {
remoteHasManga = true;
break;
}
}
}
if (!remoteHasManga) {
List<Category> mangaCategories = db.getCategoriesForManga(manga).executeAsBlocking();
for (Category category : mangaCategories) {
int categoryIndex = favResponse.favCategories.indexOf(category.getName());
if (categoryIndex >= 0) {
List<Manga> uploadMangas = toUpload.get(categoryIndex);
if (uploadMangas == null) {
uploadMangas = new ArrayList<>();
toUpload.put(categoryIndex, uploadMangas);
}
uploadMangas.add(manga);
}
}
}
}
}*/
/********** NON-FUNCTIONAL, modifygids[] CANNOT ADD NEW FAVORITES! (or as of my testing it can't, maybe I'll do more testing)**/
/*PreferencesHelper helper = new PreferencesHelper(context);
for(Map.Entry<Integer, List<Manga>> entry : toUpload.entrySet()) {
FormBody.Builder formBody = new FormBody.Builder()
.add("ddact", "fav" + entry.getKey());
for(Manga manga : entry.getValue()) {
List<String> splitUrl = new ArrayList<>(Arrays.asList(manga.getUrl().split("/")));
splitUrl.removeAll(Collections.singleton(""));
if(splitUrl.size() < 2) {
continue;
}
formBody.add("modifygids[]", splitUrl.get(1).trim());
}
formBody.add("apply", "Apply");
Request request = RequestsKt.POST(EHentai.buildFavoritesBase(context, helper.getPrefs()).favoritesBase,
EHentai.getHeadersBuilder(helper).build(),
formBody.build(),
RequestsKt.getDEFAULT_CACHE_CONTROL());
Response response = NetworkManager.getInstance().getClient().newCall(request).execute();
Util.d("EHentai", response.body().string());
}*/
// }
}

View File

@ -0,0 +1,3 @@
package exh
operator fun StringBuilder.plusAssign(other: String) { append(other) }

View File

@ -0,0 +1,22 @@
package exh.metadata
import exh.metadata.models.ExGalleryMetadata
import io.paperdb.Paper
class MetadataHelper {
fun writeGallery(galleryMetadata: ExGalleryMetadata)
= exGalleryBook().write(galleryMetadata.galleryUniqueIdentifier(), galleryMetadata)
fun fetchMetadata(url: String, exh: Boolean) = ExGalleryMetadata().apply {
this.url = url
this.exh = exh
return exGalleryBook().read<ExGalleryMetadata>(galleryUniqueIdentifier())
}
fun getAllGalleries() = exGalleryBook().allKeys.map {
exGalleryBook().read<ExGalleryMetadata>(it)
}
fun exGalleryBook() = Paper.book("gallery-ex")!!
}

View File

@ -0,0 +1,47 @@
package exh.metadata
/**
* Metadata utils
*/
fun humanReadableByteCount(bytes: Long, si: Boolean): String {
val unit = if (si) 1000 else 1024
if (bytes < unit) return bytes.toString() + " B"
val exp = (Math.log(bytes.toDouble()) / Math.log(unit.toDouble())).toInt()
val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
return String.format("%.1f %sB", bytes / Math.pow(unit.toDouble(), exp.toDouble()), pre)
}
private val KB_FACTOR: Long = 1000
private val KIB_FACTOR: Long = 1024
private val MB_FACTOR = 1000 * KB_FACTOR
private val MIB_FACTOR = 1024 * KIB_FACTOR
private val GB_FACTOR = 1000 * MB_FACTOR
private val GIB_FACTOR = 1024 * MIB_FACTOR
fun parseHumanReadableByteCount(arg0: String): Double? {
val spaceNdx = arg0.indexOf(" ")
val ret = java.lang.Double.parseDouble(arg0.substring(0, spaceNdx))
when (arg0.substring(spaceNdx + 1)) {
"GB" -> return ret * GB_FACTOR
"GiB" -> return ret * GIB_FACTOR
"MB" -> return ret * MB_FACTOR
"MiB" -> return ret * MIB_FACTOR
"KB" -> return ret * KB_FACTOR
"KiB" -> return ret * KIB_FACTOR
}
return null
}
fun String?.nullIfBlank(): String? = if(isNullOrBlank())
null
else
this
fun <T> ignore(expr: () -> T): T? {
return try { expr() } catch (t: Throwable) { null }
}
fun <K,V> Set<Map.Entry<K,V>>.forEach(action: (K, V) -> Unit) {
forEach { action(it.key, it.value) }
}

View File

@ -0,0 +1,94 @@
package exh.metadata
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.UrlUtil
import exh.metadata.models.ExGalleryMetadata
import exh.metadata.models.Tag
import exh.plusAssign
import java.text.SimpleDateFormat
import java.util.*
/**
* Copies gallery metadata to a manga object
*/
private const val ARTIST_NAMESPACE = "artist"
private const val AUTHOR_NAMESPACE = "author"
private val ONGOING_SUFFIX = arrayOf(
"[ongoing]",
"(ongoing)",
"{ongoing}"
)
val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
fun ExGalleryMetadata.copyTo(manga: Manga) {
exh?.let {
manga.source = if(it)
2
else
1
}
url?.let { manga.url = it }
thumbnailUrl?.let { manga.thumbnail_url = it }
title?.let { manga.title = it }
//Set artist (if we can find one)
tags[ARTIST_NAMESPACE]?.let {
if(it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name)
}
//Set author (if we can find one)
tags[AUTHOR_NAMESPACE]?.let {
if(it.isNotEmpty()) manga.author = it.joinToString(transform = Tag::name)
}
//Set genre
genre?.let { manga.genre = it }
//Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
//We default to completed
manga.status = Manga.COMPLETED
title?.let { t ->
ONGOING_SUFFIX.find {
t.endsWith(it, ignoreCase = true)
}?.let {
manga.status = Manga.ONGOING
}
}
//Build a nice looking description out of what we know
val titleDesc = StringBuilder()
title?.let { titleDesc += "Title: $it\n" }
altTitle?.let { titleDesc += "Japanese Title: $it\n" }
val detailsDesc = StringBuilder()
uploader?.let { detailsDesc += "Uploader: $it\n" }
datePosted?.let { detailsDesc += "Posted: ${EX_DATE_FORMAT.format(Date(it))}\n" }
visible?.let { detailsDesc += "Visible: $it\n" }
language?.let {
detailsDesc += "Language: $it"
if(translated == true) detailsDesc += " TR"
detailsDesc += "\n"
}
size?.let { detailsDesc += "File Size: ${humanReadableByteCount(it, true)}\n" }
length?.let { detailsDesc += "Length: $it pages\n" }
favorites?.let { detailsDesc += "Favorited: $it times\n" }
averageRating?.let {
detailsDesc += "Rating: $it"
ratingCount?.let { detailsDesc += " ($it)" }
detailsDesc += "\n"
}
val tagsDesc = StringBuilder("Tags:\n")
//BiConsumer only available in Java 8, don't bother calling forEach directly on 'tags'
tags.entries.forEach { namespace, tags ->
if(tags.isNotEmpty()) {
val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
tagsDesc += "$namespace: $joinedTags\n"
}
}
manga.description = listOf(titleDesc, detailsDesc, tagsDesc)
.filter { it.isNotBlank() }
.joinToString(separator = "\n")
}

View File

@ -0,0 +1,52 @@
package exh.metadata.models
import android.net.Uri
import java.util.*
/**
* Gallery metadata storage model
*/
class ExGalleryMetadata {
var url: String? = null
var exh: Boolean? = null
var title: String? = null
var altTitle: String? = null
var thumbnailUrl: String? = null
var genre: String? = null
var uploader: String? = null
var datePosted: Long? = null
var parent: String? = null
var visible: String? = null
var language: String? = null
var translated: Boolean? = null
var size: Long? = null
var length: Int? = null
var favorites: Int? = null
var ratingCount: Int? = null
var averageRating: Double? = null
//Being specific about which classes are used in generics to make deserialization easier
var tags: HashMap<String, ArrayList<Tag>> = HashMap()
private fun splitGalleryUrl()
= url?.let {
Uri.parse(it).pathSegments.filterNot(String::isNullOrBlank)
}
fun galleryId() = splitGalleryUrl()?.let { it[it.size - 2] }
fun galleryToken() =
splitGalleryUrl()?.last()
fun galleryUniqueIdentifier() = exh?.let { exh ->
url?.let {
"${if(exh) "EXH" else "EX"}-${galleryId()}-${galleryToken()}"
}
}
}

View File

@ -0,0 +1,7 @@
package exh.metadata.models
/**
* Simple tag model
*/
data class Tag(var name: String, var light: Boolean)

View File

@ -0,0 +1,182 @@
package exh.ui.login
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import kotlinx.android.synthetic.main.eh_activity_login.*
import kotlinx.android.synthetic.main.toolbar.*
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.net.HttpCookie
/**
* LoginActivity
*/
class LoginActivity : BaseActivity() {
val preferenceManager: PreferencesHelper by injectLazy()
override fun onCreate(savedInstanceState: Bundle?) {
setAppTheme()
super.onCreate(savedInstanceState)
setContentView(R.layout.eh_activity_login)
setup()
setupToolbar(toolbar, backNavigation = false)
}
fun setup() {
btn_cancel.setOnClickListener { onBackPressed() }
btn_recheck.setOnClickListener { webview.loadUrl("http://exhentai.org/") }
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
CookieManager.getInstance().removeAllCookies {
runOnUiThread {
startWebview()
}
}
} else {
CookieManager.getInstance().removeAllCookie()
startWebview()
}
}
fun startWebview() {
webview.settings.javaScriptEnabled = true
webview.settings.domStorageEnabled = true
webview.loadUrl("https://forums.e-hentai.org/index.php?act=Login")
webview.setWebViewClient(object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
Timber.d(url)
val parsedUrl = Uri.parse(url)
if(parsedUrl.host.equals("forums.e-hentai.org", ignoreCase = true)) {
//Hide distracting content
view.loadUrl(HIDE_JS)
//Check login result
if(parsedUrl.getQueryParameter("code")?.toInt() != 0) {
if(checkLoginCookies(url)) view.loadUrl("http://exhentai.org/")
}
} else if(parsedUrl.host.equals("exhentai.org", ignoreCase = true)) {
//At ExHentai, check that everything worked out...
if(applyExHentaiCookies(url)) {
preferenceManager.enableExhentai().set(true)
onBackPressed()
}
}
}
})
}
/**
* Check if we are logged in
*/
fun checkLoginCookies(url: String): Boolean {
getCookies(url)?.let { parsed ->
return parsed.filter {
(it.name.equals(MEMBER_ID_COOKIE, ignoreCase = true)
|| it.name.equals(PASS_HASH_COOKIE, ignoreCase = true))
&& it.value.isNotBlank()
}.count() >= 2
}
return false
}
/**
* Parse cookies at ExHentai
*/
fun applyExHentaiCookies(url: String): Boolean {
getCookies(url)?.let { parsed ->
var memberId: String? = null
var passHash: String? = null
var igneous: String? = null
parsed.forEach {
when (it.name.toLowerCase()) {
MEMBER_ID_COOKIE -> memberId = it.value
PASS_HASH_COOKIE -> passHash = it.value
IGNEOUS_COOKIE -> igneous = it.value
}
}
//Missing a cookie
if (memberId == null || passHash == null || igneous == null) return false
//Update prefs
preferenceManager.memberIdVal().set(memberId)
preferenceManager.passHashVal().set(passHash)
preferenceManager.igneousVal().set(igneous)
return true
}
return false
}
fun getCookies(url: String): List<HttpCookie>?
= CookieManager.getInstance().getCookie(url)?.let {
it.split("; ").flatMap {
HttpCookie.parse(it)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> onBackPressed()
else -> return super.onOptionsItemSelected(item)
}
return true
}
companion object {
const val MEMBER_ID_COOKIE = "ipb_member_id"
const val PASS_HASH_COOKIE = "ipb_pass_hash"
const val IGNEOUS_COOKIE = "igneous"
const val HIDE_JS = """
javascript:(function () {
document.getElementsByTagName('body')[0].style.visibility = 'hidden';
document.getElementsByName('submit')[0].style.visibility = 'visible';
document.querySelector('td[width="60%"][valign="top"]').style.visibility = 'visible';
function hide(e) {if(e !== null && e !== undefined) e.style.display = 'none';}
hide(document.querySelector(".errorwrap"));
hide(document.querySelector('td[width="40%"][valign="top"]'));
var child = document.querySelector(".page").querySelector('div');
child.style.padding = null;
var ft = child.querySelectorAll('table');
var fd = child.parentNode.querySelectorAll('div > div');
var fh = document.querySelector('#border').querySelectorAll('td > table');
hide(ft[0]);
hide(ft[1]);
hide(fd[1]);
hide(fd[2]);
hide(child.querySelector('br'));
var error = document.querySelector(".page > div > .borderwrap");
if(error !== null) error.style.visibility = 'visible';
hide(fh[0]);
hide(fh[1]);
hide(document.querySelector("#gfooter"));
hide(document.querySelector(".copyright"));
document.querySelectorAll("td").forEach(function(e) {
e.style.color = "white";
});
var pc = document.querySelector(".postcolor");
if(pc !== null) pc.style.color = "#26353F";
})()
"""
}
}

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="8"
android:viewportHeight="7">
<!-- Crafted by hand, command by command -->
<group
android:translateX="0.8"
android:translateY="0.7"
android:scaleX="0.8"
android:scaleY="0.8">
<path
android:fillColor="#660611"
android:pathData="M 0 0 h 3 v 1 h -3 Z" />
<path
android:fillColor="#660611"
android:pathData="M 0 1 v 2 h 1 v -2 Z" />
<path
android:fillColor="#660611"
android:pathData="M 0 3 h 2.25 v 1 h -2.25 Z" />
<path
android:fillColor="#660611"
android:pathData="M 0 4 v 2 h 1 v -2 Z" />
<path
android:fillColor="#660611"
android:pathData="M 0 6 h 3 v 1 h -3 Z" />
<path
android:fillColor="#660611"
android:pathData="M 3 3 h 1 v 1 h -1" />
<path
android:fillColor="#660611"
android:pathData="M 4.75 0 h 1 v 7 h -1 Z" />
<path
android:fillColor="#660611"
android:pathData="M 5.75 3 h 1.25 v 1 h -1.25 Z" />
<path
android:fillColor="#660611"
android:pathData="M 7 0 h 1 v 7 h -1 Z" />
</group>
</vector>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<android.support.design.widget.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/toolbar"/>
</android.support.design.widget.AppBarLayout>
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="0dp"
app:layout_constraintBottom_toTopOf="@+id/linearLayout"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<LinearLayout
android:orientation="horizontal"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_width="0dp"
android:id="@+id/linearLayout">
<Button
android:text="@android:string/cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/btn_cancel"
android:layout_weight="1"
style="@style/Widget.AppCompat.Button.Borderless" />
<Button
android:text="Recheck"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/btn_recheck"
android:layout_weight="1"
style="@style/Widget.AppCompat.Button.Borderless" />
</LinearLayout>
</android.support.constraint.ConstraintLayout>
</LinearLayout>
</android.support.design.widget.CoordinatorLayout>

View File

@ -1,6 +1,4 @@
<resources>
<string name="app_name">Tachiyomi</string>
<string name="name">Nombre</string>
<!-- Activities and fragments labels (toolbar title) -->

View File

@ -1,6 +1,4 @@
<resources>
<string name="app_name">Tachiyomi</string>
<string name="name">Nome</string>
<!-- Activities and fragments labels (toolbar title) -->

View File

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tachiyomi</string>
<string name="name">Nome</string>
<!-- Activities and fragments labels (toolbar title) -->

View File

@ -1,5 +1,5 @@
<resources>
<string name="app_name">Tachiyomi</string>
<string name="app_name">TachiyomiEH</string>
<string name="name">Name</string>
@ -375,4 +375,7 @@
<string name="download_notifier_text_only_wifi">No wifi connection available</string>
<string name="download_notifier_no_network">No network connection available</string>
<!-- EH -->
<string name="label_login">Login</string>
<string name="pref_category_eh">E-Hentai</string>
</resources>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceScreen
android:icon="@drawable/eh_ic_ehlogo_red_24dp"
android:key="eh_screen"
android:persistent="false"
android:title="@string/pref_category_eh"
app:asp_tintEnabled="true">
<SwitchPreference
android:persistent="false"
android:title="Enable ExHentai"
android:summaryOff="Requires login"
android:key="enable_exhentai"
android:defaultValue="false" />
<SwitchPreference
android:dependency="enable_exhentai"
android:defaultValue="true"
android:key="secure_exh"
android:title="Secure ExHentai"
android:summary="Use the HTTPS version of ExHentai. Uncheck if ExHentai is not working." />
</PreferenceScreen>
</PreferenceScreen>

View File

@ -10,6 +10,9 @@ buildscript {
classpath 'com.github.ben-manes:gradle-versions-plugin:0.13.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
//Firebase (EH)
classpath 'com.google.gms:google-services:3.0.0'
}
}

View File

@ -15,4 +15,6 @@
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# org.gradle.parallel=true
android.enableBuildCache=true
kotlin.incremental=true