mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 10:44:42 +00:00
More work for ext 1.5
Updated the extension loader for support of it to match main (save for the private extension stuff, that's later) * Rename new method in ConfigurableSource to get preferences (afb1ee2
) *Add more replacement suspend functions for source APIs (26c5d76
) Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com>
This commit is contained in:
parent
7fcd744c98
commit
d0b0e7c66c
34 changed files with 956 additions and 611 deletions
|
@ -296,21 +296,40 @@ dependencies {
|
|||
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
|
||||
}
|
||||
|
||||
|
||||
|
||||
tasks {
|
||||
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers)
|
||||
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
kotlinOptions.freeCompilerArgs += listOf(
|
||||
"-opt-in=kotlin.Experimental",
|
||||
"-opt-in=kotlin.RequiresOptIn",
|
||||
"-opt-in=kotlin.ExperimentalStdlibApi",
|
||||
"-Xcontext-receivers",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material.ExperimentalMaterialApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material.ExperimentalMaterialApi",
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
|
||||
"-opt-in=coil.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=coil.annotation.ExperimentalCoilApi",
|
||||
)
|
||||
|
||||
if (project.findProperty("tachiyomi.enableComposeCompilerMetrics") == "true") {
|
||||
kotlinOptions.freeCompilerArgs += listOf(
|
||||
"-P",
|
||||
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
|
||||
project.buildDir.absolutePath + "/compose_metrics",
|
||||
)
|
||||
kotlinOptions.freeCompilerArgs += listOf(
|
||||
"-P",
|
||||
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
|
||||
project.buildDir.absolutePath + "/compose_metrics",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicating Hebrew string assets due to some locale code issues on different devices
|
||||
|
|
|
@ -27,7 +27,6 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
|
|||
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
|
||||
import eu.kanade.tachiyomi.util.storage.saveTo
|
||||
import eu.kanade.tachiyomi.util.system.ImageUtil
|
||||
import eu.kanade.tachiyomi.util.system.awaitSingle
|
||||
import eu.kanade.tachiyomi.util.system.launchIO
|
||||
import eu.kanade.tachiyomi.util.system.launchNow
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
|
@ -388,7 +387,7 @@ class Downloader(
|
|||
pageList.filter { it.imageUrl.isNullOrEmpty() }.forEach { page ->
|
||||
page.status = Page.State.LOAD_PAGE
|
||||
try {
|
||||
page.imageUrl = download.source.fetchImageUrl(page).awaitSingle()
|
||||
page.imageUrl = download.source.getImageUrl(page)
|
||||
} catch (e: Throwable) {
|
||||
page.status = Page.State.ERROR
|
||||
}
|
||||
|
|
|
@ -25,11 +25,14 @@ import kotlinx.serialization.json.put
|
|||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Calendar
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val authClient = client.newBuilder()
|
||||
.addInterceptor(interceptor)
|
||||
.rateLimit(permits = 85, period = 1, unit = TimeUnit.MINUTES)
|
||||
|
@ -51,14 +54,16 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
}
|
||||
}
|
||||
}
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
track.library_id =
|
||||
it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||
track
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
track.library_id =
|
||||
it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,21 +84,23 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
}
|
||||
}
|
||||
}
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val media = response["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject
|
||||
val startedDate = parseDate(media, "startedAt")
|
||||
if (track.started_reading_date <= 0L || startedDate > 0) {
|
||||
track.started_reading_date = startedDate
|
||||
with(json) {
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val media = response["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject
|
||||
val startedDate = parseDate(media, "startedAt")
|
||||
if (track.started_reading_date <= 0L || startedDate > 0) {
|
||||
track.started_reading_date = startedDate
|
||||
}
|
||||
val finishedDate = parseDate(media, "completedAt")
|
||||
if (track.finished_reading_date <= 0L || finishedDate > 0) {
|
||||
track.finished_reading_date = finishedDate
|
||||
}
|
||||
track
|
||||
}
|
||||
val finishedDate = parseDate(media, "completedAt")
|
||||
if (track.finished_reading_date <= 0L || finishedDate > 0) {
|
||||
track.finished_reading_date = finishedDate
|
||||
}
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -105,16 +112,18 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
put("query", search)
|
||||
}
|
||||
}
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["media"]!!.jsonArray
|
||||
val entries = media.map { jsonToALManga(it.jsonObject) }
|
||||
entries.map { it.toTrack() }
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["media"]!!.jsonArray
|
||||
val entries = media.map { jsonToALManga(it.jsonObject) }
|
||||
entries.map { it.toTrack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,16 +136,18 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
put("manga_id", track.media_id)
|
||||
}
|
||||
}
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["mediaList"]!!.jsonArray
|
||||
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
||||
entries.firstOrNull()?.toTrack()
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["mediaList"]!!.jsonArray
|
||||
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
||||
entries.firstOrNull()?.toTrack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -168,22 +179,24 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
val payload = buildJsonObject {
|
||||
put("query", currentUserQuery())
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
apiUrl,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonObject
|
||||
val viewer = data["Viewer"]!!.jsonObject
|
||||
Pair(
|
||||
viewer["id"]!!.jsonPrimitive.int,
|
||||
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
apiUrl,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonObject
|
||||
val viewer = data["Viewer"]!!.jsonObject
|
||||
Pair(
|
||||
viewer["id"]!!.jsonPrimitive.int,
|
||||
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -118,10 +118,12 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
|
|||
|
||||
suspend fun findLibManga(track: Track): Track? {
|
||||
return withIOContext {
|
||||
authClient.newCall(GET("$apiUrl/subject/${track.media_id}"))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { jsonToSearch(it) }
|
||||
with(json) {
|
||||
authClient.newCall(GET("$apiUrl/subject/${track.media_id}"))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { jsonToSearch(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -155,9 +157,11 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
|
|||
|
||||
suspend fun accessToken(code: String): OAuth {
|
||||
return withIOContext {
|
||||
client.newCall(accessTokenRequest(code))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
with(json) {
|
||||
client.newCall(accessTokenRequest(code))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package eu.kanade.tachiyomi.data.track.kavita
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Color
|
||||
import androidx.annotation.StringRes
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
@ -11,7 +10,11 @@ import eu.kanade.tachiyomi.data.track.EnhancedTrackService
|
|||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.updateNewTrackInfo
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.sourcePreferences
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.security.MessageDigest
|
||||
|
||||
class Kavita(private val context: Context, id: Int) : TrackService(id), EnhancedTrackService {
|
||||
|
@ -27,6 +30,8 @@ class Kavita(private val context: Context, id: Int) : TrackService(id), Enhanced
|
|||
private val interceptor by lazy { KavitaInterceptor(this) }
|
||||
val api by lazy { KavitaApi(client, interceptor) }
|
||||
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
@StringRes
|
||||
override fun nameRes() = R.string.kavita
|
||||
|
||||
|
@ -117,28 +122,29 @@ class Kavita(private val context: Context, id: Int) : TrackService(id), Enhanced
|
|||
|
||||
fun loadOAuth() {
|
||||
val oauth = OAuth()
|
||||
for (sourceId in 1..3) {
|
||||
val authentication = oauth.authentications[sourceId - 1]
|
||||
val sourceSuffixID by lazy {
|
||||
val key = "kavita_$sourceId/all/1" // Hardcoded versionID to 1
|
||||
for (id in 1..3) {
|
||||
val authentication = oauth.authentications[id - 1]
|
||||
val sourceId by lazy {
|
||||
val key = "kavita_$id/all/1" // Hardcoded versionID to 1
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }
|
||||
.reduce(Long::or) and Long.MAX_VALUE
|
||||
}
|
||||
val preferences: SharedPreferences by lazy {
|
||||
context.getSharedPreferences("source_$sourceSuffixID", 0x0000)
|
||||
}
|
||||
val prefApiUrl = preferences.getString("APIURL", "")!!
|
||||
if (prefApiUrl.isEmpty()) {
|
||||
val preferences = (sourceManager.get(sourceId) as ConfigurableSource).sourcePreferences()
|
||||
|
||||
val prefApiUrl = preferences.getString("APIURL", "")
|
||||
val prefApiKey = preferences.getString("APIKEY", "")
|
||||
if (prefApiUrl.isNullOrEmpty() || prefApiKey.isNullOrEmpty()) {
|
||||
// Source not configured. Skip
|
||||
continue
|
||||
}
|
||||
val prefApiKey = preferences.getString("APIKEY", "")!!
|
||||
|
||||
val token = api.getNewToken(apiUrl = prefApiUrl, apiKey = prefApiKey)
|
||||
if (token.isNullOrEmpty()) {
|
||||
// Source is not accessible. Skip
|
||||
continue
|
||||
}
|
||||
|
||||
authentication.apiUrl = prefApiUrl
|
||||
authentication.jwtToken = token.toString()
|
||||
}
|
||||
|
|
|
@ -7,16 +7,20 @@ import eu.kanade.tachiyomi.network.POST
|
|||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Dns
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class KavitaApi(private val client: OkHttpClient, interceptor: KavitaInterceptor) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val authClient = client.newBuilder()
|
||||
.dns(Dns.SYSTEM)
|
||||
.addInterceptor(interceptor)
|
||||
|
@ -40,7 +44,7 @@ class KavitaApi(private val client: OkHttpClient, interceptor: KavitaInterceptor
|
|||
try {
|
||||
client.newCall(request).execute().use {
|
||||
when (it.code) {
|
||||
200 -> return it.parseAs<AuthenticationDto>().token
|
||||
200 -> return with(json) { it.parseAs<AuthenticationDto>().token }
|
||||
401 -> {
|
||||
Timber.w("Unauthorized / api key not valid: Cleaned api URL: $apiUrl, Api key is empty: ${apiKey.isEmpty()}")
|
||||
throw IOException("Unauthorized / api key not valid")
|
||||
|
@ -85,9 +89,11 @@ class KavitaApi(private val client: OkHttpClient, interceptor: KavitaInterceptor
|
|||
private fun getTotalChapters(url: String): Int {
|
||||
val requestUrl = getApiVolumesUrl(url)
|
||||
try {
|
||||
val listVolumeDto = authClient.newCall(GET(requestUrl))
|
||||
.execute()
|
||||
.parseAs<List<VolumeDto>>()
|
||||
val listVolumeDto = with(json) {
|
||||
authClient.newCall(GET(requestUrl))
|
||||
.execute()
|
||||
.parseAs<List<VolumeDto>>()
|
||||
}
|
||||
var volumeNumber = 0
|
||||
var maxChapterNumber = 0
|
||||
for (volume in listVolumeDto) {
|
||||
|
@ -111,7 +117,9 @@ class KavitaApi(private val client: OkHttpClient, interceptor: KavitaInterceptor
|
|||
try {
|
||||
authClient.newCall(GET(requestUrl)).execute().use {
|
||||
if (it.code == 200) {
|
||||
return it.parseAs<ChapterDto>().number!!.replace(",", ".").toFloat()
|
||||
return with(json) {
|
||||
it.parseAs<ChapterDto>().number!!.replace(",", ".").toFloat()
|
||||
}
|
||||
}
|
||||
if (it.code == 204) {
|
||||
return 0F
|
||||
|
@ -126,9 +134,11 @@ class KavitaApi(private val client: OkHttpClient, interceptor: KavitaInterceptor
|
|||
|
||||
suspend fun getTrackSearch(url: String): TrackSearch = withIOContext {
|
||||
try {
|
||||
val serieDto: SeriesDto = authClient.newCall(GET(url))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
val serieDto: SeriesDto = with(json) {
|
||||
authClient.newCall(GET(url))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
}
|
||||
|
||||
val track = serieDto.toTrack()
|
||||
track.apply {
|
||||
|
|
|
@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess
|
|||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
@ -25,9 +26,12 @@ import okhttp3.OkHttpClient
|
|||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
suspend fun addLibManga(track: Track, userId: String): Track {
|
||||
|
@ -56,22 +60,25 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
}
|
||||
}
|
||||
|
||||
authClient.newCall(
|
||||
POST(
|
||||
"${baseUrl}library-entries",
|
||||
headers = headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
"${baseUrl}library-entries",
|
||||
headers = headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
body = data.toString()
|
||||
.toRequestBody("application/vnd.api+json".toMediaType()),
|
||||
),
|
||||
body = data.toString().toRequestBody("application/vnd.api+json".toMediaType()),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||
track
|
||||
}
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,36 +102,42 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
}
|
||||
}
|
||||
|
||||
authClient.newCall(
|
||||
Request.Builder()
|
||||
.url("${baseUrl}library-entries/${track.media_id}")
|
||||
.headers(
|
||||
headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
)
|
||||
.patch(data.toString().toRequestBody("application/vnd.api+json".toMediaType()))
|
||||
.build(),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val manga = it["data"]?.jsonObject
|
||||
if (manga != null) {
|
||||
val startedAt = manga["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull
|
||||
val finishedAt = manga["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull
|
||||
val startedDate = KitsuDateHelper.parse(startedAt)
|
||||
if (track.started_reading_date <= 0L || startedDate > 0) {
|
||||
track.started_reading_date = startedDate
|
||||
}
|
||||
val finishedDate = KitsuDateHelper.parse(finishedAt)
|
||||
if (track.finished_reading_date <= 0L || finishedDate > 0) {
|
||||
track.finished_reading_date = finishedDate
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
Request.Builder()
|
||||
.url("${baseUrl}library-entries/${track.media_id}")
|
||||
.headers(
|
||||
headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
)
|
||||
.patch(
|
||||
data.toString().toRequestBody("application/vnd.api+json".toMediaType()),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val manga = it["data"]?.jsonObject
|
||||
if (manga != null) {
|
||||
val startedAt =
|
||||
manga["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull
|
||||
val finishedAt =
|
||||
manga["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull
|
||||
val startedDate = KitsuDateHelper.parse(startedAt)
|
||||
if (track.started_reading_date <= 0L || startedDate > 0) {
|
||||
track.started_reading_date = startedDate
|
||||
}
|
||||
val finishedDate = KitsuDateHelper.parse(finishedAt)
|
||||
if (track.finished_reading_date <= 0L || finishedDate > 0) {
|
||||
track.finished_reading_date = finishedDate
|
||||
}
|
||||
}
|
||||
track
|
||||
}
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -158,13 +171,15 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
|
||||
suspend fun search(query: String): List<TrackSearch> {
|
||||
return withIOContext {
|
||||
authClient.newCall(GET(algoliaKeyUrl))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
|
||||
algoliaSearch(key, query)
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(GET(algoliaKeyUrl))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
|
||||
algoliaSearch(key, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -173,27 +188,28 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
val jsonObject = buildJsonObject {
|
||||
put("params", "query=$query$algoliaFilter")
|
||||
}
|
||||
|
||||
client.newCall(
|
||||
POST(
|
||||
algoliaUrl,
|
||||
headers = headersOf(
|
||||
"X-Algolia-Application-Id",
|
||||
algoliaAppId,
|
||||
"X-Algolia-API-Key",
|
||||
key,
|
||||
with(json) {
|
||||
client.newCall(
|
||||
POST(
|
||||
algoliaUrl,
|
||||
headers = headersOf(
|
||||
"X-Algolia-Application-Id",
|
||||
algoliaAppId,
|
||||
"X-Algolia-API-Key",
|
||||
key,
|
||||
),
|
||||
body = jsonObject.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
body = jsonObject.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["hits"]!!.jsonArray
|
||||
.map { KitsuSearchManga(it.jsonObject) }
|
||||
.filter { it.subType != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["hits"]!!.jsonArray
|
||||
.map { KitsuSearchManga(it.jsonObject) }
|
||||
.filter { it.subType != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -203,18 +219,20 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
.encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId")
|
||||
.appendQueryParameter("include", "manga")
|
||||
.build()
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
} else {
|
||||
null
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -224,18 +242,20 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
.encodedQuery("filter[id]=${track.media_id}")
|
||||
.appendQueryParameter("include", "manga")
|
||||
.build()
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -248,9 +268,11 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.build()
|
||||
client.newCall(POST(loginUrl, body = formBody))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
with(json) {
|
||||
client.newCall(POST(loginUrl, body = formBody))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -259,12 +281,14 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||
val url = "${baseUrl}users".toUri().buildUpon()
|
||||
.encodedQuery("filter[self]=true")
|
||||
.build()
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,28 +25,41 @@ class KomgaApi(private val client: OkHttpClient) {
|
|||
suspend fun getTrackSearch(url: String): TrackSearch =
|
||||
withIOContext {
|
||||
try {
|
||||
val track = if (url.contains(READLIST_API)) {
|
||||
client.newCall(GET(url))
|
||||
.awaitSuccess()
|
||||
.parseAs<ReadListDto>()
|
||||
.toTrack()
|
||||
} else {
|
||||
client.newCall(GET(url))
|
||||
.awaitSuccess()
|
||||
.parseAs<SeriesDto>()
|
||||
.toTrack()
|
||||
}
|
||||
|
||||
val progress = client
|
||||
.newCall(GET("${url.replace("/api/v1/series/", "/api/v2/series/")}/read-progress/tachiyomi"))
|
||||
.awaitSuccess().let {
|
||||
if (url.contains("/api/v1/series/")) {
|
||||
it.parseAs<ReadProgressV2Dto>()
|
||||
val track =
|
||||
with(json) {
|
||||
if (url.contains(READLIST_API)) {
|
||||
client.newCall(GET(url))
|
||||
.awaitSuccess()
|
||||
.parseAs<ReadListDto>()
|
||||
.toTrack()
|
||||
} else {
|
||||
it.parseAs<ReadProgressDto>().toV2()
|
||||
client.newCall(GET(url))
|
||||
.awaitSuccess()
|
||||
.parseAs<SeriesDto>()
|
||||
.toTrack()
|
||||
}
|
||||
}
|
||||
|
||||
val progress = with(json) {
|
||||
client
|
||||
.newCall(
|
||||
GET(
|
||||
"${
|
||||
url.replace(
|
||||
"/api/v1/series/",
|
||||
"/api/v2/series/",
|
||||
)
|
||||
}/read-progress/tachiyomi",
|
||||
),
|
||||
)
|
||||
.awaitSuccess().let {
|
||||
if (url.contains("/api/v1/series/")) {
|
||||
it.parseAs<ReadProgressV2Dto>()
|
||||
} else {
|
||||
it.parseAs<ReadProgressDto>().toV2()
|
||||
}
|
||||
}
|
||||
}
|
||||
track.apply {
|
||||
cover_url = "$url/thumbnail"
|
||||
tracking_url = url
|
||||
|
|
|
@ -43,13 +43,15 @@ class MangaUpdatesApi(
|
|||
|
||||
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
|
||||
val listItem =
|
||||
authClient.newCall(
|
||||
GET(
|
||||
url = "$baseUrl/v1/lists/series/${track.media_id}",
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<ListItem>()
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
GET(
|
||||
url = "$baseUrl/v1/lists/series/${track.media_id}",
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<ListItem>()
|
||||
}
|
||||
|
||||
val rating = getSeriesRating(track)
|
||||
|
||||
|
@ -108,13 +110,15 @@ class MangaUpdatesApi(
|
|||
|
||||
private suspend fun getSeriesRating(track: Track): Rating? {
|
||||
return try {
|
||||
authClient.newCall(
|
||||
GET(
|
||||
url = "$baseUrl/v1/series/${track.media_id}/rating",
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<Rating>()
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
GET(
|
||||
url = "$baseUrl/v1/series/${track.media_id}/rating",
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<Rating>()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
@ -153,20 +157,22 @@ class MangaUpdatesApi(
|
|||
},
|
||||
)
|
||||
}
|
||||
return client.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/series/search",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
obj["results"]?.jsonArray?.map { element ->
|
||||
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
|
||||
return with(json) {
|
||||
client.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/series/search",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
obj["results"]?.jsonArray?.map { element ->
|
||||
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authenticate(username: String, password: String): Context? {
|
||||
|
@ -174,21 +180,23 @@ class MangaUpdatesApi(
|
|||
put("username", username)
|
||||
put("password", password)
|
||||
}
|
||||
return client.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/account/login",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
try {
|
||||
json.decodeFromJsonElement<Context>(obj["context"]!!)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
null
|
||||
return with(json) {
|
||||
client.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/account/login",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
try {
|
||||
json.decodeFromJsonElement<Context>(obj["context"]!!)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.util.PkceUtil
|
|||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
@ -28,12 +29,14 @@ import okhttp3.OkHttpClient
|
|||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
suspend fun getAccessToken(authCode: String): OAuth {
|
||||
return withIOContext {
|
||||
|
@ -43,9 +46,11 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
|||
.add("code_verifier", codeVerifier)
|
||||
.add("grant_type", "authorization_code")
|
||||
.build()
|
||||
client.newCall(POST("$baseOAuthUrl/token", body = formBody))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
with(json) {
|
||||
client.newCall(POST("$baseOAuthUrl/token", body = formBody))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,10 +60,12 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
|||
.url("$baseApiUrl/users/@me")
|
||||
.get()
|
||||
.build()
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { it["name"]!!.jsonPrimitive.content }
|
||||
with(json) {
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { it["name"]!!.jsonPrimitive.content }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,19 +76,21 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
|||
.appendQueryParameter("q", query.take(64))
|
||||
.appendQueryParameter("nsfw", "true")
|
||||
.build()
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!.jsonArray
|
||||
.map { data -> data.jsonObject["node"]!!.jsonObject }
|
||||
.map { node ->
|
||||
val id = node["id"]!!.jsonPrimitive.int
|
||||
async { getMangaDetails(id) }
|
||||
}
|
||||
.awaitAll()
|
||||
.filter { trackSearch -> !trackSearch.publishing_type.contains("novel") }
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!.jsonArray
|
||||
.map { data -> data.jsonObject["node"]!!.jsonObject }
|
||||
.map { node ->
|
||||
val id = node["id"]!!.jsonPrimitive.int
|
||||
async { getMangaDetails(id) }
|
||||
}
|
||||
.awaitAll()
|
||||
.filter { trackSearch -> !trackSearch.publishing_type.contains("novel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,28 +100,30 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
|||
.appendPath(id.toString())
|
||||
.appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date")
|
||||
.build()
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val obj = it.jsonObject
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
media_id = obj["id"]!!.jsonPrimitive.long
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
|
||||
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||
cover_url = obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content ?: ""
|
||||
tracking_url = "https://myanimelist.net/manga/$media_id"
|
||||
publishing_status = obj["status"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
publishing_type = obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
start_date = try {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(obj["start_date"]!!)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
val obj = it.jsonObject
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
media_id = obj["id"]!!.jsonPrimitive.long
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
|
||||
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||
cover_url = obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content ?: ""
|
||||
tracking_url = "https://myanimelist.net/manga/$media_id"
|
||||
publishing_status = obj["status"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
publishing_type = obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
start_date = try {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(obj["start_date"]!!)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -134,10 +145,12 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
|||
.url(mangaUrl(track.media_id).toString())
|
||||
.put(formBodyBuilder.build())
|
||||
.build()
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { parseMangaItem(it, track) }
|
||||
with(json) {
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { parseMangaItem(it, track) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -147,15 +160,17 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
|||
.appendPath(track.media_id.toString())
|
||||
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
|
||||
.build()
|
||||
authClient.newCall(GET(uri.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||
obj.jsonObject["my_list_status"]?.jsonObject?.let {
|
||||
parseMangaItem(it, track)
|
||||
with(json) {
|
||||
authClient.newCall(GET(uri.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||
obj.jsonObject["my_list_status"]?.jsonObject?.let {
|
||||
parseMangaItem(it, track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -199,9 +214,11 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
|||
.url(urlBuilder.build().toString())
|
||||
.get()
|
||||
.build()
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
with(json) {
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var token: String?) : Interceptor {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private var oauth: OAuth? = null
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
|
@ -23,14 +27,16 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var t
|
|||
if (oauth != null &&
|
||||
(oauth!!.isExpired() || oauth!!.created_at == System.currentTimeMillis())
|
||||
) {
|
||||
val newOauth = runCatching {
|
||||
val oauthResponse = chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!))
|
||||
val newOauth = with(json) {
|
||||
runCatching {
|
||||
val oauthResponse = chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!))
|
||||
|
||||
if (oauthResponse.isSuccessful) {
|
||||
oauthResponse.parseAs<OAuth>()
|
||||
} else {
|
||||
oauthResponse.closeQuietly()
|
||||
null
|
||||
if (oauthResponse.isSuccessful) {
|
||||
oauthResponse.parseAs<OAuth>()
|
||||
} else {
|
||||
oauthResponse.closeQuietly()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess
|
|||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
@ -26,9 +27,12 @@ import okhttp3.FormBody
|
|||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
suspend fun addLibManga(track: Track, user_id: String): Track {
|
||||
|
@ -62,14 +66,16 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
|||
.appendQueryParameter("search", search)
|
||||
.appendQueryParameter("limit", "20")
|
||||
.build()
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonArray>()
|
||||
.let { response ->
|
||||
response.map {
|
||||
jsonToSearch(it.jsonObject)
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonArray>()
|
||||
.let { response ->
|
||||
response.map {
|
||||
jsonToSearch(it.jsonObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,9 +126,11 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
|||
.appendQueryParameter("target_id", track.media_id.toString())
|
||||
.appendQueryParameter("target_type", "Manga")
|
||||
.build()
|
||||
return authClient.newCall(GET(url.toString()))
|
||||
.execute()
|
||||
.parseAs()
|
||||
return with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.execute()
|
||||
.parseAs()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track, user_id: String): Track? {
|
||||
|
@ -130,9 +138,11 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
|||
val urlMangas = "$apiUrl/mangas".toUri().buildUpon()
|
||||
.appendPath(track.media_id.toString())
|
||||
.build()
|
||||
val mangas = authClient.newCall(GET(urlMangas.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
val mangas = with(json) {
|
||||
authClient.newCall(GET(urlMangas.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
}
|
||||
|
||||
val entry = getUserRates(track, user_id)
|
||||
if (entry.size > 1) {
|
||||
|
@ -146,20 +156,24 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
|||
|
||||
suspend fun getCurrentUser(): Int {
|
||||
return withIOContext {
|
||||
authClient.newCall(GET("$apiUrl/users/whoami"))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["id"]!!.jsonPrimitive.int
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(GET("$apiUrl/users/whoami"))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["id"]!!.jsonPrimitive.int
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun accessToken(code: String): OAuth {
|
||||
return withIOContext {
|
||||
client.newCall(accessTokenRequest(code))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
with(json) {
|
||||
client.newCall(accessTokenRequest(code))
|
||||
.awaitSuccess()
|
||||
.parseAs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.network.PUT
|
|||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Dns
|
||||
import okhttp3.FormBody
|
||||
|
@ -23,6 +24,7 @@ import java.nio.charset.Charset
|
|||
import java.security.MessageDigest
|
||||
|
||||
class TachideskApi {
|
||||
private val json: Json by injectLazy()
|
||||
private val network by injectLazy<NetworkHelper>()
|
||||
val client: OkHttpClient =
|
||||
network.client.newBuilder()
|
||||
|
@ -50,7 +52,9 @@ class TachideskApi {
|
|||
trackUrl
|
||||
}
|
||||
|
||||
val manga = client.newCall(GET("$url/full", headers)).awaitSuccess().parseAs<MangaDataClass>()
|
||||
val manga = with(json) {
|
||||
client.newCall(GET("$url/full", headers)).awaitSuccess().parseAs<MangaDataClass>()
|
||||
}
|
||||
|
||||
TrackSearch.create(TrackManager.SUWAYOMI).apply {
|
||||
title = manga.title
|
||||
|
@ -70,7 +74,9 @@ class TachideskApi {
|
|||
|
||||
suspend fun updateProgress(track: Track): Track {
|
||||
val url = track.tracking_url
|
||||
val chapters = client.newCall(GET("$url/chapters", headers)).awaitSuccess().parseAs<List<ChapterDataClass>>()
|
||||
val chapters = with(json) {
|
||||
client.newCall(GET("$url/chapters", headers)).awaitSuccess().parseAs<List<ChapterDataClass>>()
|
||||
}
|
||||
val lastChapterIndex = chapters.first { it.chapterNumber == track.last_chapter_read }.index
|
||||
|
||||
client.newCall(
|
||||
|
|
|
@ -10,12 +10,14 @@ import eu.kanade.tachiyomi.network.await
|
|||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.util.system.localeContext
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AppUpdateChecker {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
|
@ -26,45 +28,48 @@ class AppUpdateChecker {
|
|||
}
|
||||
|
||||
return withIOContext {
|
||||
val result = if (preferences.checkForBetas().get()) {
|
||||
networkService.client
|
||||
.newCall(GET("https://api.github.com/repos/$GITHUB_REPO/releases"))
|
||||
.await()
|
||||
.parseAs<List<GithubRelease>>()
|
||||
.let { githubReleases ->
|
||||
val releases = githubReleases.take(10).filter { isNewVersion(it.version) }
|
||||
// Check if any of the latest versions are newer than the current version
|
||||
val release = releases
|
||||
.maxWithOrNull { r1, r2 ->
|
||||
when {
|
||||
r1.version == r2.version -> 0
|
||||
isNewVersion(r2.version, r1.version) -> -1
|
||||
else -> 1
|
||||
val result = with(json) {
|
||||
if (preferences.checkForBetas().get()) {
|
||||
networkService.client
|
||||
.newCall(GET("https://api.github.com/repos/$GITHUB_REPO/releases"))
|
||||
.await()
|
||||
.parseAs<List<GithubRelease>>()
|
||||
.let { githubReleases ->
|
||||
val releases =
|
||||
githubReleases.take(10).filter { isNewVersion(it.version) }
|
||||
// Check if any of the latest versions are newer than the current version
|
||||
val release = releases
|
||||
.maxWithOrNull { r1, r2 ->
|
||||
when {
|
||||
r1.version == r2.version -> 0
|
||||
isNewVersion(r2.version, r1.version) -> -1
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
preferences.lastAppCheck().set(Date().time)
|
||||
|
||||
if (release != null) {
|
||||
AppUpdateResult.NewUpdate(release)
|
||||
} else {
|
||||
AppUpdateResult.NoNewUpdate
|
||||
}
|
||||
preferences.lastAppCheck().set(Date().time)
|
||||
|
||||
if (release != null) {
|
||||
AppUpdateResult.NewUpdate(release)
|
||||
} else {
|
||||
AppUpdateResult.NoNewUpdate
|
||||
}
|
||||
}
|
||||
} else {
|
||||
networkService.client
|
||||
.newCall(GET("https://api.github.com/repos/$GITHUB_REPO/releases/latest"))
|
||||
.await()
|
||||
.parseAs<GithubRelease>()
|
||||
.let {
|
||||
preferences.lastAppCheck().set(Date().time)
|
||||
} else {
|
||||
networkService.client
|
||||
.newCall(GET("https://api.github.com/repos/$GITHUB_REPO/releases/latest"))
|
||||
.await()
|
||||
.parseAs<GithubRelease>()
|
||||
.let {
|
||||
preferences.lastAppCheck().set(Date().time)
|
||||
|
||||
// Check if latest version is newer than the current version
|
||||
if (isNewVersion(it.version)) {
|
||||
AppUpdateResult.NewUpdate(it)
|
||||
} else {
|
||||
AppUpdateResult.NoNewUpdate
|
||||
// Check if latest version is newer than the current version
|
||||
if (isNewVersion(it.version)) {
|
||||
AppUpdateResult.NewUpdate(it)
|
||||
} else {
|
||||
AppUpdateResult.NoNewUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (doExtrasAfterNewUpdate && result is AppUpdateResult.NewUpdate) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
|
|
|
@ -11,11 +11,13 @@ import eu.kanade.tachiyomi.network.await
|
|||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
internal class ExtensionGithubApi {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
|
||||
private var requiresFallbackSource = false
|
||||
|
@ -42,9 +44,11 @@ internal class ExtensionGithubApi {
|
|||
.await()
|
||||
}
|
||||
|
||||
val extensions = response
|
||||
.parseAs<List<ExtensionJsonObject>>()
|
||||
.toExtensions()
|
||||
val extensions = with(json) {
|
||||
response
|
||||
.parseAs<List<ExtensionJsonObject>>()
|
||||
.toExtensions()
|
||||
}
|
||||
|
||||
// Sanity check - a small number of extensions probably means something broke
|
||||
// with the repo generator
|
||||
|
|
|
@ -32,6 +32,7 @@ sealed class Extension {
|
|||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
val isShared: Boolean,
|
||||
) : Extension()
|
||||
|
||||
data class Available(
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
package eu.kanade.tachiyomi.extension.model
|
||||
|
||||
sealed class LoadResult {
|
||||
sealed interface LoadResult {
|
||||
|
||||
class Success(val extension: Extension.Installed) : LoadResult()
|
||||
class Untrusted(val extension: Extension.Untrusted) : LoadResult()
|
||||
class Error(val message: String? = null) : LoadResult() {
|
||||
constructor(exception: Throwable) : this(exception.message)
|
||||
}
|
||||
data class Success(val extension: Extension.Installed) : LoadResult
|
||||
data class Untrusted(val extension: Extension.Untrusted) : LoadResult
|
||||
data object Error : LoadResult
|
||||
}
|
||||
|
|
|
@ -97,7 +97,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
|||
*/
|
||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
?: return LoadResult.Error("Package name not found")
|
||||
?: return LoadResult.Error
|
||||
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await()
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.util
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
|
@ -15,8 +16,8 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
|||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import eu.kanade.tachiyomi.util.system.getApplicationIcon
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
@ -41,7 +42,11 @@ internal object ExtensionLoader {
|
|||
const val LIB_VERSION_MIN = 1.3
|
||||
const val LIB_VERSION_MAX = 1.5
|
||||
|
||||
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
@Suppress("DEPRECATION")
|
||||
private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
|
||||
PackageManager.GET_META_DATA or
|
||||
PackageManager.GET_SIGNATURES or
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
|
||||
|
||||
// inorichi's key
|
||||
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
||||
|
@ -49,7 +54,7 @@ internal object ExtensionLoader {
|
|||
/**
|
||||
* List of the trusted signatures.
|
||||
*/
|
||||
var trustedSignatures = mutableSetOf<String>() + preferences.trustedSignatures().get() + officialSignature
|
||||
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get()
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
|
@ -58,17 +63,50 @@ internal object ExtensionLoader {
|
|||
*/
|
||||
fun loadExtensions(context: Context): List<LoadResult> {
|
||||
val pkgManager = context.packageManager
|
||||
val installedPkgs = pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
|
||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong()))
|
||||
} else {
|
||||
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
}
|
||||
|
||||
val sharedExtPkgs = installedPkgs
|
||||
.asSequence()
|
||||
.filter { isPackageAnExtension(it) }
|
||||
.map { ExtensionInfo(packageInfo = it, isShared = true) }
|
||||
|
||||
// val privateExtPkgs = getPrivateExtensionDir(context)
|
||||
// .listFiles()
|
||||
// ?.asSequence()
|
||||
// ?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION }
|
||||
// ?.mapNotNull {
|
||||
// val path = it.absolutePath
|
||||
// pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
|
||||
// ?.apply { applicationInfo.fixBasePaths(path) }
|
||||
// }
|
||||
// ?.filter { isPackageAnExtension(it) }
|
||||
// ?.map { ExtensionInfo(packageInfo = it, isShared = false) }
|
||||
// ?: emptySequence()
|
||||
|
||||
val extPkgs = (sharedExtPkgs)
|
||||
// Remove duplicates. Shared takes priority than private by default
|
||||
.distinctBy { it.packageInfo.packageName }
|
||||
// Compare version number
|
||||
// .mapNotNull { sharedPkg ->
|
||||
// val privatePkg = privateExtPkgs
|
||||
// .singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName }
|
||||
// selectExtensionPackage(sharedPkg, privatePkg)
|
||||
// }
|
||||
.toList()
|
||||
|
||||
if (extPkgs.isEmpty()) return emptyList()
|
||||
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
val deferred = extPkgs.map {
|
||||
async { loadExtension(context, it.packageName, it) }
|
||||
async { loadExtension(context, it) }
|
||||
}
|
||||
deferred.map { it.await() }
|
||||
deferred.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,16 +115,48 @@ internal object ExtensionLoader {
|
|||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
||||
val pkgInfo = try {
|
||||
val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
|
||||
if (extensionPackage == null) {
|
||||
Timber.e("Extension package is not found ($pkgName)")
|
||||
return LoadResult.Error
|
||||
}
|
||||
return loadExtension(context, extensionPackage)
|
||||
}
|
||||
|
||||
fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
||||
return getExtensionInfoFromPkgName(context, pkgName)?.packageInfo
|
||||
}
|
||||
|
||||
private fun getExtensionInfoFromPkgName(context: Context, pkgName: String): ExtensionInfo? {
|
||||
// val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION")
|
||||
// val privatePkg = if (privateExtensionFile.isFile) {
|
||||
// context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS)
|
||||
// ?.takeIf { isPackageAnExtension(it) }
|
||||
// ?.let {
|
||||
// it.applicationInfo.fixBasePaths(privateExtensionFile.absolutePath)
|
||||
// ExtensionInfo(
|
||||
// packageInfo = it,
|
||||
// isShared = false,
|
||||
// )
|
||||
// }
|
||||
// } else {
|
||||
// null
|
||||
// }
|
||||
|
||||
val sharedPkg = try {
|
||||
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
ExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = true,
|
||||
)
|
||||
}
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
return LoadResult.Error(error)
|
||||
null
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
return LoadResult.Error("Tried to load a package that wasn't a extension")
|
||||
}
|
||||
return loadExtension(context, pkgName, pkgInfo)
|
||||
|
||||
return sharedPkg // selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
|
||||
fun isExtensionInstalledByApp(context: Context, pkgName: String): Boolean {
|
||||
|
@ -102,56 +172,56 @@ internal object ExtensionLoader {
|
|||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
* Loads an extension
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
* @param extensionInfo The extension to load.
|
||||
*/
|
||||
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult {
|
||||
private fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val appInfo = try {
|
||||
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
return LoadResult.Error(error)
|
||||
}
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
val appInfo = pkgInfo.applicationInfo
|
||||
val pkgName = pkgInfo.packageName
|
||||
|
||||
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
|
||||
|
||||
if (versionName.isNullOrEmpty()) {
|
||||
val exception = Exception("Missing versionName for extension $extName")
|
||||
Timber.w(exception)
|
||||
return LoadResult.Error(exception)
|
||||
Timber.w("Missing versionName for extension $extName")
|
||||
return LoadResult.Error
|
||||
}
|
||||
|
||||
// Validate lib version
|
||||
val libVersion = versionName.substringBeforeLast('.').toDouble()
|
||||
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||
val exception = Exception(
|
||||
"Lib version is $libVersion, while only versions " +
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
|
||||
val libVersion = versionName.substringBeforeLast('.').toDoubleOrNull()
|
||||
if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||
Timber.w(
|
||||
"Lib version is $libVersion, while only versions $LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
|
||||
)
|
||||
Timber.w(exception)
|
||||
return LoadResult.Error(exception)
|
||||
return LoadResult.Error
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(pkgInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
return LoadResult.Error("Package $pkgName isn't signed")
|
||||
} else if (signatureHash !in trustedSignatures) {
|
||||
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash)
|
||||
val signatures = getSignatures(pkgInfo)
|
||||
if (signatures.isNullOrEmpty()) {
|
||||
Timber.w("Package $pkgName isn't signed")
|
||||
return LoadResult.Error
|
||||
} else if (!hasTrustedSignature(signatures)) {
|
||||
val extension = Extension.Untrusted(
|
||||
extName,
|
||||
pkgName,
|
||||
versionName,
|
||||
versionCode,
|
||||
libVersion,
|
||||
signatures.last(),
|
||||
)
|
||||
Timber.w("Extension $pkgName isn't trusted")
|
||||
return LoadResult.Untrusted(extension)
|
||||
}
|
||||
|
||||
val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1
|
||||
if (!loadNsfwSource && isNsfw) {
|
||||
return LoadResult.Error("NSFW extension $pkgName not allowed")
|
||||
Timber.w("NSFW extension $pkgName not allowed")
|
||||
return LoadResult.Error
|
||||
}
|
||||
|
||||
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
|
||||
|
@ -171,14 +241,14 @@ internal object ExtensionLoader {
|
|||
}
|
||||
.flatMap {
|
||||
try {
|
||||
when (val obj = Class.forName(it, false, classLoader).newInstance()) {
|
||||
when (val obj = Class.forName(it, false, classLoader).getDeclaredConstructor().newInstance()) {
|
||||
is Source -> listOf(obj)
|
||||
is SourceFactory -> obj.createSources()
|
||||
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Timber.e(e, "Extension load error: $extName.")
|
||||
return LoadResult.Error(e)
|
||||
return LoadResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -203,12 +273,35 @@ internal object ExtensionLoader {
|
|||
hasChangelog = hasChangelog,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
isUnofficial = signatureHash != officialSignature,
|
||||
icon = context.getApplicationIcon(pkgName),
|
||||
isUnofficial = !isOfficiallySigned(signatures),
|
||||
icon = appInfo.loadIcon(pkgManager),
|
||||
isShared = extensionInfo.isShared,
|
||||
)
|
||||
return LoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which extension package to use based on version code
|
||||
*
|
||||
* @param shared extension installed to system
|
||||
* @param private extension installed to data directory
|
||||
*/
|
||||
private fun selectExtensionPackage(shared: ExtensionInfo?, private: ExtensionInfo?): ExtensionInfo? {
|
||||
when {
|
||||
private == null && shared != null -> return shared
|
||||
shared == null && private != null -> return private
|
||||
shared == null && private == null -> return null
|
||||
}
|
||||
|
||||
return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >=
|
||||
PackageInfoCompat.getLongVersionCode(private!!.packageInfo)
|
||||
) {
|
||||
shared
|
||||
} else {
|
||||
private
|
||||
}
|
||||
}
|
||||
|
||||
fun isPackageNameAnExtension(packageManager: PackageManager, pkgName: String): Boolean =
|
||||
isPackageAnExtension(packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS))
|
||||
|
||||
|
@ -221,16 +314,50 @@ internal object ExtensionLoader {
|
|||
pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
* Returns the signatures of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
* @return List SHA256 digest of the signatures
|
||||
*/
|
||||
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||
val signatures = pkgInfo.signatures
|
||||
return if (signatures != null && signatures.isNotEmpty()) {
|
||||
Hash.sha256(signatures.first().toByteArray())
|
||||
private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val signingInfo = pkgInfo.signingInfo
|
||||
if (signingInfo.hasMultipleSigners()) {
|
||||
signingInfo.apkContentsSigners
|
||||
} else {
|
||||
signingInfo.signingCertificateHistory
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@Suppress("DEPRECATION")
|
||||
pkgInfo.signatures
|
||||
}
|
||||
?.map { Hash.sha256(it.toByteArray()) }
|
||||
?.toList()
|
||||
}
|
||||
|
||||
private fun hasTrustedSignature(signatures: List<String>): Boolean {
|
||||
return trustedSignatures.any { signatures.contains(it) }
|
||||
}
|
||||
|
||||
private fun isOfficiallySigned(signatures: List<String>): Boolean {
|
||||
return signatures.all { it == officialSignature }
|
||||
}
|
||||
|
||||
/**
|
||||
* On Android 13+ the ApplicationInfo generated by getPackageArchiveInfo doesn't
|
||||
* have sourceDir which breaks assets loading (used for getting icon here).
|
||||
*/
|
||||
private fun ApplicationInfo.fixBasePaths(apkPath: String) {
|
||||
if (sourceDir == null) {
|
||||
sourceDir = apkPath
|
||||
}
|
||||
if (publicSourceDir == null) {
|
||||
publicSourceDir = apkPath
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExtensionInfo(
|
||||
val packageInfo: PackageInfo,
|
||||
val isShared: Boolean,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.network
|
|||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import kotlinx.serialization.serializer
|
||||
|
@ -16,8 +15,6 @@ import okhttp3.Response
|
|||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
@ -61,6 +58,15 @@ fun Call.asObservable(): Observable<Response> {
|
|||
}
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||
|
@ -98,6 +104,9 @@ suspend fun Call.await(): Response {
|
|||
return await(callStack)
|
||||
}
|
||||
|
||||
/**
|
||||
* @since extensions-lib 1.5
|
||||
*/
|
||||
suspend fun Call.awaitSuccess(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
val response = await(callStack)
|
||||
|
@ -108,15 +117,6 @@ suspend fun Call.awaitSuccess(): Response {
|
|||
return response
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
|
@ -131,15 +131,26 @@ fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: Progre
|
|||
return progressClient.newCall(request)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
inline fun <reified T> Response.parseAs(): T {
|
||||
return decodeFromJsonResponse(serializer(), this)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun <T> decodeFromJsonResponse(deserializer: DeserializationStrategy<T>, response: Response): T {
|
||||
context(Json)
|
||||
fun <T> decodeFromJsonResponse(
|
||||
deserializer: DeserializationStrategy<T>,
|
||||
response: Response,
|
||||
): T {
|
||||
return response.body.source().use {
|
||||
Injekt.get<Json>().decodeFromBufferedSource(deserializer, it)
|
||||
decodeFromBufferedSource(deserializer, it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception that handles HTTP codes considered not successful by OkHttp.
|
||||
* Use it to have a standardized error message in the app across the extensions.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param code [Int] the HTTP status code
|
||||
*/
|
||||
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
|
||||
|
|
|
@ -5,13 +5,11 @@ import eu.kanade.tachiyomi.data.database.models.Manga
|
|||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.lang.toNormalized
|
||||
import eu.kanade.tachiyomi.util.system.await
|
||||
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
|
@ -37,8 +35,7 @@ class SmartSearchEngine(
|
|||
"$query ${extraSearchParams.trim()}"
|
||||
} else query
|
||||
|
||||
val searchResults = source.fetchSearchManga(1, builtQuery, FilterList())
|
||||
.toSingle().await(Schedulers.io())
|
||||
val searchResults = source.getSearchManga(1, builtQuery, FilterList())
|
||||
|
||||
searchResults.mangas.map {
|
||||
val cleanedMangaTitle = cleanSmartSearchTitle(it.title)
|
||||
|
@ -63,8 +60,7 @@ class SmartSearchEngine(
|
|||
titleNormalized
|
||||
}
|
||||
val searchResults =
|
||||
source.fetchSearchManga(1, searchQuery, source.getFilterList()).toSingle()
|
||||
.await(Schedulers.io())
|
||||
source.getSearchManga(1, searchQuery, source.getFilterList())
|
||||
|
||||
if (searchResults.mangas.size == 1) {
|
||||
return@supervisorScope listOf(SearchEntry(searchResults.mangas.first(), 0.0))
|
||||
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source
|
|||
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.util.system.awaitSingle
|
||||
import rx.Observable
|
||||
|
||||
interface CatalogueSource : Source {
|
||||
|
@ -17,30 +18,63 @@ interface CatalogueSource : Source {
|
|||
val supportsLatest: Boolean
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
* Get a page with a list of manga.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchPopularManga(page: Int): Observable<MangasPage>
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getPopularManga(page: Int): MangasPage {
|
||||
return fetchPopularManga(page).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
* Get a page with a list of manga.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
|
||||
return fetchSearchManga(page, query, filters).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
* Get a page with a list of latest manga updates.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getLatestUpdates(page: Int): MangasPage {
|
||||
return fetchLatestUpdates(page).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
fun getFilterList(): FilterList
|
||||
|
||||
@Deprecated(
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getPopularManga"),
|
||||
)
|
||||
fun fetchPopularManga(page: Int): Observable<MangasPage> =
|
||||
throw IllegalStateException("Not used")
|
||||
|
||||
@Deprecated(
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getSearchManga"),
|
||||
)
|
||||
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
|
||||
throw IllegalStateException("Not used")
|
||||
|
||||
@Deprecated(
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getLatestUpdates"),
|
||||
)
|
||||
fun fetchLatestUpdates(page: Int): Observable<MangasPage> =
|
||||
throw IllegalStateException("Not used")
|
||||
}
|
||||
|
|
|
@ -1,8 +1,25 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
interface ConfigurableSource : Source {
|
||||
|
||||
/**
|
||||
* Gets instance of [SharedPreferences] scoped to the specific source.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
*/
|
||||
fun getSourcePreferences(): SharedPreferences =
|
||||
Injekt.get<Application>().getSharedPreferences(preferenceKey(), Context.MODE_PRIVATE)
|
||||
|
||||
fun setupPreferenceScreen(screen: PreferenceScreen)
|
||||
}
|
||||
|
||||
// TODO: use getSourcePreferences once all extensions are on ext-lib 1.5
|
||||
fun ConfigurableSource.sourcePreferences(): SharedPreferences =
|
||||
Injekt.get<Application>().getSharedPreferences(preferenceKey(), Context.MODE_PRIVATE)
|
||||
|
|
|
@ -18,7 +18,6 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
@ -101,13 +100,13 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
|
||||
override fun toString() = name
|
||||
|
||||
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", popularFilters)
|
||||
override suspend fun getPopularManga(page: Int) = getSearchManga(page, "", popularFilters)
|
||||
|
||||
override fun fetchSearchManga(
|
||||
override suspend fun getSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
): MangasPage {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
|
||||
val time =
|
||||
|
@ -179,10 +178,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
}
|
||||
|
||||
return Observable.just(MangasPage(mangas.toList(), false))
|
||||
return MangasPage(mangas.toList(), false)
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", latestFilters)
|
||||
override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters)
|
||||
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
val localDetails = getBaseDirectories(context)
|
||||
|
|
|
@ -12,12 +12,12 @@ import uy.kohesive.injekt.Injekt
|
|||
import uy.kohesive.injekt.api.get
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc...
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc.
|
||||
*/
|
||||
interface Source {
|
||||
|
||||
/**
|
||||
* Id for the source. Must be unique.
|
||||
* ID for the source. Must be unique.
|
||||
*/
|
||||
val id: Long
|
||||
|
||||
|
@ -30,42 +30,11 @@ interface Source {
|
|||
get() = ""
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga.
|
||||
* Get the updated details for a manga.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getMangaDetails"),
|
||||
)
|
||||
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getChapterList"),
|
||||
)
|
||||
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
|
||||
|
||||
// TODO: remove direct usages on this method
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of pages a chapter has.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getPageList"),
|
||||
)
|
||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
|
||||
|
||||
/**
|
||||
* [1.x API] Get the updated details for a manga.
|
||||
* @return the updated manga.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
|
@ -73,7 +42,11 @@ interface Source {
|
|||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get all the available chapters for a manga.
|
||||
* Get all the available chapters for a manga.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param manga the manga to update.
|
||||
* @return the chapters for the manga.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getChapterList(manga: SManga): List<SChapter> {
|
||||
|
@ -81,7 +54,12 @@ interface Source {
|
|||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get the list of pages a chapter has.
|
||||
* Get the list of pages a chapter has. Pages should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param chapter the chapter.
|
||||
* @return the pages for the chapter.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getPageList(chapter: SChapter): List<Page> {
|
||||
|
@ -100,8 +78,29 @@ interface Source {
|
|||
fun nameBasedOnEnabledLanguages(enabledLanguages: Set<String>, extensionManager: ExtensionManager? = null): String {
|
||||
return if (includeLangInName(enabledLanguages, extensionManager)) toString() else name
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getMangaDetails"),
|
||||
)
|
||||
fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
||||
throw IllegalStateException("Not used")
|
||||
|
||||
@Deprecated(
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getChapterList"),
|
||||
)
|
||||
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
|
||||
throw IllegalStateException("Not used")
|
||||
|
||||
@Deprecated(
|
||||
"Use the non-RxJava API instead",
|
||||
ReplaceWith("getPageList"),
|
||||
)
|
||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
||||
throw IllegalStateException("Not used")
|
||||
}
|
||||
|
||||
fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
|
||||
|
||||
fun Source.getPreferenceKey(): String = "source_$id"
|
||||
fun Source.preferenceKey(): String = "source_$id"
|
||||
|
|
|
@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.collectLatest
|
|||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import rx.Observable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class SourceManager(
|
||||
|
@ -116,38 +115,20 @@ class SourceManager(
|
|||
override val name: String
|
||||
get() = extensionManager.getStubSource(id)?.name ?: id.toString()
|
||||
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga =
|
||||
throw getSourceNotInstalledException()
|
||||
}
|
||||
|
||||
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails"))
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return Observable.error(getSourceNotInstalledException())
|
||||
}
|
||||
|
||||
override suspend fun getChapterList(manga: SManga): List<SChapter> {
|
||||
override suspend fun getChapterList(manga: SManga): List<SChapter> =
|
||||
throw getSourceNotInstalledException()
|
||||
}
|
||||
|
||||
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList"))
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return Observable.error(getSourceNotInstalledException())
|
||||
}
|
||||
|
||||
override suspend fun getPageList(chapter: SChapter): List<Page> {
|
||||
override suspend fun getPageList(chapter: SChapter): List<Page> =
|
||||
throw getSourceNotInstalledException()
|
||||
}
|
||||
|
||||
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getPageList"))
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return Observable.error(getSourceNotInstalledException())
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
|
||||
fun getSourceNotInstalledException(): Exception {
|
||||
private fun getSourceNotInstalledException(): Exception {
|
||||
return SourceNotFoundException(
|
||||
context.getString(
|
||||
R.string.source_not_installed_,
|
||||
|
|
|
@ -14,6 +14,7 @@ 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.util.lang.getUrlWithoutDomain
|
||||
import eu.kanade.tachiyomi.util.system.awaitSingle
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
@ -25,11 +26,11 @@ import uy.kohesive.injekt.injectLazy
|
|||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* A simple implementation for sources from a website.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
abstract class HttpSource : CatalogueSource {
|
||||
|
||||
/**
|
||||
|
@ -49,15 +50,16 @@ abstract class HttpSource : CatalogueSource {
|
|||
open val versionId = 1
|
||||
|
||||
/**
|
||||
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string: sourcename/language/versionId
|
||||
* Note the generated id sets the sign bit to 0.
|
||||
* ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string `"${name.lowercase()}/$lang/$versionId"`.
|
||||
*
|
||||
* The ID is generated by the [generateId] function, which can be reused if needed
|
||||
* to generate outdated IDs for cases where the source name or language needs to
|
||||
* be changed but migrations can be avoided.
|
||||
*
|
||||
* Note: the generated ID sets the sign bit to `0`.
|
||||
*/
|
||||
override val id by lazy {
|
||||
val key = "${name.lowercase(Locale.getDefault())}/$lang/$versionId"
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||
}
|
||||
override val id by lazy { generateId(name, lang, versionId) }
|
||||
|
||||
/**
|
||||
* Headers used for requests.
|
||||
|
@ -70,6 +72,29 @@ abstract class HttpSource : CatalogueSource {
|
|||
open val client: OkHttpClient
|
||||
get() = network.client
|
||||
|
||||
/**
|
||||
* Generates a unique ID for the source based on the provided [name], [lang] and
|
||||
* [versionId]. It will use the first 16 characters (64 bits) of the MD5 of the string
|
||||
* `"${name.lowercase()}/$lang/$versionId"`.
|
||||
*
|
||||
* Note: the generated ID sets the sign bit to `0`.
|
||||
*
|
||||
* Can be used to generate outdated IDs, such as when the source name or language
|
||||
* needs to be changed but migrations can be avoided.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param name [String] the name of the source
|
||||
* @param lang [String] the language of the source
|
||||
* @param versionId [Int] the version ID of the source
|
||||
* @return a unique ID for the source
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
protected fun generateId(name: String, lang: String, versionId: Int): Long {
|
||||
val key = "${name.lowercase()}/$lang/$versionId"
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers builder for requests. Implementations can override this method for custom headers.
|
||||
*/
|
||||
|
@ -80,7 +105,7 @@ abstract class HttpSource : CatalogueSource {
|
|||
/**
|
||||
* Visible name of the source.
|
||||
*/
|
||||
override fun toString() = "$name (${lang.uppercase(Locale.getDefault())})"
|
||||
override fun toString() = "$name (${lang.uppercase()})"
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||
|
@ -124,9 +149,20 @@ abstract class HttpSource : CatalogueSource {
|
|||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
override fun fetchSearchManga(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Observable<MangasPage> {
|
||||
return Observable.defer {
|
||||
try {
|
||||
client.newCall(searchMangaRequest(page, query, filters)).asObservableSuccess()
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
// RxJava doesn't handle Errors, which tends to happen during global searches
|
||||
// if an old extension using non-existent classes is still around
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
.map { response ->
|
||||
searchMangaParse(response)
|
||||
}
|
||||
|
@ -139,7 +175,11 @@ abstract class HttpSource : CatalogueSource {
|
|||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
protected abstract fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
|
||||
protected abstract fun searchMangaRequest(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): Request
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
|
@ -176,11 +216,18 @@ abstract class HttpSource : CatalogueSource {
|
|||
protected abstract fun latestUpdatesParse(response: Response): MangasPage
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
||||
* override this method.
|
||||
* Get the updated details for a manga.
|
||||
* Normally it's not needed to override this method.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
* @param manga the manga to update.
|
||||
* @return the updated manga.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
return fetchMangaDetails(manga).awaitSingle()
|
||||
}
|
||||
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
|
@ -207,11 +254,23 @@ abstract class HttpSource : CatalogueSource {
|
|||
protected abstract fun mangaDetailsParse(response: Response): SManga
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||
* override this method. If a manga is licensed an empty chapter list observable is returned
|
||||
* Get all the available chapters for a manga.
|
||||
* Normally it's not needed to override this method.
|
||||
*
|
||||
* @param manga the manga to look for chapters.
|
||||
* @param manga the manga to update.
|
||||
* @return the chapters for the manga.
|
||||
* @throws LicensedMangaChaptersException if a manga is licensed and therefore no chapters are available.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
override suspend fun getChapterList(manga: SManga): List<SChapter> {
|
||||
if (manga.status == SManga.LICENSED) {
|
||||
throw LicensedMangaChaptersException()
|
||||
}
|
||||
|
||||
return fetchChapterList(manga).awaitSingle()
|
||||
}
|
||||
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return if (manga.status != SManga.LICENSED) {
|
||||
client.newCall(chapterListRequest(manga))
|
||||
|
@ -220,7 +279,7 @@ abstract class HttpSource : CatalogueSource {
|
|||
chapterListParse(response)
|
||||
}
|
||||
} else {
|
||||
Observable.error(Exception("Licensed - No chapters to show"))
|
||||
Observable.error(LicensedMangaChaptersException())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -242,10 +301,18 @@ abstract class HttpSource : CatalogueSource {
|
|||
protected abstract fun chapterListParse(response: Response): List<SChapter>
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter.
|
||||
* Get the list of pages a chapter has. Pages should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
* @param chapter the chapter.
|
||||
* @return the pages for the chapter.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
override suspend fun getPageList(chapter: SChapter): List<Page> {
|
||||
return fetchPageList(chapter).awaitSingle()
|
||||
}
|
||||
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
|
@ -275,8 +342,15 @@ abstract class HttpSource : CatalogueSource {
|
|||
* Returns an observable with the page containing the source url of the image. If there's any
|
||||
* error, it will return null instead of throwing an exception.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param page the page whose source image has to be fetched.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
open suspend fun getImageUrl(page: Page): String {
|
||||
return fetchImageUrl(page).awaitSingle()
|
||||
}
|
||||
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
|
||||
open fun fetchImageUrl(page: Page): Observable<String> {
|
||||
return client.newCall(imageUrlRequest(page))
|
||||
.asObservableSuccess()
|
||||
|
@ -300,23 +374,14 @@ abstract class HttpSource : CatalogueSource {
|
|||
*/
|
||||
protected abstract fun imageUrlParse(response: Response): String
|
||||
|
||||
/**
|
||||
* Returns an observable with the response of the source image.
|
||||
*
|
||||
* @param page the page whose source image has to be downloaded.
|
||||
*/
|
||||
fun fetchImage(page: Page): Observable<Response> {
|
||||
return client.newCachelessCallWithProgress(imageRequest(page), page)
|
||||
.asObservableSuccess()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the response of the source image.
|
||||
* Typically does not need to be overridden.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param page the page whose source image has to be downloaded.
|
||||
*/
|
||||
suspend fun getImage(page: Page): Response {
|
||||
// images will be cached or saved manually, so don't take up network cache
|
||||
open suspend fun getImage(page: Page): Response {
|
||||
return client.newCachelessCallWithProgress(imageRequest(page), page)
|
||||
.awaitSuccess()
|
||||
}
|
||||
|
@ -438,11 +503,12 @@ abstract class HttpSource : CatalogueSource {
|
|||
* @param chapter the chapter to be added.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {
|
||||
}
|
||||
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {}
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
override fun getFilterList() = FilterList()
|
||||
}
|
||||
|
||||
class LicensedMangaChaptersException : Exception("Licensed - No chapters to show")
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
package eu.kanade.tachiyomi.source.online
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
|
||||
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||
page.status = Page.State.LOAD_PAGE
|
||||
return fetchImageUrl(page)
|
||||
.doOnError { page.status = Page.State.ERROR }
|
||||
.onErrorReturn { null }
|
||||
.doOnNext { page.imageUrl = it }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||
return Observable.from(pages)
|
||||
.filter { !it.imageUrl.isNullOrEmpty() }
|
||||
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
|
||||
}
|
||||
|
||||
fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||
return Observable.from(pages)
|
||||
.filter { it.imageUrl.isNullOrEmpty() }
|
||||
.concatMap { getImageUrl(it) }
|
||||
}
|
|
@ -34,8 +34,9 @@ import eu.kanade.tachiyomi.extension.model.Extension
|
|||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.getPreferenceKey
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.preferenceKey
|
||||
import eu.kanade.tachiyomi.source.sourcePreferences
|
||||
import eu.kanade.tachiyomi.ui.base.controller.BaseCoroutineController
|
||||
import eu.kanade.tachiyomi.ui.setting.DSL
|
||||
import eu.kanade.tachiyomi.ui.setting.onChange
|
||||
|
@ -231,7 +232,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
|
|||
|
||||
val prefs = mutableListOf<Preference>()
|
||||
val block: (@DSL SwitchPreferenceCompat).() -> Unit = {
|
||||
key = source.getPreferenceKey() + "_enabled"
|
||||
key = source.preferenceKey() + "_enabled"
|
||||
title = when {
|
||||
isMultiSource && !isMultiLangSingleSource -> source.toString()
|
||||
else -> LocaleHelper.getSourceDisplayName(source.lang, context)
|
||||
|
@ -278,9 +279,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
|
|||
val newScreen = screen.preferenceManager.createPreferenceScreen(context)
|
||||
source.setupPreferenceScreen(newScreen)
|
||||
|
||||
val dataStore = SharedPreferencesDataStore(
|
||||
context.getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE),
|
||||
)
|
||||
val dataStore = SharedPreferencesDataStore(source.sourcePreferences())
|
||||
// Reparent the preferences
|
||||
while (newScreen.preferenceCount != 0) {
|
||||
val pref = newScreen.getPreference(0)
|
||||
|
|
|
@ -6,7 +6,6 @@ import eu.kanade.tachiyomi.source.model.Page
|
|||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.system.awaitSingle
|
||||
import eu.kanade.tachiyomi.util.system.launchIO
|
||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
@ -195,7 +194,7 @@ class HttpPageLoader(
|
|||
try {
|
||||
if (page.imageUrl.isNullOrEmpty()) {
|
||||
page.status = Page.State.LOAD_PAGE
|
||||
page.imageUrl = source.fetchImageUrl(page).awaitSingle()
|
||||
page.imageUrl = source.getImageUrl(page)
|
||||
}
|
||||
val imageUrl = page.imageUrl!!
|
||||
|
||||
|
|
|
@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.ui.source.browse
|
|||
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.util.system.awaitSingle
|
||||
|
||||
open class BrowseSourcePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() {
|
||||
|
||||
override suspend fun requestNextPage() {
|
||||
val page = currentPage
|
||||
|
||||
val observable = if (query.isBlank() && filters.isEmpty()) {
|
||||
source.fetchPopularManga(page)
|
||||
val mangasPage = if (query.isBlank() && filters.isEmpty()) {
|
||||
source.getPopularManga(page)
|
||||
} else {
|
||||
source.fetchSearchManga(page, query, filters)
|
||||
source.getSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
val mangasPage = observable.awaitSingle()
|
||||
|
||||
if (mangasPage.mangas.isNotEmpty()) {
|
||||
onPageReceived(mangasPage)
|
||||
} else {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package eu.kanade.tachiyomi.ui.source.browse
|
||||
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.util.system.awaitSingle
|
||||
|
||||
/**
|
||||
* LatestUpdatesPager inherited from the general Pager.
|
||||
|
@ -9,7 +8,7 @@ import eu.kanade.tachiyomi.util.system.awaitSingle
|
|||
class LatestUpdatesPager(val source: CatalogueSource) : Pager() {
|
||||
|
||||
override suspend fun requestNextPage() {
|
||||
val mangasPage = source.fetchLatestUpdates(currentPage).awaitSingle()
|
||||
val mangasPage = source.getLatestUpdates(currentPage)
|
||||
onPageReceived(mangasPage)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.source.SourceManager
|
|||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter
|
||||
import eu.kanade.tachiyomi.util.system.awaitSingle
|
||||
import eu.kanade.tachiyomi.util.system.launchIO
|
||||
import eu.kanade.tachiyomi.util.system.launchUI
|
||||
import eu.kanade.tachiyomi.util.system.withUIContext
|
||||
|
@ -176,7 +175,7 @@ open class GlobalSearchPresenter(
|
|||
return@mainLaunch
|
||||
}
|
||||
val mangas = try {
|
||||
source.fetchSearchManga(1, query, source.getFilterList()).awaitSingle()
|
||||
source.getSearchManga(1, query, source.getFilterList())
|
||||
} catch (error: Exception) {
|
||||
MangasPage(emptyList(), false)
|
||||
}
|
||||
|
|
|
@ -22,5 +22,5 @@ fun Element.attrOrText(css: String): String {
|
|||
* @param html the body of the response. Use only if the body was read before calling this method.
|
||||
*/
|
||||
fun Response.asJsoup(html: String? = null): Document {
|
||||
return Jsoup.parse(html ?: body!!.string(), request.url.toString())
|
||||
return Jsoup.parse(html ?: body.string(), request.url.toString())
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue