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:
parent
20b50323b9
commit
21f03c9454
|
@ -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 |
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class MangamoLoginDto(
|
||||
val accessToken: String,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class UserDto(
|
||||
val isSubscribed: Boolean? = null,
|
||||
)
|
Loading…
Reference in New Issue