Add Mangamo (#2862)

* Add Mangamo

* Mangamo: Remove excess fields and use assertions

* Mangamo: improve Firestore queries

* Mangamo: synchronize auth

* Mangamo: fix serialization bug when no fields are returned from query

* Mangamo: exclude disabled manga from latest and improve chapter update query

* Mangamo: clean up DTO objects

* Mangamo: add custom 401 messaging, use emoji for payment indication

* Mangamo: make manga/chapter URLs relative
This commit is contained in:
Trevor Paley 2024-05-08 09:16:13 -07:00 committed by Draff
parent 20b50323b9
commit 21f03c9454
20 changed files with 1054 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'Mangamo'
extClass = '.Mangamo'
extVersionCode = 1
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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<String>()
}
class CollectionQuery : DocumentQuery() {
var filter: Filter? = null
var orderBy: List<OrderByTerm>? = 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>) : 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<String, String> {
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<String, String?>()
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)
}
}

View File

@ -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<Application>().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<DocumentDto<UserDto>>().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<QueryResultDto<SeriesDto>>()
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<DocumentDto<SeriesDto>>().fields
return processSeries(dto)
}
// Chapter list section
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
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<DocumentDto<SeriesDto>>().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<QueryResultDto<ChapterDto>>().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<SChapter> =
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<Page> {
val data = response.body.string().parseJson<List<PageDto>>()
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<String>())
}
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<String>())
}
screen.addPreference(userTokenPref)
screen.addPreference(hideCoinMangaPref)
screen.addPreference(exclusivesOnly)
}
}

View File

@ -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<MangamoLoginDto>().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<FirebaseAuthDto>()
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<TokenRefreshDto>()
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<FirebaseRegisterDto>()
return tokenInfo.localId
}
}
}

View File

@ -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
}

View File

@ -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 <reified T> String.parseJson(): T {
return when (T::class) {
DocumentDto::class -> json.decodeFromString<T>(
DocumentSerializer(serializer<T>() as KSerializer<out DocumentDto<out Any?>>) as KSerializer<T>,
this,
)
else -> json.decodeFromString<T>(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
}
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.extension.en.mangamo
import kotlin.reflect.KProperty
@Suppress("ClassName")
class cachedBy<T>(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
}
}
}

View File

@ -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,
)

View File

@ -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<T>(val document: DocumentDto<T>?)
typealias QueryResultDto<T> = @Serializable List<DocumentWrapper<T>>
val <T>QueryResultDto<T>.documents
get() = mapNotNull { it.document }
val <T>QueryResultDto<T>.elements
get() = mapNotNull { it.document?.fields }
typealias DocumentDto<T> = @Contextual DocumentDtoInternal<T>
@Serializable
class DocumentDtoInternal<T>(
val fields: T,
)
class DocumentSerializer(dataSerializer: KSerializer<out DocumentDto<out Any?>>) :
JsonTransformingSerializer<DocumentDto<Any>>(dataSerializer as KSerializer<DocumentDto<Any>>) {
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
}
}
}

View File

@ -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,
)

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.extension.en.mangamo.dto
import kotlinx.serialization.Serializable
@Serializable
class MangamoLoginDto(
val accessToken: String,
)

View File

@ -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,
)

View File

@ -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<AuthorDto>? = null,
val description: String? = null,
val enabled: Boolean? = null,
val genres: List<GenreDto>? = 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,
)

View File

@ -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,
)

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.extension.en.mangamo.dto
import kotlinx.serialization.Serializable
@Serializable
class UserDto(
val isSubscribed: Boolean? = null,
)