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:
Jays2Kings 2023-10-04 18:16:53 -07:00
parent 7fcd744c98
commit d0b0e7c66c
34 changed files with 956 additions and 611 deletions

View file

@ -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
@ -323,4 +342,4 @@ tasks {
preBuild {
dependsOn(formatKotlin, copyHebrewStrings)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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