mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Extract source api from app module (#8014)
* Extract source api from app module * Extract source online api from app module
This commit is contained in:
		| @@ -33,6 +33,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.source.model.copyFrom | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import eu.kanade.tachiyomi.util.system.toLong | ||||
| import kotlinx.serialization.protobuf.ProtoBuf | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType | ||||
| import eu.kanade.tachiyomi.util.system.DeviceUtil | ||||
| import eu.kanade.tachiyomi.util.system.LocaleHelper | ||||
| import eu.kanade.tachiyomi.util.system.isDevFlavor | ||||
| import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView | ||||
| import java.io.File | ||||
| import java.text.DateFormat | ||||
|   | ||||
| @@ -1,54 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.webkit.CookieManager | ||||
| import okhttp3.Cookie | ||||
| import okhttp3.CookieJar | ||||
| import okhttp3.HttpUrl | ||||
|  | ||||
| class AndroidCookieJar : CookieJar { | ||||
|  | ||||
|     private val manager = CookieManager.getInstance() | ||||
|  | ||||
|     override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { | ||||
|         val urlString = url.toString() | ||||
|  | ||||
|         cookies.forEach { manager.setCookie(urlString, it.toString()) } | ||||
|     } | ||||
|  | ||||
|     override fun loadForRequest(url: HttpUrl): List<Cookie> { | ||||
|         return get(url) | ||||
|     } | ||||
|  | ||||
|     fun get(url: HttpUrl): List<Cookie> { | ||||
|         val cookies = manager.getCookie(url.toString()) | ||||
|  | ||||
|         return if (cookies != null && cookies.isNotEmpty()) { | ||||
|             cookies.split(";").mapNotNull { Cookie.parse(url, it) } | ||||
|         } else { | ||||
|             emptyList() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1): Int { | ||||
|         val urlString = url.toString() | ||||
|         val cookies = manager.getCookie(urlString) ?: return 0 | ||||
|  | ||||
|         fun List<String>.filterNames(): List<String> { | ||||
|             return if (cookieNames != null) { | ||||
|                 this.filter { it in cookieNames } | ||||
|             } else { | ||||
|                 this | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return cookies.split(";") | ||||
|             .map { it.substringBefore("=") } | ||||
|             .filterNames() | ||||
|             .onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") } | ||||
|             .count() | ||||
|     } | ||||
|  | ||||
|     fun removeAll() { | ||||
|         manager.removeAllCookies {} | ||||
|     } | ||||
| } | ||||
| @@ -1,174 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.HttpUrl.Companion.toHttpUrl | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.dnsoverhttps.DnsOverHttps | ||||
| import java.net.InetAddress | ||||
|  | ||||
| /** | ||||
|  * Based on https://github.com/square/okhttp/blob/ef5d0c83f7bbd3a0c0534e7ca23cbc4ee7550f3b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DohProviders.java | ||||
|  */ | ||||
|  | ||||
| const val PREF_DOH_CLOUDFLARE = 1 | ||||
| const val PREF_DOH_GOOGLE = 2 | ||||
| const val PREF_DOH_ADGUARD = 3 | ||||
| const val PREF_DOH_QUAD9 = 4 | ||||
| const val PREF_DOH_ALIDNS = 5 | ||||
| const val PREF_DOH_DNSPOD = 6 | ||||
| const val PREF_DOH_360 = 7 | ||||
| const val PREF_DOH_QUAD101 = 8 | ||||
| const val PREF_DOH_MULLVAD = 9 | ||||
| const val PREF_DOH_CONTROLD = 10 | ||||
| const val PREF_DOH_NJALLA = 11 | ||||
|  | ||||
| fun OkHttpClient.Builder.dohCloudflare() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("162.159.36.1"), | ||||
|             InetAddress.getByName("162.159.46.1"), | ||||
|             InetAddress.getByName("1.1.1.1"), | ||||
|             InetAddress.getByName("1.0.0.1"), | ||||
|             InetAddress.getByName("162.159.132.53"), | ||||
|             InetAddress.getByName("2606:4700:4700::1111"), | ||||
|             InetAddress.getByName("2606:4700:4700::1001"), | ||||
|             InetAddress.getByName("2606:4700:4700::0064"), | ||||
|             InetAddress.getByName("2606:4700:4700::6400"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| fun OkHttpClient.Builder.dohGoogle() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://dns.google/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("8.8.4.4"), | ||||
|             InetAddress.getByName("8.8.8.8"), | ||||
|             InetAddress.getByName("2001:4860:4860::8888"), | ||||
|             InetAddress.getByName("2001:4860:4860::8844"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| // AdGuard "Default" DNS works too but for the sake of making sure no site is blacklisted, | ||||
| // we use "Unfiltered" | ||||
| fun OkHttpClient.Builder.dohAdGuard() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("94.140.14.140"), | ||||
|             InetAddress.getByName("94.140.14.141"), | ||||
|             InetAddress.getByName("2a10:50c0::1:ff"), | ||||
|             InetAddress.getByName("2a10:50c0::2:ff"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| fun OkHttpClient.Builder.dohQuad9() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://dns.quad9.net/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("9.9.9.9"), | ||||
|             InetAddress.getByName("149.112.112.112"), | ||||
|             InetAddress.getByName("2620:fe::fe"), | ||||
|             InetAddress.getByName("2620:fe::9"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| fun OkHttpClient.Builder.dohAliDNS() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://dns.alidns.com/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("223.5.5.5"), | ||||
|             InetAddress.getByName("223.6.6.6"), | ||||
|             InetAddress.getByName("2400:3200::1"), | ||||
|             InetAddress.getByName("2400:3200:baba::1"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| fun OkHttpClient.Builder.dohDNSPod() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://doh.pub/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("1.12.12.12"), | ||||
|             InetAddress.getByName("120.53.53.53"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| fun OkHttpClient.Builder.doh360() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://doh.360.cn/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("101.226.4.6"), | ||||
|             InetAddress.getByName("218.30.118.6"), | ||||
|             InetAddress.getByName("123.125.81.6"), | ||||
|             InetAddress.getByName("140.207.198.6"), | ||||
|             InetAddress.getByName("180.163.249.75"), | ||||
|             InetAddress.getByName("101.199.113.208"), | ||||
|             InetAddress.getByName("36.99.170.86"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| fun OkHttpClient.Builder.dohQuad101() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://dns.twnic.tw/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("101.101.101.101"), | ||||
|             InetAddress.getByName("2001:de4::101"), | ||||
|             InetAddress.getByName("2001:de4::102"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| /* | ||||
|  * Mullvad DoH | ||||
|  * without ad blocking option | ||||
|  * Source : https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/ | ||||
|  */ | ||||
| fun OkHttpClient.Builder.dohMullvad() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://doh.mullvad.net/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("194.242.2.2"), | ||||
|             InetAddress.getByName("193.19.108.2"), | ||||
|             InetAddress.getByName("2a07:e340::2"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| /* | ||||
|  * Control D | ||||
|  * unfiltered option | ||||
|  * Source : https://controld.com/free-dns/? | ||||
|  */ | ||||
|  | ||||
| fun OkHttpClient.Builder.dohControlD() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://freedns.controld.com/p0".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("76.76.2.0"), | ||||
|             InetAddress.getByName("76.76.10.0"), | ||||
|             InetAddress.getByName("2606:1a40::"), | ||||
|             InetAddress.getByName("2606:1a40:1::"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
|  | ||||
| /* | ||||
|  * Njalla | ||||
|  * | ||||
|  * Non logging and uncensored | ||||
|  */ | ||||
| fun OkHttpClient.Builder.dohNajalla() = dns( | ||||
|     DnsOverHttps.Builder().client(build()) | ||||
|         .url("https://dns.njal.la/dns-query".toHttpUrl()) | ||||
|         .bootstrapDnsHosts( | ||||
|             InetAddress.getByName("95.215.19.53"), | ||||
|             InetAddress.getByName("2001:67c:2354:2::53"), | ||||
|         ) | ||||
|         .build(), | ||||
| ) | ||||
| @@ -1,75 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor | ||||
| import eu.kanade.tachiyomi.network.interceptor.Http103Interceptor | ||||
| import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor | ||||
| import okhttp3.Cache | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.logging.HttpLoggingInterceptor | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class NetworkHelper(context: Context) { | ||||
|  | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     private val cacheDir = File(context.cacheDir, "network_cache") | ||||
|     private val cacheSize = 5L * 1024 * 1024 // 5 MiB | ||||
|  | ||||
|     val cookieManager = AndroidCookieJar() | ||||
|  | ||||
|     private val userAgentInterceptor by lazy { UserAgentInterceptor() } | ||||
|     private val http103Interceptor by lazy { Http103Interceptor(context) } | ||||
|     private val cloudflareInterceptor by lazy { CloudflareInterceptor(context) } | ||||
|  | ||||
|     private val baseClientBuilder: OkHttpClient.Builder | ||||
|         get() { | ||||
|             val builder = OkHttpClient.Builder() | ||||
|                 .cookieJar(cookieManager) | ||||
|                 .connectTimeout(30, TimeUnit.SECONDS) | ||||
|                 .readTimeout(30, TimeUnit.SECONDS) | ||||
|                 .callTimeout(2, TimeUnit.MINUTES) | ||||
|                 .fastFallback(true) | ||||
|                 .addInterceptor(userAgentInterceptor) | ||||
|                 .addNetworkInterceptor(http103Interceptor) | ||||
|  | ||||
|             if (preferences.verboseLogging()) { | ||||
|                 val httpLoggingInterceptor = HttpLoggingInterceptor().apply { | ||||
|                     level = HttpLoggingInterceptor.Level.HEADERS | ||||
|                 } | ||||
|                 builder.addNetworkInterceptor(httpLoggingInterceptor) | ||||
|             } | ||||
|  | ||||
|             when (preferences.dohProvider()) { | ||||
|                 PREF_DOH_CLOUDFLARE -> builder.dohCloudflare() | ||||
|                 PREF_DOH_GOOGLE -> builder.dohGoogle() | ||||
|                 PREF_DOH_ADGUARD -> builder.dohAdGuard() | ||||
|                 PREF_DOH_QUAD9 -> builder.dohQuad9() | ||||
|                 PREF_DOH_ALIDNS -> builder.dohAliDNS() | ||||
|                 PREF_DOH_DNSPOD -> builder.dohDNSPod() | ||||
|                 PREF_DOH_360 -> builder.doh360() | ||||
|                 PREF_DOH_QUAD101 -> builder.dohQuad101() | ||||
|                 PREF_DOH_MULLVAD -> builder.dohMullvad() | ||||
|                 PREF_DOH_CONTROLD -> builder.dohControlD() | ||||
|                 PREF_DOH_NJALLA -> builder.dohNajalla() | ||||
|             } | ||||
|  | ||||
|             return builder | ||||
|         } | ||||
|  | ||||
|     val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() } | ||||
|  | ||||
|     @Suppress("UNUSED") | ||||
|     val cloudflareClient by lazy { | ||||
|         client.newBuilder() | ||||
|             .addInterceptor(cloudflareInterceptor) | ||||
|             .build() | ||||
|     } | ||||
|  | ||||
|     val defaultUserAgent by lazy { | ||||
|         preferences.defaultUserAgent().get() | ||||
|     } | ||||
| } | ||||
| @@ -1,127 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import okhttp3.Call | ||||
| import okhttp3.Callback | ||||
| import okhttp3.MediaType.Companion.toMediaType | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import okhttp3.internal.closeQuietly | ||||
| import rx.Observable | ||||
| import rx.Producer | ||||
| import rx.Subscription | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.fullType | ||||
| import java.io.IOException | ||||
| import java.util.concurrent.atomic.AtomicBoolean | ||||
| import kotlin.coroutines.resumeWithException | ||||
|  | ||||
| val jsonMime = "application/json; charset=utf-8".toMediaType() | ||||
|  | ||||
| fun Call.asObservable(): Observable<Response> { | ||||
|     return Observable.unsafeCreate { subscriber -> | ||||
|         // Since Call is a one-shot type, clone it for each new subscriber. | ||||
|         val call = clone() | ||||
|  | ||||
|         // Wrap the call in a helper which handles both unsubscription and backpressure. | ||||
|         val requestArbiter = object : AtomicBoolean(), Producer, Subscription { | ||||
|             override fun request(n: Long) { | ||||
|                 if (n == 0L || !compareAndSet(false, true)) return | ||||
|  | ||||
|                 try { | ||||
|                     val response = call.execute() | ||||
|                     if (!subscriber.isUnsubscribed) { | ||||
|                         subscriber.onNext(response) | ||||
|                         subscriber.onCompleted() | ||||
|                     } | ||||
|                 } catch (error: Exception) { | ||||
|                     if (!subscriber.isUnsubscribed) { | ||||
|                         subscriber.onError(error) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             override fun unsubscribe() { | ||||
|                 call.cancel() | ||||
|             } | ||||
|  | ||||
|             override fun isUnsubscribed(): Boolean { | ||||
|                 return call.isCanceled() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         subscriber.add(requestArbiter) | ||||
|         subscriber.setProducer(requestArbiter) | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Based on https://github.com/gildor/kotlin-coroutines-okhttp | ||||
| suspend fun Call.await(): Response { | ||||
|     return suspendCancellableCoroutine { continuation -> | ||||
|         enqueue( | ||||
|             object : Callback { | ||||
|                 override fun onResponse(call: Call, response: Response) { | ||||
|                     if (!response.isSuccessful) { | ||||
|                         continuation.resumeWithException(HttpException(response.code)) | ||||
|                         return | ||||
|                     } | ||||
|  | ||||
|                     continuation.resume(response) { | ||||
|                         response.body.closeQuietly() | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onFailure(call: Call, e: IOException) { | ||||
|                     // Don't bother with resuming the continuation if it is already cancelled. | ||||
|                     if (continuation.isCancelled) return | ||||
|                     continuation.resumeWithException(e) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         continuation.invokeOnCancellation { | ||||
|             try { | ||||
|                 cancel() | ||||
|             } catch (ex: Throwable) { | ||||
|                 // Ignore cancel exception | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Call.asObservableSuccess(): Observable<Response> { | ||||
|     return asObservable().doOnNext { response -> | ||||
|         if (!response.isSuccessful) { | ||||
|             response.close() | ||||
|             throw HttpException(response.code) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { | ||||
|     val progressClient = newBuilder() | ||||
|         .cache(null) | ||||
|         .addNetworkInterceptor { chain -> | ||||
|             val originalResponse = chain.proceed(chain.request()) | ||||
|             originalResponse.newBuilder() | ||||
|                 .body(ProgressResponseBody(originalResponse.body, listener)) | ||||
|                 .build() | ||||
|         } | ||||
|         .build() | ||||
|  | ||||
|     return progressClient.newCall(request) | ||||
| } | ||||
|  | ||||
| inline fun <reified T> Response.parseAs(): T { | ||||
|     // Avoiding Injekt.get<Json>() due to compiler issues | ||||
|     val json = Injekt.getInstance<Json>(fullType<Json>().type) | ||||
|     this.use { | ||||
|         val responseBody = it.body.string() | ||||
|         return json.decodeFromString(responseBody) | ||||
|     } | ||||
| } | ||||
|  | ||||
| class HttpException(val code: Int) : IllegalStateException("HTTP error $code") | ||||
| @@ -1,5 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| interface ProgressListener { | ||||
|     fun update(bytesRead: Long, contentLength: Long, done: Boolean) | ||||
| } | ||||
| @@ -1,44 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.MediaType | ||||
| import okhttp3.ResponseBody | ||||
| import okio.Buffer | ||||
| import okio.BufferedSource | ||||
| import okio.ForwardingSource | ||||
| import okio.Source | ||||
| import okio.buffer | ||||
| import java.io.IOException | ||||
|  | ||||
| class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { | ||||
|  | ||||
|     private val bufferedSource: BufferedSource by lazy { | ||||
|         source(responseBody.source()).buffer() | ||||
|     } | ||||
|  | ||||
|     override fun contentType(): MediaType? { | ||||
|         return responseBody.contentType() | ||||
|     } | ||||
|  | ||||
|     override fun contentLength(): Long { | ||||
|         return responseBody.contentLength() | ||||
|     } | ||||
|  | ||||
|     override fun source(): BufferedSource { | ||||
|         return bufferedSource | ||||
|     } | ||||
|  | ||||
|     private fun source(source: Source): Source { | ||||
|         return object : ForwardingSource(source) { | ||||
|             var totalBytesRead = 0L | ||||
|  | ||||
|             @Throws(IOException::class) | ||||
|             override fun read(sink: Buffer, byteCount: Long): Long { | ||||
|                 val bytesRead = super.read(sink, byteCount) | ||||
|                 // read() returns the number of bytes read, or -1 if this source is exhausted. | ||||
|                 totalBytesRead += if (bytesRead != -1L) bytesRead else 0 | ||||
|                 progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) | ||||
|                 return bytesRead | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,67 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.CacheControl | ||||
| import okhttp3.FormBody | ||||
| import okhttp3.Headers | ||||
| import okhttp3.Request | ||||
| import okhttp3.RequestBody | ||||
| import java.util.concurrent.TimeUnit.MINUTES | ||||
|  | ||||
| private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() | ||||
| private val DEFAULT_HEADERS = Headers.Builder().build() | ||||
| private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() | ||||
| internal val CACHE_CONTROL_NO_STORE = CacheControl.Builder().noStore().build() | ||||
|  | ||||
| fun GET( | ||||
|     url: String, | ||||
|     headers: Headers = DEFAULT_HEADERS, | ||||
|     cache: CacheControl = DEFAULT_CACHE_CONTROL, | ||||
| ): Request { | ||||
|     return Request.Builder() | ||||
|         .url(url) | ||||
|         .headers(headers) | ||||
|         .cacheControl(cache) | ||||
|         .build() | ||||
| } | ||||
|  | ||||
| fun POST( | ||||
|     url: String, | ||||
|     headers: Headers = DEFAULT_HEADERS, | ||||
|     body: RequestBody = DEFAULT_BODY, | ||||
|     cache: CacheControl = DEFAULT_CACHE_CONTROL, | ||||
| ): Request { | ||||
|     return Request.Builder() | ||||
|         .url(url) | ||||
|         .post(body) | ||||
|         .headers(headers) | ||||
|         .cacheControl(cache) | ||||
|         .build() | ||||
| } | ||||
|  | ||||
| fun PUT( | ||||
|     url: String, | ||||
|     headers: Headers = DEFAULT_HEADERS, | ||||
|     body: RequestBody = DEFAULT_BODY, | ||||
|     cache: CacheControl = DEFAULT_CACHE_CONTROL, | ||||
| ): Request { | ||||
|     return Request.Builder() | ||||
|         .url(url) | ||||
|         .put(body) | ||||
|         .headers(headers) | ||||
|         .cacheControl(cache) | ||||
|         .build() | ||||
| } | ||||
|  | ||||
| fun DELETE( | ||||
|     url: String, | ||||
|     headers: Headers = DEFAULT_HEADERS, | ||||
|     body: RequestBody = DEFAULT_BODY, | ||||
|     cache: CacheControl = DEFAULT_CACHE_CONTROL, | ||||
| ): Request { | ||||
|     return Request.Builder() | ||||
|         .url(url) | ||||
|         .delete(body) | ||||
|         .headers(headers) | ||||
|         .cacheControl(cache) | ||||
|         .build() | ||||
| } | ||||
| @@ -1,149 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network.interceptor | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import android.webkit.WebView | ||||
| import android.widget.Toast | ||||
| import androidx.core.content.ContextCompat | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.util.system.WebViewClientCompat | ||||
| import eu.kanade.tachiyomi.util.system.isOutdated | ||||
| import eu.kanade.tachiyomi.util.system.setDefaultSettings | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import okhttp3.Cookie | ||||
| import okhttp3.HttpUrl.Companion.toHttpUrl | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.IOException | ||||
| import java.util.concurrent.CountDownLatch | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class CloudflareInterceptor(private val context: Context) : WebViewInterceptor(context) { | ||||
|  | ||||
|     private val executor = ContextCompat.getMainExecutor(context) | ||||
|  | ||||
|     private val networkHelper: NetworkHelper by injectLazy() | ||||
|  | ||||
|     override fun shouldIntercept(response: Response): Boolean { | ||||
|         // Check if Cloudflare anti-bot is on | ||||
|         return response.code in ERROR_CODES && response.header("Server") in SERVER_CHECK | ||||
|     } | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response { | ||||
|         try { | ||||
|             response.close() | ||||
|             networkHelper.cookieManager.remove(request.url, COOKIE_NAMES, 0) | ||||
|             val oldCookie = networkHelper.cookieManager.get(request.url) | ||||
|                 .firstOrNull { it.name == "cf_clearance" } | ||||
|             resolveWithWebView(request, oldCookie) | ||||
|  | ||||
|             return chain.proceed(request) | ||||
|         } | ||||
|         // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that | ||||
|         // we don't crash the entire app | ||||
|         catch (e: CloudflareBypassException) { | ||||
|             throw IOException(context.getString(R.string.information_cloudflare_bypass_failure)) | ||||
|         } catch (e: Exception) { | ||||
|             throw IOException(e) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetJavaScriptEnabled") | ||||
|     private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) { | ||||
|         // We need to lock this thread until the WebView finds the challenge solution url, because | ||||
|         // OkHttp doesn't support asynchronous interceptors. | ||||
|         val latch = CountDownLatch(1) | ||||
|  | ||||
|         var webView: WebView? = null | ||||
|  | ||||
|         var challengeFound = false | ||||
|         var cloudflareBypassed = false | ||||
|         var isWebViewOutdated = false | ||||
|  | ||||
|         val origRequestUrl = originalRequest.url.toString() | ||||
|         val headers = originalRequest.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap() | ||||
|  | ||||
|         executor.execute { | ||||
|             val webview = WebView(context) | ||||
|             webView = webview | ||||
|             webview.setDefaultSettings() | ||||
|  | ||||
|             // Avoid sending empty User-Agent, Chromium WebView will reset to default if empty | ||||
|             webview.settings.userAgentString = originalRequest.header("User-Agent") | ||||
|                 ?: networkHelper.defaultUserAgent | ||||
|  | ||||
|             webview.webViewClient = object : WebViewClientCompat() { | ||||
|                 override fun onPageFinished(view: WebView, url: String) { | ||||
|                     fun isCloudFlareBypassed(): Boolean { | ||||
|                         return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl()) | ||||
|                             .firstOrNull { it.name == "cf_clearance" } | ||||
|                             .let { it != null && it != oldCookie } | ||||
|                     } | ||||
|  | ||||
|                     if (isCloudFlareBypassed()) { | ||||
|                         cloudflareBypassed = true | ||||
|                         latch.countDown() | ||||
|                     } | ||||
|  | ||||
|                     if (url == origRequestUrl && !challengeFound) { | ||||
|                         // The first request didn't return the challenge, abort. | ||||
|                         latch.countDown() | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onReceivedErrorCompat( | ||||
|                     view: WebView, | ||||
|                     errorCode: Int, | ||||
|                     description: String?, | ||||
|                     failingUrl: String, | ||||
|                     isMainFrame: Boolean, | ||||
|                 ) { | ||||
|                     if (isMainFrame) { | ||||
|                         if (errorCode in ERROR_CODES) { | ||||
|                             // Found the Cloudflare challenge page. | ||||
|                             challengeFound = true | ||||
|                         } else { | ||||
|                             // Unlock thread, the challenge wasn't found. | ||||
|                             latch.countDown() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             webView?.loadUrl(origRequestUrl, headers) | ||||
|         } | ||||
|  | ||||
|         // Wait a reasonable amount of time to retrieve the solution. The minimum should be | ||||
|         // around 4 seconds but it can take more due to slow networks or server issues. | ||||
|         latch.await(12, TimeUnit.SECONDS) | ||||
|  | ||||
|         executor.execute { | ||||
|             if (!cloudflareBypassed) { | ||||
|                 isWebViewOutdated = webView?.isOutdated() == true | ||||
|             } | ||||
|  | ||||
|             webView?.stopLoading() | ||||
|             webView?.destroy() | ||||
|             webView = null | ||||
|         } | ||||
|  | ||||
|         // Throw exception if we failed to bypass Cloudflare | ||||
|         if (!cloudflareBypassed) { | ||||
|             // Prompt user to update WebView if it seems too outdated | ||||
|             if (isWebViewOutdated) { | ||||
|                 context.toast(R.string.information_webview_outdated, Toast.LENGTH_LONG) | ||||
|             } | ||||
|  | ||||
|             throw CloudflareBypassException() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| private val ERROR_CODES = listOf(403, 503) | ||||
| private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") | ||||
| private val COOKIE_NAMES = listOf("cf_clearance") | ||||
|  | ||||
| private class CloudflareBypassException : Exception() | ||||
| @@ -1,112 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network.interceptor | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import android.webkit.JavascriptInterface | ||||
| import android.webkit.WebView | ||||
| import androidx.core.content.ContextCompat | ||||
| import eu.kanade.tachiyomi.util.system.WebViewClientCompat | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.MediaType.Companion.toMediaType | ||||
| import okhttp3.Protocol | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import okhttp3.ResponseBody.Companion.toResponseBody | ||||
| import java.io.IOException | ||||
| import java.util.concurrent.CountDownLatch | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| // TODO: Remove when OkHttp can handle HTTP 103 responses | ||||
| class Http103Interceptor(context: Context) : WebViewInterceptor(context) { | ||||
|  | ||||
|     private val executor = ContextCompat.getMainExecutor(context) | ||||
|  | ||||
|     override fun shouldIntercept(response: Response): Boolean { | ||||
|         return response.code == 103 | ||||
|     } | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response { | ||||
|         logcat { "Proceeding with WebView for request $request" } | ||||
|         try { | ||||
|             return proceedWithWebView(request, response) | ||||
|         } catch (e: Exception) { | ||||
|             throw IOException(e) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") | ||||
|     private fun proceedWithWebView(originalRequest: Request, originalResponse: Response): Response { | ||||
|         // We need to lock this thread until the WebView loads the page, because | ||||
|         // OkHttp doesn't support asynchronous interceptors. | ||||
|         val latch = CountDownLatch(1) | ||||
|  | ||||
|         val jsInterface = JsInterface(latch) | ||||
|  | ||||
|         var outerWebView: WebView? = null | ||||
|  | ||||
|         var exception: Exception? = null | ||||
|  | ||||
|         val requestUrl = originalRequest.url.toString() | ||||
|         val headers = originalRequest.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap() | ||||
|  | ||||
|         executor.execute { | ||||
|             val webview = createWebView(originalRequest).also { outerWebView = it } | ||||
|             webview.addJavascriptInterface(jsInterface, "android") | ||||
|  | ||||
|             webview.webViewClient = object : WebViewClientCompat() { | ||||
|                 override fun onPageFinished(view: WebView, url: String) { | ||||
|                     view.evaluateJavascript(jsScript) {} | ||||
|                 } | ||||
|  | ||||
|                 override fun onReceivedErrorCompat( | ||||
|                     view: WebView, | ||||
|                     errorCode: Int, | ||||
|                     description: String?, | ||||
|                     failingUrl: String, | ||||
|                     isMainFrame: Boolean, | ||||
|                 ) { | ||||
|                     if (isMainFrame) { | ||||
|                         exception = Exception("Error $errorCode - $description") | ||||
|                         latch.countDown() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             webview.loadUrl(requestUrl, headers) | ||||
|         } | ||||
|  | ||||
|         latch.await(10, TimeUnit.SECONDS) | ||||
|  | ||||
|         executor.execute { | ||||
|             outerWebView?.run { | ||||
|                 stopLoading() | ||||
|                 destroy() | ||||
|             } | ||||
|             outerWebView = null | ||||
|         } | ||||
|  | ||||
|         exception?.let { throw it } | ||||
|  | ||||
|         val responseHtml = jsInterface.responseHtml ?: throw Exception("Couldn't fetch site through webview") | ||||
|  | ||||
|         return originalResponse.newBuilder() | ||||
|             .code(200) | ||||
|             .protocol(Protocol.HTTP_1_1) | ||||
|             .message("OK") | ||||
|             .body(responseHtml.toResponseBody(htmlMediaType)) | ||||
|             .build() | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal class JsInterface(private val latch: CountDownLatch, var responseHtml: String? = null) { | ||||
|     @Suppress("UNUSED") | ||||
|     @JavascriptInterface | ||||
|     fun passPayload(passedPayload: String) { | ||||
|         responseHtml = passedPayload | ||||
|         latch.countDown() | ||||
|     } | ||||
| } | ||||
|  | ||||
| private const val jsScript = "window.android.passPayload(document.querySelector('html').outerHTML)" | ||||
| private val htmlMediaType = "text/html".toMediaType() | ||||
| @@ -1,105 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network.interceptor | ||||
|  | ||||
| import android.os.SystemClock | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Response | ||||
| import java.io.IOException | ||||
| import java.util.ArrayDeque | ||||
| import java.util.concurrent.Semaphore | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| /** | ||||
|  * An OkHttp interceptor that handles rate limiting. | ||||
|  * | ||||
|  * Examples: | ||||
|  * | ||||
|  * permits = 5,  period = 1, unit = seconds  =>  5 requests per second | ||||
|  * permits = 10, period = 2, unit = minutes  =>  10 requests per 2 minutes | ||||
|  * | ||||
|  * @since extension-lib 1.3 | ||||
|  * | ||||
|  * @param permits {Int}   Number of requests allowed within a period of units. | ||||
|  * @param period {Long}   The limiting duration. Defaults to 1. | ||||
|  * @param unit {TimeUnit} The unit of time for the period. Defaults to seconds. | ||||
|  */ | ||||
| fun OkHttpClient.Builder.rateLimit( | ||||
|     permits: Int, | ||||
|     period: Long = 1, | ||||
|     unit: TimeUnit = TimeUnit.SECONDS, | ||||
| ) = addInterceptor(RateLimitInterceptor(null, permits, period, unit)) | ||||
|  | ||||
| /** We can probably accept domains or wildcards by comparing with [endsWith], etc. */ | ||||
| @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") | ||||
| internal class RateLimitInterceptor( | ||||
|     private val host: String?, | ||||
|     private val permits: Int, | ||||
|     period: Long, | ||||
|     unit: TimeUnit, | ||||
| ) : Interceptor { | ||||
|  | ||||
|     private val requestQueue = ArrayDeque<Long>(permits) | ||||
|     private val rateLimitMillis = unit.toMillis(period) | ||||
|     private val fairLock = Semaphore(1, true) | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val call = chain.call() | ||||
|         if (call.isCanceled()) throw IOException("Canceled") | ||||
|  | ||||
|         val request = chain.request() | ||||
|         when (host) { | ||||
|             null, request.url.host -> {} // need rate limit | ||||
|             else -> return chain.proceed(request) | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             fairLock.acquire() | ||||
|         } catch (e: InterruptedException) { | ||||
|             throw IOException(e) | ||||
|         } | ||||
|  | ||||
|         val requestQueue = this.requestQueue | ||||
|         val timestamp: Long | ||||
|  | ||||
|         try { | ||||
|             synchronized(requestQueue) { | ||||
|                 while (requestQueue.size >= permits) { // queue is full, remove expired entries | ||||
|                     val periodStart = SystemClock.elapsedRealtime() - rateLimitMillis | ||||
|                     var hasRemovedExpired = false | ||||
|                     while (requestQueue.isEmpty().not() && requestQueue.first <= periodStart) { | ||||
|                         requestQueue.removeFirst() | ||||
|                         hasRemovedExpired = true | ||||
|                     } | ||||
|                     if (call.isCanceled()) { | ||||
|                         throw IOException("Canceled") | ||||
|                     } else if (hasRemovedExpired) { | ||||
|                         break | ||||
|                     } else { | ||||
|                         try { // wait for the first entry to expire, or notified by cached response | ||||
|                             (requestQueue as Object).wait(requestQueue.first - periodStart) | ||||
|                         } catch (_: InterruptedException) { | ||||
|                             continue | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // add request to queue | ||||
|                 timestamp = SystemClock.elapsedRealtime() | ||||
|                 requestQueue.addLast(timestamp) | ||||
|             } | ||||
|         } finally { | ||||
|             fairLock.release() | ||||
|         } | ||||
|  | ||||
|         val response = chain.proceed(request) | ||||
|         if (response.networkResponse == null) { // response is cached, remove it from queue | ||||
|             synchronized(requestQueue) { | ||||
|                 if (requestQueue.isEmpty() || timestamp < requestQueue.first) return@synchronized | ||||
|                 requestQueue.removeFirstOccurrence(timestamp) | ||||
|                 (requestQueue as Object).notifyAll() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return response | ||||
|     } | ||||
| } | ||||
| @@ -1,27 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network.interceptor | ||||
|  | ||||
| import okhttp3.HttpUrl | ||||
| import okhttp3.OkHttpClient | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| /** | ||||
|  * An OkHttp interceptor that handles given url host's rate limiting. | ||||
|  * | ||||
|  * Examples: | ||||
|  * | ||||
|  * httpUrl = "api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1, unit = seconds  =>  5 requests per second to api.manga.com | ||||
|  * httpUrl = "imagecdn.manga.com".toHttpUrlOrNull(), permits = 10, period = 2, unit = minutes  =>  10 requests per 2 minutes to imagecdn.manga.com | ||||
|  * | ||||
|  * @since extension-lib 1.3 | ||||
|  * | ||||
|  * @param httpUrl {HttpUrl} The url host that this interceptor should handle. Will get url's host by using HttpUrl.host() | ||||
|  * @param permits {Int}   Number of requests allowed within a period of units. | ||||
|  * @param period {Long}   The limiting duration. Defaults to 1. | ||||
|  * @param unit {TimeUnit} The unit of time for the period. Defaults to seconds. | ||||
|  */ | ||||
| fun OkHttpClient.Builder.rateLimitHost( | ||||
|     httpUrl: HttpUrl, | ||||
|     permits: Int, | ||||
|     period: Long = 1, | ||||
|     unit: TimeUnit = TimeUnit.SECONDS, | ||||
| ) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period, unit)) | ||||
| @@ -1,26 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network.interceptor | ||||
|  | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class UserAgentInterceptor : Interceptor { | ||||
|  | ||||
|     private val networkHelper: NetworkHelper by injectLazy() | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
|  | ||||
|         return if (originalRequest.header("User-Agent").isNullOrEmpty()) { | ||||
|             val newRequest = originalRequest | ||||
|                 .newBuilder() | ||||
|                 .removeHeader("User-Agent") | ||||
|                 .addHeader("User-Agent", networkHelper.defaultUserAgent) | ||||
|                 .build() | ||||
|             chain.proceed(newRequest) | ||||
|         } else { | ||||
|             chain.proceed(originalRequest) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,68 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.network.interceptor | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import android.webkit.WebSettings | ||||
| import android.webkit.WebView | ||||
| import android.widget.Toast | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.util.lang.launchUI | ||||
| import eu.kanade.tachiyomi.util.system.DeviceUtil | ||||
| import eu.kanade.tachiyomi.util.system.WebViewUtil | ||||
| import eu.kanade.tachiyomi.util.system.setDefaultSettings | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| abstract class WebViewInterceptor(private val context: Context) : Interceptor { | ||||
|  | ||||
|     private val networkHelper: NetworkHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * When this is called, it initializes the WebView if it wasn't already. We use this to avoid | ||||
|      * blocking the main thread too much. If used too often we could consider moving it to the | ||||
|      * Application class. | ||||
|      */ | ||||
|     private val initWebView by lazy { | ||||
|         // Crashes on some devices. We skip this in some cases since the only impact is slower | ||||
|         // WebView init in those rare cases. | ||||
|         // See https://bugs.chromium.org/p/chromium/issues/detail?id=1279562 | ||||
|         if (DeviceUtil.isMiui || Build.VERSION.SDK_INT == Build.VERSION_CODES.S && DeviceUtil.isSamsung) { | ||||
|             return@lazy | ||||
|         } | ||||
|  | ||||
|         WebSettings.getDefaultUserAgent(context) | ||||
|     } | ||||
|  | ||||
|     abstract fun shouldIntercept(response: Response): Boolean | ||||
|  | ||||
|     abstract fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val request = chain.request() | ||||
|         val response = chain.proceed(request) | ||||
|         if (!shouldIntercept(response)) { | ||||
|             return response | ||||
|         } | ||||
|  | ||||
|         if (!WebViewUtil.supportsWebView(context)) { | ||||
|             launchUI { | ||||
|                 context.toast(R.string.information_webview_required, Toast.LENGTH_LONG) | ||||
|             } | ||||
|             return response | ||||
|         } | ||||
|         initWebView | ||||
|  | ||||
|         return intercept(chain, request, response) | ||||
|     } | ||||
|  | ||||
|     fun createWebView(request: Request): WebView { | ||||
|         val webview = WebView(context) | ||||
|         webview.setDefaultSettings() | ||||
|         webview.settings.userAgentString = request.header("User-Agent") ?: networkHelper.defaultUserAgent | ||||
|         return webview | ||||
|     } | ||||
| } | ||||
| @@ -1,46 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.FilterList | ||||
| import eu.kanade.tachiyomi.source.model.MangasPage | ||||
| import rx.Observable | ||||
|  | ||||
| interface CatalogueSource : Source { | ||||
|  | ||||
|     /** | ||||
|      * An ISO 639-1 compliant language code (two letters in lower case). | ||||
|      */ | ||||
|     override val lang: String | ||||
|  | ||||
|     /** | ||||
|      * Whether the source has support for latest updates. | ||||
|      */ | ||||
|     val supportsLatest: Boolean | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     fun fetchPopularManga(page: Int): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. | ||||
|      * | ||||
|      * @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> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of latest manga updates. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     fun fetchLatestUpdates(page: Int): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns the list of filters for the source. | ||||
|      */ | ||||
|     fun getFilterList(): FilterList | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import androidx.preference.PreferenceScreen | ||||
|  | ||||
| interface ConfigurableSource : Source { | ||||
|  | ||||
|     fun setupPreferenceScreen(screen: PreferenceScreen) | ||||
| } | ||||
| @@ -1,113 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import android.graphics.drawable.Drawable | ||||
| import eu.kanade.domain.source.model.SourceData | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.extension.ExtensionManager | ||||
| 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.awaitSingle | ||||
| import rx.Observable | ||||
| 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... | ||||
|  */ | ||||
| interface Source { | ||||
|  | ||||
|     /** | ||||
|      * Id for the source. Must be unique. | ||||
|      */ | ||||
|     val id: Long | ||||
|  | ||||
|     /** | ||||
|      * Name of the source. | ||||
|      */ | ||||
|     val name: String | ||||
|  | ||||
|     val lang: String | ||||
|         get() = "" | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the updated details for a manga. | ||||
|      * | ||||
|      * @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. | ||||
|      */ | ||||
|     @Suppress("DEPRECATION") | ||||
|     suspend fun getMangaDetails(manga: SManga): SManga { | ||||
|         return fetchMangaDetails(manga).awaitSingle() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * [1.x API] Get all the available chapters for a manga. | ||||
|      */ | ||||
|     @Suppress("DEPRECATION") | ||||
|     suspend fun getChapterList(manga: SManga): List<SChapter> { | ||||
|         return fetchChapterList(manga).awaitSingle() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * [1.x API] Get the list of pages a chapter has. | ||||
|      */ | ||||
|     @Suppress("DEPRECATION") | ||||
|     suspend fun getPageList(chapter: SChapter): List<Page> { | ||||
|         return fetchPageList(chapter).awaitSingle() | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this) | ||||
|  | ||||
| fun Source.getPreferenceKey(): String = "source_$id" | ||||
|  | ||||
| fun Source.toSourceData(): SourceData = SourceData(id = id, lang = lang, name = name) | ||||
|  | ||||
| fun Source.getNameForMangaInfo(): String { | ||||
|     val preferences = Injekt.get<PreferencesHelper>() | ||||
|     val enabledLanguages = preferences.enabledLanguages().get() | ||||
|         .filterNot { it in listOf("all", "other") } | ||||
|     val hasOneActiveLanguages = enabledLanguages.size == 1 | ||||
|     val isInEnabledLanguages = lang in enabledLanguages | ||||
|     return when { | ||||
|         // For edge cases where user disables a source they got manga of in their library. | ||||
|         hasOneActiveLanguages && !isInEnabledLanguages -> toString() | ||||
|         // Hide the language tag when only one language is used. | ||||
|         hasOneActiveLanguages && isInEnabledLanguages -> name | ||||
|         else -> toString() | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource | ||||
| @@ -0,0 +1,31 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import android.graphics.drawable.Drawable | ||||
| import eu.kanade.domain.source.model.SourceData | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.extension.ExtensionManager | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this) | ||||
|  | ||||
| fun Source.getPreferenceKey(): String = "source_$id" | ||||
|  | ||||
| fun Source.toSourceData(): SourceData = SourceData(id = id, lang = lang, name = name) | ||||
|  | ||||
| fun Source.getNameForMangaInfo(): String { | ||||
|     val preferences = Injekt.get<PreferencesHelper>() | ||||
|     val enabledLanguages = preferences.enabledLanguages().get() | ||||
|         .filterNot { it in listOf("all", "other") } | ||||
|     val hasOneActiveLanguages = enabledLanguages.size == 1 | ||||
|     val isInEnabledLanguages = lang in enabledLanguages | ||||
|     return when { | ||||
|         // For edge cases where user disables a source they got manga of in their library. | ||||
|         hasOneActiveLanguages && !isInEnabledLanguages -> toString() | ||||
|         // Hide the language tag when only one language is used. | ||||
|         hasOneActiveLanguages && isInEnabledLanguages -> name | ||||
|         else -> toString() | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource | ||||
| @@ -1,12 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| /** | ||||
|  * A factory for creating sources at runtime. | ||||
|  */ | ||||
| interface SourceFactory { | ||||
|     /** | ||||
|      * Create a new copy of the sources | ||||
|      * @return The created sources | ||||
|      */ | ||||
|     fun createSources(): List<Source> | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| /** | ||||
|  * A source that explicitly doesn't require traffic considerations. | ||||
|  * | ||||
|  * This typically applies for self-hosted sources. | ||||
|  */ | ||||
| interface UnmeteredSource | ||||
| @@ -1,40 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| sealed class Filter<T>(val name: String, var state: T) { | ||||
|     open class Header(name: String) : Filter<Any>(name, 0) | ||||
|     open class Separator(name: String = "") : Filter<Any>(name, 0) | ||||
|     abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state) | ||||
|     abstract class Text(name: String, state: String = "") : Filter<String>(name, state) | ||||
|     abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state) | ||||
|     abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) { | ||||
|         fun isIgnored() = state == STATE_IGNORE | ||||
|         fun isIncluded() = state == STATE_INCLUDE | ||||
|         fun isExcluded() = state == STATE_EXCLUDE | ||||
|  | ||||
|         companion object { | ||||
|             const val STATE_IGNORE = 0 | ||||
|             const val STATE_INCLUDE = 1 | ||||
|             const val STATE_EXCLUDE = 2 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     abstract class Group<V>(name: String, state: List<V>) : Filter<List<V>>(name, state) | ||||
|  | ||||
|     abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) : | ||||
|         Filter<Sort.Selection?>(name, state) { | ||||
|         data class Selection(val index: Int, val ascending: Boolean) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (other !is Filter<*>) return false | ||||
|  | ||||
|         return name == other.name && state == other.state | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         var result = name.hashCode() | ||||
|         result = 31 * result + (state?.hashCode() ?: 0) | ||||
|         return result | ||||
|     } | ||||
| } | ||||
| @@ -1,14 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list { | ||||
|  | ||||
|     constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return list.hashCode() | ||||
|     } | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean) | ||||
| @@ -1,66 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import android.net.Uri | ||||
| import eu.kanade.tachiyomi.network.ProgressListener | ||||
| import kotlinx.serialization.Serializable | ||||
| import kotlinx.serialization.Transient | ||||
| import rx.subjects.Subject | ||||
|  | ||||
| @Serializable | ||||
| open class Page( | ||||
|     val index: Int, | ||||
|     val url: String = "", | ||||
|     var imageUrl: String? = null, | ||||
|     @Transient var uri: Uri? = null, // Deprecated but can't be deleted due to extensions | ||||
| ) : ProgressListener { | ||||
|  | ||||
|     val number: Int | ||||
|         get() = index + 1 | ||||
|  | ||||
|     @Transient | ||||
|     @Volatile | ||||
|     var status: Int = 0 | ||||
|         set(value) { | ||||
|             field = value | ||||
|             statusSubject?.onNext(value) | ||||
|             statusCallback?.invoke(this) | ||||
|         } | ||||
|  | ||||
|     @Transient | ||||
|     @Volatile | ||||
|     var progress: Int = 0 | ||||
|         set(value) { | ||||
|             field = value | ||||
|             statusCallback?.invoke(this) | ||||
|         } | ||||
|  | ||||
|     @Transient | ||||
|     private var statusSubject: Subject<Int, Int>? = null | ||||
|  | ||||
|     @Transient | ||||
|     private var statusCallback: ((Page) -> Unit)? = null | ||||
|  | ||||
|     override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { | ||||
|         progress = if (contentLength > 0) { | ||||
|             (100 * bytesRead / contentLength).toInt() | ||||
|         } else { | ||||
|             -1 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun setStatusSubject(subject: Subject<Int, Int>?) { | ||||
|         this.statusSubject = subject | ||||
|     } | ||||
|  | ||||
|     fun setStatusCallback(f: ((Page) -> Unit)?) { | ||||
|         statusCallback = f | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val QUEUE = 0 | ||||
|         const val LOAD_PAGE = 1 | ||||
|         const val DOWNLOAD_IMAGE = 2 | ||||
|         const val READY = 3 | ||||
|         const val ERROR = 4 | ||||
|     } | ||||
| } | ||||
| @@ -1,39 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import data.Chapters | ||||
| import java.io.Serializable | ||||
|  | ||||
| interface SChapter : Serializable { | ||||
|  | ||||
|     var url: String | ||||
|  | ||||
|     var name: String | ||||
|  | ||||
|     var date_upload: Long | ||||
|  | ||||
|     var chapter_number: Float | ||||
|  | ||||
|     var scanlator: String? | ||||
|  | ||||
|     fun copyFrom(other: SChapter) { | ||||
|         name = other.name | ||||
|         url = other.url | ||||
|         date_upload = other.date_upload | ||||
|         chapter_number = other.chapter_number | ||||
|         scanlator = other.scanlator | ||||
|     } | ||||
|  | ||||
|     fun copyFrom(other: Chapters) { | ||||
|         name = other.name | ||||
|         url = other.url | ||||
|         date_upload = other.date_upload | ||||
|         chapter_number = other.chapter_number | ||||
|         scanlator = other.scanlator | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         fun create(): SChapter { | ||||
|             return SChapterImpl() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import data.Chapters | ||||
|  | ||||
| fun SChapter.copyFrom(other: Chapters) { | ||||
|     name = other.name | ||||
|     url = other.url | ||||
|     date_upload = other.date_upload | ||||
|     chapter_number = other.chapter_number | ||||
|     scanlator = other.scanlator | ||||
| } | ||||
| @@ -1,14 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| class SChapterImpl : SChapter { | ||||
|  | ||||
|     override lateinit var url: String | ||||
|  | ||||
|     override lateinit var name: String | ||||
|  | ||||
|     override var date_upload: Long = 0 | ||||
|  | ||||
|     override var chapter_number: Float = -1f | ||||
|  | ||||
|     override var scanlator: String? = null | ||||
| } | ||||
| @@ -1,112 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import data.Mangas | ||||
| import java.io.Serializable | ||||
|  | ||||
| interface SManga : Serializable { | ||||
|  | ||||
|     var url: String | ||||
|  | ||||
|     var title: String | ||||
|  | ||||
|     var artist: String? | ||||
|  | ||||
|     var author: String? | ||||
|  | ||||
|     var description: String? | ||||
|  | ||||
|     var genre: String? | ||||
|  | ||||
|     var status: Int | ||||
|  | ||||
|     var thumbnail_url: String? | ||||
|  | ||||
|     var initialized: Boolean | ||||
|  | ||||
|     fun getGenres(): List<String>? { | ||||
|         if (genre.isNullOrBlank()) return null | ||||
|         return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct() | ||||
|     } | ||||
|  | ||||
|     fun copyFrom(other: SManga) { | ||||
|         if (other.author != null) { | ||||
|             author = other.author | ||||
|         } | ||||
|  | ||||
|         if (other.artist != null) { | ||||
|             artist = other.artist | ||||
|         } | ||||
|  | ||||
|         if (other.description != null) { | ||||
|             description = other.description | ||||
|         } | ||||
|  | ||||
|         if (other.genre != null) { | ||||
|             genre = other.genre | ||||
|         } | ||||
|  | ||||
|         if (other.thumbnail_url != null) { | ||||
|             thumbnail_url = other.thumbnail_url | ||||
|         } | ||||
|  | ||||
|         status = other.status | ||||
|  | ||||
|         if (!initialized) { | ||||
|             initialized = other.initialized | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun copyFrom(other: Mangas) { | ||||
|         if (other.author != null) { | ||||
|             author = other.author | ||||
|         } | ||||
|  | ||||
|         if (other.artist != null) { | ||||
|             artist = other.artist | ||||
|         } | ||||
|  | ||||
|         if (other.description != null) { | ||||
|             description = other.description | ||||
|         } | ||||
|  | ||||
|         if (other.genre != null) { | ||||
|             genre = other.genre.joinToString(separator = ", ") | ||||
|         } | ||||
|  | ||||
|         if (other.thumbnail_url != null) { | ||||
|             thumbnail_url = other.thumbnail_url | ||||
|         } | ||||
|  | ||||
|         status = other.status.toInt() | ||||
|  | ||||
|         if (!initialized) { | ||||
|             initialized = other.initialized | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun copy() = create().also { | ||||
|         it.url = url | ||||
|         it.title = title | ||||
|         it.artist = artist | ||||
|         it.author = author | ||||
|         it.description = description | ||||
|         it.genre = genre | ||||
|         it.status = status | ||||
|         it.thumbnail_url = thumbnail_url | ||||
|         it.initialized = initialized | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val UNKNOWN = 0 | ||||
|         const val ONGOING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val LICENSED = 3 | ||||
|         const val PUBLISHING_FINISHED = 4 | ||||
|         const val CANCELLED = 5 | ||||
|         const val ON_HIATUS = 6 | ||||
|  | ||||
|         fun create(): SManga { | ||||
|             return SMangaImpl() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import data.Mangas | ||||
|  | ||||
| fun SManga.copyFrom(other: Mangas) { | ||||
|     if (other.author != null) { | ||||
|         author = other.author | ||||
|     } | ||||
|  | ||||
|     if (other.artist != null) { | ||||
|         artist = other.artist | ||||
|     } | ||||
|  | ||||
|     if (other.description != null) { | ||||
|         description = other.description | ||||
|     } | ||||
|  | ||||
|     if (other.genre != null) { | ||||
|         genre = other.genre.joinToString(separator = ", ") | ||||
|     } | ||||
|  | ||||
|     if (other.thumbnail_url != null) { | ||||
|         thumbnail_url = other.thumbnail_url | ||||
|     } | ||||
|  | ||||
|     status = other.status.toInt() | ||||
|  | ||||
|     if (!initialized) { | ||||
|         initialized = other.initialized | ||||
|     } | ||||
| } | ||||
| @@ -1,22 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| class SMangaImpl : SManga { | ||||
|  | ||||
|     override lateinit var url: String | ||||
|  | ||||
|     override lateinit var title: String | ||||
|  | ||||
|     override var artist: String? = null | ||||
|  | ||||
|     override var author: String? = null | ||||
|  | ||||
|     override var description: String? = null | ||||
|  | ||||
|     override var genre: String? = null | ||||
|  | ||||
|     override var status: Int = 0 | ||||
|  | ||||
|     override var thumbnail_url: String? = null | ||||
|  | ||||
|     override var initialized: Boolean = false | ||||
| } | ||||
| @@ -1,376 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.network.CACHE_CONTROL_NO_STORE | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import eu.kanade.tachiyomi.network.newCallWithProgress | ||||
| import eu.kanade.tachiyomi.source.CatalogueSource | ||||
| import eu.kanade.tachiyomi.source.model.FilterList | ||||
| import eu.kanade.tachiyomi.source.model.MangasPage | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import okhttp3.Headers | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.net.URI | ||||
| import java.net.URISyntaxException | ||||
| import java.security.MessageDigest | ||||
|  | ||||
| /** | ||||
|  * A simple implementation for sources from a website. | ||||
|  */ | ||||
| abstract class HttpSource : CatalogueSource { | ||||
|  | ||||
|     /** | ||||
|      * Network service. | ||||
|      */ | ||||
|     protected val network: NetworkHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Base url of the website without the trailing slash, like: http://mysite.com | ||||
|      */ | ||||
|     abstract val baseUrl: String | ||||
|  | ||||
|     /** | ||||
|      * Version id used to generate the source id. If the site completely changes and urls are | ||||
|      * incompatible, you may increase this value and it'll be considered as a new source. | ||||
|      */ | ||||
|     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. | ||||
|      */ | ||||
|     override val id by lazy { | ||||
|         val key = "${name.lowercase()}/$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 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Headers used for requests. | ||||
|      */ | ||||
|     val headers: Headers by lazy { headersBuilder().build() } | ||||
|  | ||||
|     /** | ||||
|      * Default network client for doing requests. | ||||
|      */ | ||||
|     open val client: OkHttpClient | ||||
|         get() = network.client | ||||
|  | ||||
|     /** | ||||
|      * Headers builder for requests. Implementations can override this method for custom headers. | ||||
|      */ | ||||
|     protected open fun headersBuilder() = Headers.Builder().apply { | ||||
|         add("User-Agent", network.defaultUserAgent) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Visible name of the source. | ||||
|      */ | ||||
|     override fun toString() = "$name (${lang.uppercase()})" | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     override fun fetchPopularManga(page: Int): Observable<MangasPage> { | ||||
|         return client.newCall(popularMangaRequest(page)) | ||||
|             .asObservableSuccess() | ||||
|             .map { response -> | ||||
|                 popularMangaParse(response) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the popular manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     protected abstract fun popularMangaRequest(page: Int): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     protected abstract fun popularMangaParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @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 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) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the search manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @param query the search query. | ||||
|      * @param filters the list of filters to apply. | ||||
|      */ | ||||
|     protected abstract fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     protected abstract fun searchMangaParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of latest manga updates. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { | ||||
|         return client.newCall(latestUpdatesRequest(page)) | ||||
|             .asObservableSuccess() | ||||
|             .map { response -> | ||||
|                 latestUpdatesParse(response) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for latest manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     protected abstract fun latestUpdatesRequest(page: Int): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     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. | ||||
|      * | ||||
|      * @param manga the manga to be updated. | ||||
|      */ | ||||
|     override fun fetchMangaDetails(manga: SManga): Observable<SManga> { | ||||
|         return client.newCall(mangaDetailsRequest(manga)) | ||||
|             .asObservableSuccess() | ||||
|             .map { response -> | ||||
|                 mangaDetailsParse(response).apply { initialized = true } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the details of a manga. Override only if it's needed to change the | ||||
|      * url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param manga the manga to be updated. | ||||
|      */ | ||||
|     open fun mangaDetailsRequest(manga: SManga): Request { | ||||
|         return GET(baseUrl + manga.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the details of a manga. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     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 | ||||
|      * | ||||
|      * @param manga the manga to look for chapters. | ||||
|      */ | ||||
|     override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { | ||||
|         return if (manga.status != SManga.LICENSED) { | ||||
|             client.newCall(chapterListRequest(manga)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     chapterListParse(response) | ||||
|                 } | ||||
|         } else { | ||||
|             Observable.error(Exception("Licensed - No chapters to show")) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for updating the chapter list. Override only if it's needed to override | ||||
|      * the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param manga the manga to look for chapters. | ||||
|      */ | ||||
|     protected open fun chapterListRequest(manga: SManga): Request { | ||||
|         return GET(baseUrl + manga.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of chapters. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     protected abstract fun chapterListParse(response: Response): List<SChapter> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the page list for a chapter. | ||||
|      * | ||||
|      * @param chapter the chapter whose page list has to be fetched. | ||||
|      */ | ||||
|     override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { | ||||
|         return client.newCall(pageListRequest(chapter)) | ||||
|             .asObservableSuccess() | ||||
|             .map { response -> | ||||
|                 pageListParse(response) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the page list. Override only if it's needed to override the | ||||
|      * url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param chapter the chapter whose page list has to be fetched. | ||||
|      */ | ||||
|     protected open fun pageListRequest(chapter: SChapter): Request { | ||||
|         return GET(baseUrl + chapter.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of pages. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     protected abstract fun pageListParse(response: Response): List<Page> | ||||
|  | ||||
|     /** | ||||
|      * 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. | ||||
|      * | ||||
|      * @param page the page whose source image has to be fetched. | ||||
|      */ | ||||
|     open fun fetchImageUrl(page: Page): Observable<String> { | ||||
|         return client.newCall(imageUrlRequest(page)) | ||||
|             .asObservableSuccess() | ||||
|             .map { imageUrlParse(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the url to the source image. Override only if it's needed to | ||||
|      * override the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param page the chapter whose page list has to be fetched | ||||
|      */ | ||||
|     protected open fun imageUrlRequest(page: Page): Request { | ||||
|         return GET(page.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the absolute url to the source image. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     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> { | ||||
|         val request = imageRequest(page).newBuilder() | ||||
|             // images will be cached or saved manually, so don't take up network cache | ||||
|             .cacheControl(CACHE_CONTROL_NO_STORE) | ||||
|             .build() | ||||
|         return client.newCallWithProgress(request, page) | ||||
|             .asObservableSuccess() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the source image. Override only if it's needed to override | ||||
|      * the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param page the chapter whose page list has to be fetched | ||||
|      */ | ||||
|     protected open fun imageRequest(page: Page): Request { | ||||
|         return GET(page.imageUrl!!, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Assigns the url of the chapter without the scheme and domain. It saves some redundancy from | ||||
|      * database and the urls could still work after a domain change. | ||||
|      * | ||||
|      * @param url the full url to the chapter. | ||||
|      */ | ||||
|     fun SChapter.setUrlWithoutDomain(url: String) { | ||||
|         this.url = getUrlWithoutDomain(url) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Assigns the url of the manga without the scheme and domain. It saves some redundancy from | ||||
|      * database and the urls could still work after a domain change. | ||||
|      * | ||||
|      * @param url the full url to the manga. | ||||
|      */ | ||||
|     fun SManga.setUrlWithoutDomain(url: String) { | ||||
|         this.url = getUrlWithoutDomain(url) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the url of the given string without the scheme and domain. | ||||
|      * | ||||
|      * @param orig the full url. | ||||
|      */ | ||||
|     private fun getUrlWithoutDomain(orig: String): String { | ||||
|         return try { | ||||
|             val uri = URI(orig.replace(" ", "%20")) | ||||
|             var out = uri.path | ||||
|             if (uri.query != null) { | ||||
|                 out += "?" + uri.query | ||||
|             } | ||||
|             if (uri.fragment != null) { | ||||
|                 out += "#" + uri.fragment | ||||
|             } | ||||
|             out | ||||
|         } catch (e: URISyntaxException) { | ||||
|             orig | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called before inserting a new chapter into database. Use it if you need to override chapter | ||||
|      * fields, like the title or the chapter number. Do not change anything to [manga]. | ||||
|      * | ||||
|      * @param chapter the chapter to be added. | ||||
|      * @param manga the manga of the chapter. | ||||
|      */ | ||||
|     open fun prepareNewChapter(chapter: SChapter, manga: SManga) {} | ||||
|  | ||||
|     /** | ||||
|      * Returns the list of filters for the source. | ||||
|      */ | ||||
|     override fun getFilterList() = FilterList() | ||||
| } | ||||
| @@ -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.LOAD_PAGE | ||||
|     return fetchImageUrl(page) | ||||
|         .doOnError { page.status = Page.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) } | ||||
| } | ||||
| @@ -1,200 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.MangasPage | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.util.asJsoup | ||||
| import okhttp3.Response | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
|  | ||||
| /** | ||||
|  * A simple implementation for sources from a website using Jsoup, an HTML parser. | ||||
|  */ | ||||
| abstract class ParsedHttpSource : HttpSource() { | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun popularMangaParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(popularMangaSelector()).map { element -> | ||||
|             popularMangaFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = popularMangaNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     protected abstract fun popularMangaSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [popularMangaSelector]. | ||||
|      */ | ||||
|     protected abstract fun popularMangaFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     protected abstract fun popularMangaNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun searchMangaParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(searchMangaSelector()).map { element -> | ||||
|             searchMangaFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = searchMangaNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     protected abstract fun searchMangaSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [searchMangaSelector]. | ||||
|      */ | ||||
|     protected abstract fun searchMangaFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     protected abstract fun searchMangaNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun latestUpdatesParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(latestUpdatesSelector()).map { element -> | ||||
|             latestUpdatesFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     protected abstract fun latestUpdatesSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [latestUpdatesSelector]. | ||||
|      */ | ||||
|     protected abstract fun latestUpdatesFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     protected abstract fun latestUpdatesNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the details of a manga. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun mangaDetailsParse(response: Response): SManga { | ||||
|         return mangaDetailsParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the details of the manga from the given [document]. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     protected abstract fun mangaDetailsParse(document: Document): SManga | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of chapters. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun chapterListParse(response: Response): List<SChapter> { | ||||
|         val document = response.asJsoup() | ||||
|         return document.select(chapterListSelector()).map { chapterFromElement(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. | ||||
|      */ | ||||
|     protected abstract fun chapterListSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a chapter from the given element. | ||||
|      * | ||||
|      * @param element an element obtained from [chapterListSelector]. | ||||
|      */ | ||||
|     protected abstract fun chapterFromElement(element: Element): SChapter | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the page list. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         return pageListParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns a page list from the given document. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     protected abstract fun pageListParse(document: Document): List<Page> | ||||
|  | ||||
|     /** | ||||
|      * Parse the response from the site and returns the absolute url to the source image. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun imageUrlParse(response: Response): String { | ||||
|         return imageUrlParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the absolute url to the source image from the document. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     protected abstract fun imageUrlParse(document: Document): String | ||||
| } | ||||
| @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.preference.preferenceCategory | ||||
| import eu.kanade.tachiyomi.util.preference.switchPreference | ||||
| import eu.kanade.tachiyomi.util.preference.titleRes | ||||
| import eu.kanade.tachiyomi.util.system.DeviceUtil | ||||
| import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable | ||||
| import eu.kanade.tachiyomi.util.system.isTablet | ||||
| import eu.kanade.tachiyomi.widget.preference.ThemesPreference | ||||
| import java.util.Date | ||||
|   | ||||
| @@ -1,26 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.util | ||||
|  | ||||
| import okhttp3.Response | ||||
| import org.jsoup.Jsoup | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
|  | ||||
| fun Element.selectText(css: String, defaultValue: String? = null): String? { | ||||
|     return select(css).first()?.text() ?: defaultValue | ||||
| } | ||||
|  | ||||
| fun Element.selectInt(css: String, defaultValue: Int = 0): Int { | ||||
|     return select(css).first()?.text()?.toInt() ?: defaultValue | ||||
| } | ||||
|  | ||||
| fun Element.attrOrText(css: String): String { | ||||
|     return if (css != "text") attr(css) else text() | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Returns a Jsoup document for this response. | ||||
|  * @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()) | ||||
| } | ||||
| @@ -1,57 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.util.lang | ||||
|  | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.CoroutineStart | ||||
| import kotlinx.coroutines.DelicateCoroutinesApi | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.GlobalScope | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.NonCancellable | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
|  | ||||
| /** | ||||
|  * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. | ||||
|  * | ||||
|  * **Possible replacements** | ||||
|  * - suspend function | ||||
|  * - custom scope like view or presenter scope | ||||
|  */ | ||||
| @DelicateCoroutinesApi | ||||
| fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = | ||||
|     GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) | ||||
|  | ||||
| /** | ||||
|  * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. | ||||
|  * | ||||
|  * **Possible replacements** | ||||
|  * - suspend function | ||||
|  * - custom scope like view or presenter scope | ||||
|  */ | ||||
| @DelicateCoroutinesApi | ||||
| fun launchIO(block: suspend CoroutineScope.() -> Unit): Job = | ||||
|     GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block) | ||||
|  | ||||
| /** | ||||
|  * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. | ||||
|  * | ||||
|  * **Possible replacements** | ||||
|  * - suspend function | ||||
|  * - custom scope like view or presenter scope | ||||
|  */ | ||||
| @DelicateCoroutinesApi | ||||
| fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = | ||||
|     GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) | ||||
|  | ||||
| fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = | ||||
|     launch(Dispatchers.Main, block = block) | ||||
|  | ||||
| fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = | ||||
|     launch(Dispatchers.IO, block = block) | ||||
|  | ||||
| fun CoroutineScope.launchNonCancellableIO(block: suspend CoroutineScope.() -> Unit): Job = | ||||
|     launchIO { withContext(NonCancellable, block) } | ||||
|  | ||||
| suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) | ||||
|  | ||||
| suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block) | ||||
| @@ -1,87 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.util.lang | ||||
|  | ||||
| import kotlinx.coroutines.CancellableContinuation | ||||
| import kotlinx.coroutines.CancellationException | ||||
| import kotlinx.coroutines.CoroutineStart | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.GlobalScope | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||||
| import rx.Emitter | ||||
| import rx.Observable | ||||
| import rx.Subscriber | ||||
| import rx.Subscription | ||||
| import kotlin.coroutines.resume | ||||
| import kotlin.coroutines.resumeWithException | ||||
|  | ||||
| /* | ||||
|  * Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY. | ||||
|  */ | ||||
|  | ||||
| suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne() | ||||
|  | ||||
| private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont -> | ||||
|     cont.unsubscribeOnCancellation( | ||||
|         subscribe( | ||||
|             object : Subscriber<T>() { | ||||
|                 override fun onStart() { | ||||
|                     request(1) | ||||
|                 } | ||||
|  | ||||
|                 override fun onNext(t: T) { | ||||
|                     cont.resume(t) | ||||
|                 } | ||||
|  | ||||
|                 override fun onCompleted() { | ||||
|                     if (cont.isActive) { | ||||
|                         cont.resumeWithException( | ||||
|                             IllegalStateException( | ||||
|                                 "Should have invoked onNext", | ||||
|                             ), | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onError(e: Throwable) { | ||||
|                     /* | ||||
|                      * Rx1 observable throws NoSuchElementException if cancellation happened before | ||||
|                      * element emission. To mitigate this we try to atomically resume continuation with exception: | ||||
|                      * if resume failed, then we know that continuation successfully cancelled itself | ||||
|                      */ | ||||
|                     val token = cont.tryResumeWithException(e) | ||||
|                     if (token != null) { | ||||
|                         cont.completeResume(token) | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|         ), | ||||
|     ) | ||||
| } | ||||
|  | ||||
| internal fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) = | ||||
|     invokeOnCancellation { sub.unsubscribe() } | ||||
|  | ||||
| fun <T> runAsObservable( | ||||
|     backpressureMode: Emitter.BackpressureMode = Emitter.BackpressureMode.NONE, | ||||
|     block: suspend () -> T, | ||||
| ): Observable<T> { | ||||
|     return Observable.create( | ||||
|         { emitter -> | ||||
|             val job = GlobalScope.launch(Dispatchers.Unconfined, start = CoroutineStart.ATOMIC) { | ||||
|                 try { | ||||
|                     emitter.onNext(block()) | ||||
|                     emitter.onCompleted() | ||||
|                 } catch (e: Throwable) { | ||||
|                     // Ignore `CancellationException` as error, since it indicates "normal cancellation" | ||||
|                     if (e !is CancellationException) { | ||||
|                         emitter.onError(e) | ||||
|                     } else { | ||||
|                         emitter.onCompleted() | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             emitter.setCancellation { job.cancel() } | ||||
|         }, | ||||
|         backpressureMode, | ||||
|     ) | ||||
| } | ||||
| @@ -24,10 +24,8 @@ import android.util.TypedValue | ||||
| import android.view.Display | ||||
| import android.view.View | ||||
| import android.view.WindowManager | ||||
| import android.widget.Toast | ||||
| import androidx.annotation.AttrRes | ||||
| import androidx.annotation.ColorInt | ||||
| import androidx.annotation.StringRes | ||||
| import androidx.appcompat.view.ContextThemeWrapper | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.content.ContextCompat | ||||
| @@ -52,29 +50,6 @@ import kotlin.math.roundToInt | ||||
|  | ||||
| private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720 | ||||
|  | ||||
| /** | ||||
|  * Display a toast in this context. | ||||
|  * | ||||
|  * @param resource the text resource. | ||||
|  * @param duration the duration of the toast. Defaults to short. | ||||
|  */ | ||||
| fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast { | ||||
|     return toast(getString(resource), duration, block) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Display a toast in this context. | ||||
|  * | ||||
|  * @param text the text to display. | ||||
|  * @param duration the duration of the toast. Defaults to short. | ||||
|  */ | ||||
| fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast { | ||||
|     return Toast.makeText(applicationContext, text.orEmpty(), duration).also { | ||||
|         block(it) | ||||
|         it.show() | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Copies a string to clipboard | ||||
|  * | ||||
|   | ||||
| @@ -1,51 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.util.system | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.os.Build | ||||
| import com.google.android.material.color.DynamicColors | ||||
| import logcat.LogPriority | ||||
|  | ||||
| object DeviceUtil { | ||||
|  | ||||
|     val isMiui by lazy { | ||||
|         getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("PrivateApi") | ||||
|     fun isMiuiOptimizationDisabled(): Boolean { | ||||
|         val sysProp = getSystemProperty("persist.sys.miui_optimization") | ||||
|         if (sysProp == "0" || sysProp == "false") { | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|         return try { | ||||
|             Class.forName("android.miui.AppOpsUtils") | ||||
|                 .getDeclaredMethod("isXOptMode") | ||||
|                 .invoke(null) as Boolean | ||||
|         } catch (e: Exception) { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     val isSamsung by lazy { | ||||
|         Build.MANUFACTURER.equals("samsung", ignoreCase = true) | ||||
|     } | ||||
|  | ||||
|     val isDynamicColorAvailable by lazy { | ||||
|         DynamicColors.isDynamicColorAvailable() || (isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) | ||||
|     } | ||||
|  | ||||
|     val invalidDefaultBrowsers = listOf("android", "com.huawei.android.internal.app") | ||||
|  | ||||
|     @SuppressLint("PrivateApi") | ||||
|     private fun getSystemProperty(key: String?): String? { | ||||
|         return try { | ||||
|             Class.forName("android.os.SystemProperties") | ||||
|                 .getDeclaredMethod("get", String::class.java) | ||||
|                 .invoke(null, key) as String | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.WARN, e) { "Unable to use SystemProperties.get()" } | ||||
|             null | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| package eu.kanade.tachiyomi.util.system | ||||
|  | ||||
| import android.os.Build | ||||
| import com.google.android.material.color.DynamicColors | ||||
|  | ||||
| val DeviceUtil.isDynamicColorAvailable by lazy { | ||||
|     DynamicColors.isDynamicColorAvailable() || (DeviceUtil.isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) | ||||
| } | ||||
| @@ -1,18 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.util.system | ||||
|  | ||||
| import logcat.LogPriority | ||||
| import logcat.asLog | ||||
| import logcat.logcat | ||||
|  | ||||
| inline fun Any.logcat( | ||||
|     priority: LogPriority = LogPriority.DEBUG, | ||||
|     throwable: Throwable? = null, | ||||
|     message: () -> String = { "" }, | ||||
| ) = logcat(priority = priority) { | ||||
|     var msg = message() | ||||
|     if (throwable != null) { | ||||
|         if (msg.isNotBlank()) msg += "\n" | ||||
|         msg += throwable.asLog() | ||||
|     } | ||||
|     msg | ||||
| } | ||||
| @@ -1,94 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.util.system | ||||
|  | ||||
| import android.annotation.TargetApi | ||||
| import android.os.Build | ||||
| import android.webkit.WebResourceError | ||||
| import android.webkit.WebResourceRequest | ||||
| import android.webkit.WebResourceResponse | ||||
| import android.webkit.WebView | ||||
| import android.webkit.WebViewClient | ||||
|  | ||||
| @Suppress("OverridingDeprecatedMember") | ||||
| abstract class WebViewClientCompat : WebViewClient() { | ||||
|  | ||||
|     open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? { | ||||
|         return null | ||||
|     } | ||||
|  | ||||
|     open fun onReceivedErrorCompat( | ||||
|         view: WebView, | ||||
|         errorCode: Int, | ||||
|         description: String?, | ||||
|         failingUrl: String, | ||||
|         isMainFrame: Boolean, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     @TargetApi(Build.VERSION_CODES.N) | ||||
|     final override fun shouldOverrideUrlLoading( | ||||
|         view: WebView, | ||||
|         request: WebResourceRequest, | ||||
|     ): Boolean { | ||||
|         return shouldOverrideUrlCompat(view, request.url.toString()) | ||||
|     } | ||||
|  | ||||
|     final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { | ||||
|         return shouldOverrideUrlCompat(view, url) | ||||
|     } | ||||
|  | ||||
|     final override fun shouldInterceptRequest( | ||||
|         view: WebView, | ||||
|         request: WebResourceRequest, | ||||
|     ): WebResourceResponse? { | ||||
|         return shouldInterceptRequestCompat(view, request.url.toString()) | ||||
|     } | ||||
|  | ||||
|     final override fun shouldInterceptRequest( | ||||
|         view: WebView, | ||||
|         url: String, | ||||
|     ): WebResourceResponse? { | ||||
|         return shouldInterceptRequestCompat(view, url) | ||||
|     } | ||||
|  | ||||
|     final override fun onReceivedError( | ||||
|         view: WebView, | ||||
|         request: WebResourceRequest, | ||||
|         error: WebResourceError, | ||||
|     ) { | ||||
|         onReceivedErrorCompat( | ||||
|             view, | ||||
|             error.errorCode, | ||||
|             error.description?.toString(), | ||||
|             request.url.toString(), | ||||
|             request.isForMainFrame, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     final override fun onReceivedError( | ||||
|         view: WebView, | ||||
|         errorCode: Int, | ||||
|         description: String?, | ||||
|         failingUrl: String, | ||||
|     ) { | ||||
|         onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url) | ||||
|     } | ||||
|  | ||||
|     final override fun onReceivedHttpError( | ||||
|         view: WebView, | ||||
|         request: WebResourceRequest, | ||||
|         error: WebResourceResponse, | ||||
|     ) { | ||||
|         onReceivedErrorCompat( | ||||
|             view, | ||||
|             error.statusCode, | ||||
|             error.reasonPhrase, | ||||
|             request.url | ||||
|                 .toString(), | ||||
|             request.isForMainFrame, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -1,67 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.util.system | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import android.content.pm.PackageManager | ||||
| import android.webkit.CookieManager | ||||
| import android.webkit.WebSettings | ||||
| import android.webkit.WebView | ||||
| import logcat.LogPriority | ||||
|  | ||||
| object WebViewUtil { | ||||
|     const val SPOOF_PACKAGE_NAME = "org.chromium.chrome" | ||||
|  | ||||
|     const val MINIMUM_WEBVIEW_VERSION = 100 | ||||
|  | ||||
|     fun supportsWebView(context: Context): Boolean { | ||||
|         try { | ||||
|             // May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView | ||||
|             // is not installed | ||||
|             CookieManager.getInstance() | ||||
|         } catch (e: Throwable) { | ||||
|             logcat(LogPriority.ERROR, e) | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW) | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun WebView.isOutdated(): Boolean { | ||||
|     return getWebViewMajorVersion() < WebViewUtil.MINIMUM_WEBVIEW_VERSION | ||||
| } | ||||
|  | ||||
| @SuppressLint("SetJavaScriptEnabled") | ||||
| fun WebView.setDefaultSettings() { | ||||
|     with(settings) { | ||||
|         javaScriptEnabled = true | ||||
|         domStorageEnabled = true | ||||
|         databaseEnabled = true | ||||
|         useWideViewPort = true | ||||
|         loadWithOverviewMode = true | ||||
|         cacheMode = WebSettings.LOAD_DEFAULT | ||||
|     } | ||||
| } | ||||
|  | ||||
| private fun WebView.getWebViewMajorVersion(): Int { | ||||
|     val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString()) | ||||
|     return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) { | ||||
|         uaRegexMatch.groupValues[1].toInt() | ||||
|     } else { | ||||
|         0 | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Based on https://stackoverflow.com/a/29218966 | ||||
| private fun WebView.getDefaultUserAgentString(): String { | ||||
|     val originalUA: String = settings.userAgentString | ||||
|  | ||||
|     // Next call to getUserAgentString() will get us the default | ||||
|     settings.userAgentString = null | ||||
|     val defaultUserAgentString = settings.userAgentString | ||||
|  | ||||
|     // Revert to original UA string | ||||
|     settings.userAgentString = originalUA | ||||
|  | ||||
|     return defaultUserAgentString | ||||
| } | ||||
		Reference in New Issue
	
	Block a user