diff --git a/src/en/mangamo/build.gradle b/src/en/mangamo/build.gradle new file mode 100644 index 000000000..b5dd1e62a --- /dev/null +++ b/src/en/mangamo/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Mangamo' + extClass = '.Mangamo' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/mangamo/res/mipmap-hdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..32b7bb7d3 Binary files /dev/null and b/src/en/mangamo/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/mangamo/res/mipmap-mdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..f1a25b88c Binary files /dev/null and b/src/en/mangamo/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/mangamo/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..2c6ae750b Binary files /dev/null and b/src/en/mangamo/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/mangamo/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..e1969bf78 Binary files /dev/null and b/src/en/mangamo/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/mangamo/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangamo/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..2a497942a Binary files /dev/null and b/src/en/mangamo/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/FirestoreRequestFactory.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/FirestoreRequestFactory.kt new file mode 100644 index 000000000..5c2546aec --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/FirestoreRequestFactory.kt @@ -0,0 +1,187 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class FirestoreRequestFactory( + private val helper: MangamoHelper, + private val auth: MangamoAuth, +) { + + open class DocumentQuery { + var fields = listOf() + } + + class CollectionQuery : DocumentQuery() { + + var filter: Filter? = null + var orderBy: List? = null + + // Firestore supports cursors, but this is simpler and probably good enough + var limit: Int? = null + var offset: Int? = null + + class OrderByTerm(private val field: String, private val direction: Direction) { + enum class Direction { ASCENDING, DESCENDING } + + fun toJsonString() = """{"direction":"$direction","field":{"fieldPath":"$field"}}""" + } + + fun ascending(field: String) = + OrderByTerm(field, OrderByTerm.Direction.ASCENDING) + fun descending(field: String) = + OrderByTerm(field, OrderByTerm.Direction.DESCENDING) + + sealed interface Filter { + fun toJsonString(): String + + class CompositeFilter(private val op: Operator, private val filters: List) : Filter { + enum class Operator { AND, OR } + + override fun toJsonString(): String = + """{"compositeFilter":{"op":"$op","filters":[${filters.joinToString { it.toJsonString() }}]}}""" + } + + class FieldFilter(private val fieldName: String, private val op: Operator, private val value: Any?) : Filter { + enum class Operator { + LESS_THAN, + LESS_THAN_OR_EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + EQUAL, + NOT_EQUAL, + ARRAY_CONTAINS, + IN, + ARRAY_CONTAINS_ANY, + NOT_IN, + } + + override fun toJsonString(): String { + val valueTerm = + when (value) { + null -> "{\"nullValue\":null}" + is Int -> "{\"integerValue\":$value}" + is Double -> "{\"doubleValue\":$value}" + is String -> "{\"stringValue\":\"$value\"}" + is Boolean -> "{\"booleanValue\":$value}" + else -> throw Exception("${value.javaClass} not supported in field filters") + } + return """{"fieldFilter":{"op":"$op","field":{"fieldPath":"$fieldName"},"value":$valueTerm}}""" + } + } + + class UnaryFilter(private val fieldName: String, private val op: Operator) : Filter { + enum class Operator { IS_NAN, IS_NULL, IS_NOT_NAN, IS_NOT_NULL } + + override fun toJsonString(): String { + return """{"unaryFilter":{"op":"$op","field":{"fieldPath":"$fieldName"}}}""" + } + } + } + + fun and(vararg filters: Filter) = + Filter.CompositeFilter(Filter.CompositeFilter.Operator.AND, filters.toList()) + fun or(vararg filters: Filter) = + Filter.CompositeFilter(Filter.CompositeFilter.Operator.OR, filters.toList()) + fun isLessThan(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.LESS_THAN, value) + fun isLessThanOrEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.LESS_THAN_OR_EQUAL, value) + fun isGreaterThan(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.GREATER_THAN, value) + fun isGreaterThanOrEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.GREATER_THAN_OR_EQUAL, value) + fun isEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.EQUAL, value) + fun isNotEqual(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.NOT_EQUAL, value) + fun contains(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.ARRAY_CONTAINS, value) + fun isIn(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.IN, value) + fun containsAny(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.ARRAY_CONTAINS_ANY, value) + fun isNotIn(fieldName: String, value: Any?) = + Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.NOT_IN, value) + fun isNaN(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NAN) + fun isNull(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NULL) + fun isNotNaN(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NOT_NAN) + fun isNotNull(fieldName: String) = + Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NOT_NULL) + } + + fun getDocument(path: String, query: DocumentQuery.() -> Unit = {}): Request { + val queryInfo = DocumentQuery() + query(queryInfo) + + val urlBuilder = "${MangamoConstants.FIRESTORE_API_BASE_PATH}/$path".toHttpUrl().newBuilder() + + for (field in queryInfo.fields) { + urlBuilder.addQueryParameter("mask.fieldPaths", field) + } + + val headers = Headers.Builder() + .add("Authorization", "Bearer ${auth.getIdToken()}") + .build() + + return GET(urlBuilder.build(), headers) + } + + private fun deconstructCollectionPath(path: String): Pair { + val pivot = path.lastIndexOf('/') + if (pivot == -1) { + return Pair("", path) + } + return Pair(path.substring(0, pivot), path.substring(pivot + 1)) + } + + fun getCollection( + fullPath: String, + query: CollectionQuery.() -> Unit = {}, + ): Request { + val queryInfo = CollectionQuery() + query(queryInfo) + + val structuredQuery = mutableMapOf() + + val (path, collectionId) = deconstructCollectionPath(fullPath) + + structuredQuery["from"] = "{\"collectionId\":\"$collectionId\"}" + + if (queryInfo.fields.isNotEmpty()) { + structuredQuery["select"] = "{\"fields\":[${queryInfo.fields.joinToString { + "{\"fieldPath\":\"$it\"}" + }}]}" + } + + if (queryInfo.filter != null) { + structuredQuery["where"] = queryInfo.filter!!.toJsonString() + } + + if (queryInfo.orderBy != null) { + structuredQuery["orderBy"] = "[${queryInfo.orderBy!!.joinToString { it.toJsonString() }}]" + } + + structuredQuery["offset"] = queryInfo.offset?.toString() + structuredQuery["limit"] = queryInfo.limit?.toString() + + val headers = helper.jsonHeaders.newBuilder() + .add("Authorization", "Bearer ${auth.getIdToken()}") + .build() + + val body = "{\"structuredQuery\":{${ + structuredQuery.entries + .filter { it.value != null } + .joinToString { "\"${it.key}\":${it.value}" } + }}}".toRequestBody() + + return POST("${MangamoConstants.FIRESTORE_API_BASE_PATH}/$path:runQuery", headers, body) + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/Mangamo.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/Mangamo.kt new file mode 100644 index 000000000..f0ed6dafd --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/Mangamo.kt @@ -0,0 +1,463 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.EditTextPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.extension.en.mangamo.MangamoHelper.Companion.parseJson +import eu.kanade.tachiyomi.extension.en.mangamo.dto.ChapterDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.PageDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.QueryResultDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.SeriesDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.UserDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.documents +import eu.kanade.tachiyomi.extension.en.mangamo.dto.elements +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 okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.IOException + +class Mangamo : ConfigurableSource, HttpSource() { + + override val name = "Mangamo" + + override val lang = "en" + + override val baseUrl = "https://mangamo.com" + + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val helper = MangamoHelper(headers) + + private var userToken = "" + get() { + if (field == "") { + field = preferences.getString(MangamoConstants.USER_TOKEN_PREF, "")!! + + if (field == "") { + field = MangamoAuth.createAnonymousUserToken(client) + preferences.edit() + .putString(MangamoConstants.USER_TOKEN_PREF, field) + .apply() + } + } + return field + } + + private val auth by cachedBy({ userToken }) { + MangamoAuth(helper, client, userToken) + } + + private val firestore by cachedBy({ auth }) { + FirestoreRequestFactory(helper, auth) + } + + private val user by cachedBy({ Pair(userToken, firestore) }) { + val response = client.newCall( + firestore.getDocument("Users/$userToken") { + fields = listOf(UserDto::isSubscribed.name) + }, + ).execute() + response.body.string().parseJson>().fields + } + + private val coinMangaPref + get() = preferences.getStringSet(MangamoConstants.HIDE_COIN_MANGA_PREF, setOf())!! + private val exclusivesOnlyPref + get() = preferences.getStringSet(MangamoConstants.EXCLUSIVES_ONLY_PREF, setOf())!! + + override val client: OkHttpClient = super.client.newBuilder() + .addNetworkInterceptor { + val request = it.request() + val response = it.proceed(request) + + if (request.url.toString().startsWith("${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/page")) { + if (response.code == 401) { + throw IOException("You don't have access to this chapter") + } + } + response + } + .addNetworkInterceptor { + val response = it.proceed(it.request()) + + // Add Cache-Control to Firestore queries + if (it.request().url.toString().startsWith(MangamoConstants.FIRESTORE_API_BASE_PATH)) { + return@addNetworkInterceptor response.newBuilder() + .header("Cache-Control", "public, max-age=${MangamoConstants.FIRESTORE_CACHE_LENGTH}") + .build() + } + response + } + .build() + + private val seriesRequiredFields = listOf( + SeriesDto::id.name, + SeriesDto::name.name, + SeriesDto::name_lowercase.name, + SeriesDto::description.name, + SeriesDto::authors.name, + SeriesDto::genres.name, + SeriesDto::ongoing.name, + SeriesDto::releaseStatusTag.name, + SeriesDto::titleArt.name, + ) + + private fun processSeries(dto: SeriesDto) = SManga.create().apply { + author = dto.authors?.joinToString { it.name } + description = dto.description + genre = dto.genres?.joinToString { it.name } + status = helper.getSeriesStatus(dto) + thumbnail_url = dto.titleArt + title = dto.name!! + url = helper.getSeriesUrl(dto) + initialized = true + } + + private fun parseMangaPage(response: Response, filterPredicate: (SeriesDto) -> Boolean = { true }): MangasPage { + val collection = response.body.string().parseJson>() + + val isDone = collection.documents.size < MangamoConstants.BROWSE_PAGE_SIZE + + val results = collection.elements.filter(filterPredicate) + + return MangasPage(results.map { processSeries(it) }, !isDone) + } + + // Popular manga + + override fun popularMangaRequest(page: Int): Request = firestore.getCollection("Series") { + limit = MangamoConstants.BROWSE_PAGE_SIZE + offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE + + val fields = seriesRequiredFields.toMutableList() + this.fields = fields + + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyTransactional.name + } + + val prefFilters = + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) { + isEqual(SeriesDto::onlyOnMangamo.name, true) + } else { + null + } + + filter = and( + *listOfNotNull( + isEqual(SeriesDto::enabled.name, true), + prefFilters, + ).toTypedArray(), + ) + } + + override fun popularMangaParse(response: Response): MangasPage = parseMangaPage(response) { + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + if (it.onlyTransactional == true) { + return@parseMangaPage false + } + } + true + } + + // Latest manga + + override fun latestUpdatesRequest(page: Int): Request = firestore.getCollection("Series") { + limit = MangamoConstants.BROWSE_PAGE_SIZE + offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE + + val fields = seriesRequiredFields.toMutableList() + this.fields = fields + + fields += SeriesDto::enabled.name + + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyTransactional.name + } + + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyOnMangamo.name + } + + orderBy = listOf(descending(SeriesDto::updatedAt.name)) + + // Filters can't be used with orderBy because firebase wants there to be indexes + // on various fields to support those queries and we can't create them. + // Therefore, all filtering has to be done on the client in the parse method. + } + + override fun latestUpdatesParse(response: Response): MangasPage = parseMangaPage(response) { + if (it.enabled != true) { + return@parseMangaPage false + } + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + if (it.onlyTransactional == true) { + return@parseMangaPage false + } + } + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) { + if (it.onlyOnMangamo != true) { + return@parseMangaPage false + } + } + true + } + + // Search manga + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = firestore.getCollection("Series") { + limit = MangamoConstants.BROWSE_PAGE_SIZE + offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE + + val fields = seriesRequiredFields.toMutableList() + this.fields = fields + + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) { + fields += SeriesDto::onlyTransactional.name + } + + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH)) { + fields += SeriesDto::onlyOnMangamo.name + } + + // Adding additional filters makes Firestore complain about wanting an index + // so we filter on the client in parse, just like for Latest. + + filter = and( + isEqual(SeriesDto::enabled.name, true), + isGreaterThanOrEqual(SeriesDto::name_lowercase.name, query.lowercase()), + isLessThanOrEqual(SeriesDto::name_lowercase.name, query.lowercase() + "\uf8ff"), + ) + } + + override fun searchMangaParse(response: Response): MangasPage = parseMangaPage(response) { + if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_SEARCH)) { + if (it.onlyTransactional == true) { + return@parseMangaPage false + } + } + if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH)) { + if (it.onlyOnMangamo != true) { + return@parseMangaPage false + } + } + true + } + + // Manga details + + override fun getMangaUrl(manga: SManga): String { + return baseUrl + manga.url + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val uri = getMangaUrl(manga).toHttpUrl() + + val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt() + + return firestore.getDocument("Series/$seriesId") { + fields = seriesRequiredFields + } + } + + override fun mangaDetailsParse(response: Response): SManga { + val dto = response.body.string().parseJson>().fields + return processSeries(dto) + } + + // Chapter list section + + override fun fetchChapterList(manga: SManga): Observable> { + val uri = getMangaUrl(manga).toHttpUrl() + + val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt() + + val seriesObservable = client.newCall( + firestore.getDocument("Series/$seriesId") { + fields = listOf( + SeriesDto::maxFreeChapterNumber.name, + SeriesDto::maxMeteredReadingChapterNumber.name, + SeriesDto::onlyTransactional.name, + ) + }, + ).asObservableSuccess().map { response -> + response.body.string().parseJson>().fields + } + + val chaptersObservable = client.newCall( + firestore.getCollection("Series/$seriesId/chapters") { + fields = listOf( + ChapterDto::enabled.name, + ChapterDto::id.name, + ChapterDto::seriesId.name, + ChapterDto::chapterNumber.name, + ChapterDto::name.name, + ChapterDto::createdAt.name, + ChapterDto::onlyTransactional.name, + ) + + orderBy = listOf(descending(ChapterDto::chapterNumber.name)) + }, + ).asObservableSuccess().map { response -> + response.body.string().parseJson>().elements + } + + val hideCoinChapters = coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_CHAPTERS) + + return Observable.combineLatest(seriesObservable, chaptersObservable) { series, chapters -> + chapters + .mapNotNull { chapter -> + if (chapter.enabled != true) { + return@mapNotNull null + } + + val isUserSubscribed = user.isSubscribed == true + + val isFreeChapter = chapter.chapterNumber!! <= (series.maxFreeChapterNumber ?: 0) + val isMeteredChapter = chapter.chapterNumber <= (series.maxMeteredReadingChapterNumber ?: 0) + val isCoinChapter = chapter.onlyTransactional == true || + (series.onlyTransactional == true && !isFreeChapter) + + if (hideCoinChapters && isCoinChapter) { + return@mapNotNull null + } + + SChapter.create().apply { + chapter_number = chapter.chapterNumber + date_upload = chapter.createdAt!! + name = chapter.name + + if (isCoinChapter) { + " \uD83E\uDE99" // coin emoji + } else if (isFreeChapter || isUserSubscribed) { + "" + } else if (isMeteredChapter) { + " \uD83D\uDD52" // three-o-clock emoji + } else { + // subscriber chapter + " \uD83D\uDD12" // lock emoji + } + url = helper.getChapterUrl(chapter) + } + } + } + } + + override fun chapterListParse(response: Response): List = + throw UnsupportedOperationException() + + private fun getPagesImagesRequest(series: Int, chapter: Int): Request { + return POST( + "${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/page/$series/$chapter", + helper.jsonHeaders, + "{\"idToken\":\"${auth.getIdToken()}\"}".toRequestBody(), + ) + } + + override fun pageListRequest(chapter: SChapter): Request { + val uri = (baseUrl + chapter.url).toHttpUrl() + + val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt() + val chapterId = uri.queryParameter(MangamoConstants.CHAPTER_QUERY_PARAM)!!.toInt() + + return getPagesImagesRequest(seriesId, chapterId) + } + + override fun pageListParse(response: Response): List { + val data = response.body.string().parseJson>() + + return data.map { + Page(it.pageNumber - 1, imageUrl = it.uri) + }.sortedBy { it.index } + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val userTokenPref = EditTextPreference(screen.context).apply { + key = MangamoConstants.USER_TOKEN_PREF + summary = "If you are a paying user, enter your user token to authenticate." + title = "User Token" + + dialogMessage = """ + Copy your token from the Mangamo app by going to My Manga > Profile icon (top right) > About and tapping on the "User" string at the bottom. + + Then replace the auto-generated token you see below with your personal token. + """.trimIndent() + + setDefaultValue("") + + setOnPreferenceChangeListener { _, newValue -> + userToken = newValue as String + true + } + } + + val hideCoinMangaPref = MultiSelectListPreference(screen.context).apply { + key = MangamoConstants.HIDE_COIN_MANGA_PREF + title = "Hide Coin Manga" + + summary = """ + Hide manga that require coins. + + For technical reasons, manga where a subscription only gives access to some chapters are not considered coin manga, even if coins are required to access all chapters. + """.trimIndent() + + entries = arrayOf( + "Hide in Popular/Latest", + "Hide in Search", + "Hide Coin Chapters", + ) + + entryValues = arrayOf( + MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE, + MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_SEARCH, + MangamoConstants.HIDE_COIN_MANGA_OPTION_CHAPTERS, + ) + + setDefaultValue(setOf()) + } + + val exclusivesOnly = MultiSelectListPreference(screen.context).apply { + key = MangamoConstants.EXCLUSIVES_ONLY_PREF + title = "Only Show Exclusives" + summary = "Only show Mangamo-exclusive manga." + + entries = arrayOf( + "In Popular/Latest", + "In Search", + ) + + entryValues = arrayOf( + MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE, + MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH, + ) + + setDefaultValue(setOf()) + } + + screen.addPreference(userTokenPref) + screen.addPreference(hideCoinMangaPref) + screen.addPreference(exclusivesOnly) + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoAuth.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoAuth.kt new file mode 100644 index 000000000..95036d8a5 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoAuth.kt @@ -0,0 +1,110 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import eu.kanade.tachiyomi.extension.en.mangamo.MangamoHelper.Companion.parseJson +import eu.kanade.tachiyomi.extension.en.mangamo.dto.FirebaseAuthDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.FirebaseRegisterDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.MangamoLoginDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.TokenRefreshDto +import eu.kanade.tachiyomi.network.POST +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.internal.EMPTY_HEADERS + +class MangamoAuth( + private val helper: MangamoHelper, + private val client: OkHttpClient, + private val userToken: String, +) { + + private lateinit var currentToken: String + private lateinit var refreshToken: String + private var expirationTime: Long = 0 + + fun getIdToken(): String { + synchronized(this) { + if (!this::currentToken.isInitialized) { + obtainInitialIdToken() + } + refreshIfNecessary() + return currentToken + } + } + + fun forceRefresh() { + obtainInitialIdToken() + } + + private fun expireIn(seconds: Long) { + expirationTime = System.currentTimeMillis() + (seconds - 1) * 1000 + } + + private fun obtainInitialIdToken() { + val mangamoLoginResponse = client.newCall( + POST( + "${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/v3/login", + helper.jsonHeaders, + "{\"purchaserInfo\":{\"originalAppUserId\":\"$userToken\"}}".toRequestBody(), + ), + ).execute() + + val customToken = mangamoLoginResponse.body.string().parseJson().accessToken + + val googleIdentityResponse = client.newCall( + POST( + "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${MangamoConstants.FIREBASE_API_KEY}", + EMPTY_HEADERS, + "{\"token\":\"$customToken\",\"returnSecureToken\":true}".toRequestBody(), + ), + ).execute() + + val tokenInfo = googleIdentityResponse.body.string().parseJson() + + currentToken = tokenInfo.idToken + refreshToken = tokenInfo.refreshToken + expireIn(tokenInfo.expiresIn) + } + + private fun refreshIfNecessary() { + if (System.currentTimeMillis() > expirationTime) { + val headers = Headers.Builder() + .add("Content-Type", "application/x-www-form-urlencoded") + .build() + + val refreshResponse = client.newCall( + POST( + "https://securetoken.googleapis.com/v1/token?key=${MangamoConstants.FIREBASE_API_KEY}", + headers, + "grant_type=refresh_token&refresh_token=$refreshToken".toRequestBody(), + ), + ).execute() + + if (refreshResponse.code == 200) { + val tokenInfo = refreshResponse.body.string().parseJson() + + currentToken = tokenInfo.idToken + refreshToken = tokenInfo.refreshToken + expireIn(tokenInfo.expiresIn) + } else { + // Refresh token may have expired + obtainInitialIdToken() + } + } + } + + companion object { + fun createAnonymousUserToken(client: OkHttpClient): String { + val googleIdentityResponse = client.newCall( + POST( + "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=${MangamoConstants.FIREBASE_API_KEY}", + EMPTY_HEADERS, + "{\"returnSecureToken\":true}".toRequestBody(), + ), + ).execute() + + val tokenInfo = googleIdentityResponse.body.string().parseJson() + + return tokenInfo.localId + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoConstants.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoConstants.kt new file mode 100644 index 000000000..2b638c87d --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoConstants.kt @@ -0,0 +1,24 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +object MangamoConstants { + const val USER_TOKEN_PREF = "userToken" + const val HIDE_COIN_MANGA_PREF = "hideCoinManga" + const val EXCLUSIVES_ONLY_PREF = "onlyShowExclusives" + + const val HIDE_COIN_MANGA_OPTION_IN_BROWSE = "inBrowse" + const val HIDE_COIN_MANGA_OPTION_IN_SEARCH = "inSearch" + const val HIDE_COIN_MANGA_OPTION_CHAPTERS = "chapters" + + const val EXCLUSIVES_ONLY_OPTION_IN_BROWSE = "inBrowse" + const val EXCLUSIVES_ONLY_OPTION_IN_SEARCH = "inSearch" + + const val FIREBASE_API_KEY = "AIzaSyCU00GBJ4BPSK5owyaXvHZIXwMJ5Rq5F8c" + const val FIREBASE_FUNCTION_BASE_PATH = "https://us-central1-mangamoapp1.cloudfunctions.net/api" + const val FIRESTORE_API_BASE_PATH = "https://firestore.googleapis.com/v1/projects/mangamoapp1/databases/(default)/documents" + const val FIRESTORE_CACHE_LENGTH = 600 + + const val SERIES_QUERY_PARAM = "series" + const val CHAPTER_QUERY_PARAM = "chapter" + + const val BROWSE_PAGE_SIZE = 50 +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoHelper.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoHelper.kt new file mode 100644 index 000000000..3c55d9f4e --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/MangamoHelper.kt @@ -0,0 +1,72 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import eu.kanade.tachiyomi.extension.en.mangamo.dto.ChapterDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDto +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDtoInternal +import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentSerializer +import eu.kanade.tachiyomi.extension.en.mangamo.dto.SeriesDto +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.KSerializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer +import okhttp3.Headers + +class MangamoHelper(headers: Headers) { + + companion object { + + val json = Json { + isLenient = true + ignoreUnknownKeys = true + explicitNulls = false + + serializersModule = SerializersModule { + contextual(DocumentDto::class) { DocumentSerializer(DocumentDtoInternal.serializer(it[0])) } + } + } + + @Suppress("UNCHECKED_CAST") + inline fun String.parseJson(): T { + return when (T::class) { + DocumentDto::class -> json.decodeFromString( + DocumentSerializer(serializer() as KSerializer>) as KSerializer, + this, + ) + else -> json.decodeFromString(this) + } + } + } + + val jsonHeaders = headers.newBuilder() + .set("Content-Type", "application/json") + .build() + + private fun getCatalogUrl(series: SeriesDto): String { + val lowercaseHyphenated = series.name_lowercase!!.replace(' ', '-') + return "/catalog/$lowercaseHyphenated" + } + + fun getSeriesUrl(series: SeriesDto): String { + return "${getCatalogUrl(series)}?${MangamoConstants.SERIES_QUERY_PARAM}=${series.id}" + } + + fun getChapterUrl(chapter: ChapterDto): String { + return "?${MangamoConstants.SERIES_QUERY_PARAM}=${chapter.seriesId}&${MangamoConstants.CHAPTER_QUERY_PARAM}=${chapter.id}" + } + + fun getSeriesStatus(series: SeriesDto): Int = + when (series.releaseStatusTag) { + "Ongoing" -> SManga.ONGOING + "series-complete" -> SManga.COMPLETED + "Completed" -> SManga.COMPLETED + "Paused" -> SManga.ON_HIATUS + else -> + if (series.ongoing == true) { + SManga.ONGOING + } else { + SManga.UNKNOWN + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/cachedBy.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/cachedBy.kt new file mode 100644 index 000000000..5231b5f02 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/cachedBy.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.extension.en.mangamo + +import kotlin.reflect.KProperty + +@Suppress("ClassName") +class cachedBy(private val dependencies: () -> Any?, private val callback: () -> T) { + private object UNINITIALIZED + private var cachedValue: Any? = UNINITIALIZED + private var lastDeps: Any? = UNINITIALIZED + + @Suppress("UNCHECKED_CAST") + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + synchronized(this) { + val newDeps = dependencies() + if (newDeps != lastDeps) { + lastDeps = newDeps + cachedValue = callback() + } + return cachedValue as T + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/ChapterDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/ChapterDto.kt new file mode 100644 index 000000000..4c4397ae8 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/ChapterDto.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class ChapterDto( + val id: Int? = null, + val chapterNumber: Float? = null, + val createdAt: Long? = null, + val enabled: Boolean? = null, + val name: String? = null, + val onlyTransactional: Boolean? = null, + val seriesId: Int? = null, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/DocumentDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/DocumentDto.kt new file mode 100644 index 000000000..b6fe8d1e0 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/DocumentDto.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +@Serializable +class DocumentWrapper(val document: DocumentDto?) + +typealias QueryResultDto = @Serializable List> + +val QueryResultDto.documents + get() = mapNotNull { it.document } + +val QueryResultDto.elements + get() = mapNotNull { it.document?.fields } + +typealias DocumentDto = @Contextual DocumentDtoInternal + +@Serializable +class DocumentDtoInternal( + val fields: T, +) + +class DocumentSerializer(dataSerializer: KSerializer>) : + JsonTransformingSerializer>(dataSerializer as KSerializer>) { + + override fun transformDeserialize(element: JsonElement): JsonElement { + val objMap = element.jsonObject.toMap(HashMap()) + + if (objMap.containsKey("fields")) { + objMap["fields"] = reduceFieldsObject(objMap["fields"]!!) + } else { + objMap["fields"] = JsonObject(mapOf()) + } + + return JsonObject(objMap) + } + + private fun reduceFieldsObject(fields: JsonElement): JsonElement { + return JsonObject(fields.jsonObject.mapValues { reduceField(it.value) }) + } + + private fun reduceField(element: JsonElement): JsonElement { + val valueContainer = element.jsonObject.entries.first() + + return when (valueContainer.key) { + "arrayValue" -> valueContainer.value.jsonObject["values"]?.jsonArray + ?.map { reduceField(it) } + .let { JsonArray(it ?: listOf()) } + "mapValue" -> reduceFieldsObject(valueContainer.value.jsonObject["fields"]!!) + else -> valueContainer.value + } + } +} diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/FirebaseAuthDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/FirebaseAuthDto.kt new file mode 100644 index 000000000..36c113580 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/FirebaseAuthDto.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class FirebaseAuthDto( + val idToken: String, + val refreshToken: String, + val expiresIn: Long, +) + +@Serializable +class FirebaseRegisterDto( + val localId: String, + val idToken: String, + val refreshToken: String, + val expiresIn: Long, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/MangamoLoginDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/MangamoLoginDto.kt new file mode 100644 index 000000000..c67545ba6 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/MangamoLoginDto.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class MangamoLoginDto( + val accessToken: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/PageDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/PageDto.kt new file mode 100644 index 000000000..415589d13 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/PageDto.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class PageDto( + val id: Int, + val pageNumber: Int, + val uri: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/SeriesDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/SeriesDto.kt new file mode 100644 index 000000000..301eaf89a --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/SeriesDto.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class SeriesDto( + val id: Int? = null, + val authors: List? = null, + val description: String? = null, + val enabled: Boolean? = null, + val genres: List? = null, + val maxFreeChapterNumber: Int? = null, + val maxMeteredReadingChapterNumber: Int? = null, + val name: String? = null, + @Suppress("PropertyName") + val name_lowercase: String? = null, + val ongoing: Boolean? = null, + val onlyOnMangamo: Boolean? = null, + val onlyTransactional: Boolean? = null, + val releaseStatusTag: String? = null, + val titleArt: String? = null, + val updatedAt: Long? = null, +) + +@Serializable +class AuthorDto( + val id: Int, + val name: String, +) + +@Serializable +class GenreDto( + val id: Int, + val name: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/TokenRefreshDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/TokenRefreshDto.kt new file mode 100644 index 000000000..c9d63af62 --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/TokenRefreshDto.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class TokenRefreshDto( + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("id_token") + val idToken: String, + @SerialName("refresh_token") + val refreshToken: String, +) diff --git a/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/UserDto.kt b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/UserDto.kt new file mode 100644 index 000000000..c050b00ab --- /dev/null +++ b/src/en/mangamo/src/eu/kanade/tachiyomi/extension/en/mangamo/dto/UserDto.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.extension.en.mangamo.dto + +import kotlinx.serialization.Serializable + +@Serializable +class UserDto( + val isSubscribed: Boolean? = null, +)