MangaDex source
This commit is contained in:
@@ -59,13 +59,14 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
freeCompilerArgs += [
|
||||
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
|
||||
]
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||
@@ -85,9 +86,9 @@ dependencies {
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0'
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.3.0'
|
||||
implementation 'androidx.room:room-ktx:2.3.0'
|
||||
kapt 'androidx.room:room-compiler:2.3.0'
|
||||
implementation 'androidx.room:room-runtime:2.4.0'
|
||||
implementation 'androidx.room:room-ktx:2.4.0'
|
||||
kapt 'androidx.room:room-compiler:2.4.0'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||
implementation 'com.squareup.okio:okio:2.10.0'
|
||||
@@ -105,14 +106,14 @@ dependencies {
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'com.google.truth:truth:1.1.3'
|
||||
testImplementation 'org.json:json:20210307'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
|
||||
testImplementation 'org.json:json:20211205'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.4'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.3.0'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.4.0'
|
||||
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
||||
}
|
||||
@@ -40,7 +40,8 @@ enum class MangaSource(
|
||||
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
|
||||
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
|
||||
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java),
|
||||
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java)
|
||||
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java),
|
||||
MANGADEX("MangaDex", null, MangaDexRepository::class.java),
|
||||
;
|
||||
|
||||
@get:Throws(NoBeanDefFoundException::class)
|
||||
|
||||
@@ -34,4 +34,5 @@ val parserModule
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import android.os.Build
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
private const val CONTENT_RATING =
|
||||
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
||||
private const val LOCALE_FALLBACK = "en"
|
||||
|
||||
class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
override val source = MangaSource.MANGADEX
|
||||
override val defaultDomain = "mangadex.org"
|
||||
|
||||
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.ALPHABETICAL,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.POPULARITY,
|
||||
)
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val url = buildString {
|
||||
append("https://api.")
|
||||
append(domain)
|
||||
append("/manga?limit=")
|
||||
append(PAGE_SIZE)
|
||||
append("&offset=")
|
||||
append(offset)
|
||||
append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
|
||||
tags?.forEach { tag ->
|
||||
append("includedTags[]=")
|
||||
append(tag.key)
|
||||
append('&')
|
||||
}
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append("title=")
|
||||
append(query.urlEncoded())
|
||||
append('&')
|
||||
}
|
||||
append(CONTENT_RATING)
|
||||
append("&order")
|
||||
append(when (sortOrder) {
|
||||
null,
|
||||
SortOrder.UPDATED,
|
||||
-> "[latestUploadedChapter]=desc"
|
||||
SortOrder.ALPHABETICAL -> "[title]=asc"
|
||||
SortOrder.NEWEST -> "[createdAt]=desc"
|
||||
SortOrder.POPULARITY -> "[followedCount]=desc"
|
||||
else -> "[followedCount]=desc"
|
||||
})
|
||||
}
|
||||
val json = loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||
return json.map { jo ->
|
||||
val id = jo.getString("id")
|
||||
val attrs = jo.getJSONObject("attributes")
|
||||
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||
val cover = relations["cover_art"]
|
||||
?.getJSONObject("attributes")
|
||||
?.getString("fileName")
|
||||
?.let {
|
||||
"https://uploads.$domain/covers/$id/$it"
|
||||
}
|
||||
Manga(
|
||||
id = generateUid(id),
|
||||
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
|
||||
"Title should not be null"
|
||||
},
|
||||
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
|
||||
url = id,
|
||||
publicUrl = "https://$domain/title/$id",
|
||||
rating = Manga.NO_RATING,
|
||||
isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
|
||||
coverUrl = cover?.plus(".256.jpg").orEmpty(),
|
||||
largeCoverUrl = cover,
|
||||
description = attrs.optJSONObject("description")?.selectByLocale(),
|
||||
tags = attrs.getJSONArray("tags").mapToSet { tag ->
|
||||
MangaTag(
|
||||
title = tag.getJSONObject("attributes")
|
||||
.getJSONObject("name")
|
||||
.firstStringValue(),
|
||||
key = tag.getString("id"),
|
||||
source = source,
|
||||
)
|
||||
},
|
||||
state = when (jo.getStringOrNull("status")) {
|
||||
"ongoing" -> MangaState.ONGOING
|
||||
"completed" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
author = (relations["author"] ?: relations["artist"])
|
||||
?.getJSONObject("attributes")
|
||||
?.getStringOrNull("name"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope<Manga> {
|
||||
val domain = getDomain()
|
||||
val attrsDeferred = async {
|
||||
loaderContext.httpGet(
|
||||
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art"
|
||||
).parseJson().getJSONObject("data").getJSONObject("attributes")
|
||||
}
|
||||
val feedDeferred = async {
|
||||
val url = buildString {
|
||||
append("https://api.")
|
||||
append(domain)
|
||||
append("/manga/")
|
||||
append(manga.url)
|
||||
append("/feed")
|
||||
append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&")
|
||||
append(CONTENT_RATING)
|
||||
}
|
||||
loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||
}
|
||||
val mangaAttrs = attrsDeferred.await()
|
||||
val feed = feedDeferred.await()
|
||||
//2022-01-02T00:27:11+00:00
|
||||
val dateFormat = SimpleDateFormat(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
"yyyy-MM-dd'T'HH:mm:ssX"
|
||||
} else {
|
||||
"yyyy-MM-dd'T'HH:mm:ss'+00:00'"
|
||||
},
|
||||
Locale.ROOT
|
||||
)
|
||||
manga.copy(
|
||||
description = mangaAttrs.getJSONObject("description").selectByLocale()
|
||||
?: manga.description,
|
||||
chapters = feed.mapNotNull { jo ->
|
||||
val id = jo.getString("id")
|
||||
val attrs = jo.getJSONObject("attributes")
|
||||
if (attrs.optJSONArray("data").isNullOrEmpty()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
|
||||
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||
val number = attrs.optInt("chapter", 0)
|
||||
MangaChapter(
|
||||
id = generateUid(id),
|
||||
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
|
||||
?: "Chapter #$number",
|
||||
number = number,
|
||||
url = id,
|
||||
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
|
||||
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
|
||||
branch = locale.displayName.toTitleCase(locale),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val domain = getDomain()
|
||||
val attrs = loaderContext.httpGet("https://api.$domain/chapter/${chapter.url}")
|
||||
.parseJson()
|
||||
.getJSONObject("data")
|
||||
.getJSONObject("attributes")
|
||||
val data = attrs.getJSONArray("data")
|
||||
val prefix = "https://uploads.$domain/data/${attrs.getString("hash")}/"
|
||||
val referer = "https://$domain/"
|
||||
return List(data.length()) { i ->
|
||||
val url = prefix + data.getString(i)
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
referer = referer,
|
||||
preview = null, // TODO prefix + dataSaver.getString(i),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val tags = loaderContext.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
|
||||
.getJSONArray("data")
|
||||
return tags.mapToSet { jo ->
|
||||
MangaTag(
|
||||
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(),
|
||||
key = jo.getString("id"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.firstStringValue() = values().next() as String
|
||||
|
||||
private fun JSONObject.selectByLocale(): String? {
|
||||
val preferredLocales = LocaleListCompat.getAdjustedDefault()
|
||||
repeat(preferredLocales.size()) { i ->
|
||||
val locale = preferredLocales.get(i)
|
||||
getStringOrNull(locale.language)?.let { return it }
|
||||
getStringOrNull(locale.toLanguageTag())?.let { return it }
|
||||
}
|
||||
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import androidx.collection.arraySetOf
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.sendBlocking
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -143,7 +143,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
fun observe() = callbackFlow<String> {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
sendBlocking(key)
|
||||
trySendBlocking(key)
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
|
||||
@@ -24,7 +24,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import org.koitharu.kotatsu.utils.ext.toTitleCase
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
class DetailsViewModel(
|
||||
intent: MangaIntent,
|
||||
@@ -127,9 +129,7 @@ class DetailsViewModel(
|
||||
selectedBranch.value = if (hist != null) {
|
||||
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
||||
} else {
|
||||
manga.chapters
|
||||
?.groupBy { it.branch }
|
||||
?.maxByOrNull { it.value.size }?.key
|
||||
predictBranch(manga.chapters)
|
||||
}
|
||||
mangaData.value = manga
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
@@ -240,4 +240,21 @@ class DetailsViewModel(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun predictBranch(chapters: List<MangaChapter>?): String? {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val groups = chapters.groupBy { it.branch }
|
||||
val locale = Locale.getDefault()
|
||||
var language = locale.displayLanguage.toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
language = locale.displayName.toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
return groups.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.provider.Settings
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.sendBlocking
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -38,7 +38,7 @@ class ScreenOrientationHelper(private val activity: Activity) {
|
||||
fun observeAutoOrientation() = callbackFlow<Boolean> {
|
||||
val observer = object : ContentObserver(Handler(activity.mainLooper)) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
sendBlocking(isAutoRotationEnabled)
|
||||
trySendBlocking(isAutoRotationEnabled)
|
||||
}
|
||||
}
|
||||
activity.contentResolver.registerContentObserver(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
fun <T> Iterator<T>.nextOrNull(): T? = if (hasNext()) next() else null
|
||||
|
||||
fun <T> Iterator<T>.toList(): List<T> {
|
||||
if (!hasNext()) {
|
||||
return emptyList()
|
||||
}
|
||||
val list = ArrayList<T>()
|
||||
while (hasNext()) list += next()
|
||||
return list
|
||||
}
|
||||
|
||||
fun <T> Iterator<T>.toSet(): Set<T> {
|
||||
if (!hasNext()) {
|
||||
return emptySet()
|
||||
}
|
||||
val list = LinkedHashSet<T>()
|
||||
while (hasNext()) list += next()
|
||||
return list
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package org.koitharu.kotatsu.utils.ext
|
||||
import androidx.collection.ArraySet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.utils.json.JSONIterator
|
||||
import org.koitharu.kotatsu.utils.json.JSONStringIterator
|
||||
import org.koitharu.kotatsu.utils.json.JSONValuesIterator
|
||||
import kotlin.contracts.contract
|
||||
|
||||
inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
|
||||
destination: C,
|
||||
@@ -16,10 +20,26 @@ inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
|
||||
return destination
|
||||
}
|
||||
|
||||
inline fun <R, C : MutableCollection<in R>> JSONArray.mapNotNullTo(
|
||||
destination: C,
|
||||
block: (JSONObject) -> R?
|
||||
): C {
|
||||
val len = length()
|
||||
for (i in 0 until len) {
|
||||
val jo = getJSONObject(i)
|
||||
destination.add(block(jo) ?: continue)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
inline fun <T> JSONArray.map(block: (JSONObject) -> T): List<T> {
|
||||
return mapTo(ArrayList(length()), block)
|
||||
}
|
||||
|
||||
inline fun <T> JSONArray.mapNotNull(block: (JSONObject) -> T?): List<T> {
|
||||
return mapNotNullTo(ArrayList(length()), block)
|
||||
}
|
||||
|
||||
fun <T> JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List<T> {
|
||||
val len = length()
|
||||
val result = ArrayList<T>(len)
|
||||
@@ -46,26 +66,6 @@ operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
|
||||
|
||||
fun JSONArray.stringIterator(): Iterator<String> = JSONStringIterator(this)
|
||||
|
||||
private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
|
||||
|
||||
private val total = array.length()
|
||||
private var index = 0
|
||||
|
||||
override fun hasNext() = index < total - 1
|
||||
|
||||
override fun next(): JSONObject = array.getJSONObject(index++)
|
||||
}
|
||||
|
||||
private class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
|
||||
|
||||
private val total = array.length()
|
||||
private var index = 0
|
||||
|
||||
override fun hasNext() = index < total - 1
|
||||
|
||||
override fun next(): String = array.getString(index++)
|
||||
}
|
||||
|
||||
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
|
||||
val len = length()
|
||||
val result = ArraySet<T>(len)
|
||||
@@ -74,4 +74,24 @@ fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
|
||||
result.add(block(jo))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun JSONObject.values(): Iterator<Any> = JSONValuesIterator(this)
|
||||
|
||||
fun JSONArray.associateByKey(key: String): Map<String, JSONObject> {
|
||||
val destination = LinkedHashMap<String, JSONObject>(length())
|
||||
repeat(length()) { i ->
|
||||
val item = getJSONObject(i)
|
||||
val keyValue = item.getString(key)
|
||||
destination[keyValue] = item
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
fun JSONArray?.isNullOrEmpty(): Boolean {
|
||||
contract {
|
||||
returns(false) implies (this@isNullOrEmpty != null)
|
||||
}
|
||||
|
||||
return this == null || this.length() == 0
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.utils.json
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
|
||||
|
||||
private val total = array.length()
|
||||
private var index = 0
|
||||
|
||||
override fun hasNext() = index < total - 1
|
||||
|
||||
override fun next(): JSONObject = array.getJSONObject(index++)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.utils.json
|
||||
|
||||
import org.json.JSONArray
|
||||
|
||||
class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
|
||||
|
||||
private val total = array.length()
|
||||
private var index = 0
|
||||
|
||||
override fun hasNext() = index < total - 1
|
||||
|
||||
override fun next(): String = array.getString(index++)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.utils.json
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
class JSONValuesIterator(
|
||||
private val jo: JSONObject,
|
||||
): Iterator<Any> {
|
||||
|
||||
private val keyIterator = jo.keys()
|
||||
|
||||
override fun hasNext(): Boolean = keyIterator.hasNext()
|
||||
|
||||
override fun next(): Any {
|
||||
val key = keyIterator.next()
|
||||
return jo.get(key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user