mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-25 18:47:51 +02:00
Move :core
to :core:common
This commit is contained in:
1
core/common/.gitignore
vendored
Normal file
1
core/common/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
51
core/common/build.gradle.kts
Normal file
51
core/common/build.gradle.kts
Normal file
@ -0,0 +1,51 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
kotlin("plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "eu.kanade.tachiyomi.core.common"
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += listOf(
|
||||
"-Xcontext-receivers",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.i18n)
|
||||
|
||||
api(libs.logcat)
|
||||
|
||||
api(libs.rxjava)
|
||||
|
||||
api(libs.okhttp.core)
|
||||
api(libs.okhttp.logging)
|
||||
api(libs.okhttp.brotli)
|
||||
api(libs.okhttp.dnsoverhttps)
|
||||
api(libs.okio)
|
||||
|
||||
implementation(libs.image.decoder)
|
||||
|
||||
implementation(libs.unifile)
|
||||
|
||||
api(kotlinx.coroutines.core)
|
||||
api(kotlinx.serialization.json)
|
||||
api(kotlinx.serialization.json.okio)
|
||||
|
||||
api(libs.preferencektx)
|
||||
|
||||
implementation(libs.jsoup)
|
||||
|
||||
// Sort
|
||||
implementation(libs.natural.comparator)
|
||||
|
||||
// JavaScript engine
|
||||
implementation(libs.bundles.js.engine)
|
||||
|
||||
testImplementation(libs.bundles.test)
|
||||
}
|
2
core/common/src/main/AndroidManifest.xml
Normal file
2
core/common/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
@ -0,0 +1,35 @@
|
||||
package eu.kanade.tachiyomi.core.security
|
||||
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.preference.getEnum
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
class SecurityPreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
|
||||
fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false)
|
||||
|
||||
fun lockAppAfter() = preferenceStore.getInt("lock_app_after", 0)
|
||||
|
||||
fun secureScreen() = preferenceStore.getEnum("secure_screen_v2", SecureScreenMode.INCOGNITO)
|
||||
|
||||
fun hideNotificationContent() = preferenceStore.getBoolean("hide_notification_content", false)
|
||||
|
||||
/**
|
||||
* For app lock. Will be set when there is a pending timed lock.
|
||||
* Otherwise this pref should be deleted.
|
||||
*/
|
||||
fun lastAppClosed() = preferenceStore.getLong(
|
||||
Preference.appStateKey("last_app_closed"),
|
||||
0,
|
||||
)
|
||||
|
||||
enum class SecureScreenMode(val titleRes: StringResource) {
|
||||
ALWAYS(MR.strings.lock_always),
|
||||
INCOGNITO(MR.strings.pref_incognito_mode),
|
||||
NEVER(MR.strings.lock_never),
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
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 {}
|
||||
}
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
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
|
||||
const val PREF_DOH_SHECAN = 12
|
||||
|
||||
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://dns.mullvad.net/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("194.242.2.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(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Source: https://shecan.ir/
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohShecan() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://free.shecan.ir/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("178.22.122.100"),
|
||||
InetAddress.getByName("185.51.200.2"),
|
||||
)
|
||||
.build(),
|
||||
)
|
@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.content.Context
|
||||
import app.cash.quickjs.QuickJs
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
|
||||
/**
|
||||
* Util for evaluating JavaScript in sources.
|
||||
*/
|
||||
@Suppress("UNUSED", "UNCHECKED_CAST")
|
||||
class JavaScriptEngine(context: Context) {
|
||||
|
||||
/**
|
||||
* Evaluate arbitrary JavaScript code and get the result as a primtive type
|
||||
* (e.g., String, Int).
|
||||
*
|
||||
* @since extensions-lib 1.4
|
||||
* @param script JavaScript to execute.
|
||||
* @return Result of JavaScript code as a primitive type.
|
||||
*/
|
||||
suspend fun <T> evaluate(script: String): T = withIOContext {
|
||||
QuickJs.create().use {
|
||||
it.evaluate(script) as T
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NetworkHelper(
|
||||
private val context: Context,
|
||||
private val preferences: NetworkPreferences,
|
||||
) {
|
||||
|
||||
val cookieJar = AndroidCookieJar()
|
||||
|
||||
val client: OkHttpClient = run {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(2, TimeUnit.MINUTES)
|
||||
.cache(
|
||||
Cache(
|
||||
directory = File(context.cacheDir, "network_cache"),
|
||||
maxSize = 5L * 1024 * 1024, // 5 MiB
|
||||
),
|
||||
)
|
||||
.addInterceptor(UncaughtExceptionInterceptor())
|
||||
.addInterceptor(UserAgentInterceptor(::defaultUserAgentProvider))
|
||||
.addNetworkInterceptor(IgnoreGzipInterceptor())
|
||||
.addNetworkInterceptor(BrotliInterceptor)
|
||||
|
||||
if (preferences.verboseLogging().get()) {
|
||||
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
builder.addNetworkInterceptor(httpLoggingInterceptor)
|
||||
}
|
||||
|
||||
builder.addInterceptor(
|
||||
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider),
|
||||
)
|
||||
|
||||
when (preferences.dohProvider().get()) {
|
||||
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()
|
||||
PREF_DOH_SHECAN -> builder.dohShecan()
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Since extension-lib 1.5
|
||||
*/
|
||||
@Deprecated("The regular client handles Cloudflare by default")
|
||||
@Suppress("UNUSED")
|
||||
val cloudflareClient: OkHttpClient = client
|
||||
|
||||
fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim()
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
|
||||
class NetworkPreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
private val verboseLogging: Boolean = false,
|
||||
) {
|
||||
|
||||
fun verboseLogging(): Preference<Boolean> {
|
||||
return preferenceStore.getBoolean("verbose_logging", verboseLogging)
|
||||
}
|
||||
|
||||
fun dohProvider(): Preference<Int> {
|
||||
return preferenceStore.getInt("doh_provider", -1)
|
||||
}
|
||||
|
||||
fun defaultUserAgent(): Preference<String> {
|
||||
return preferenceStore.getString(
|
||||
"default_user_agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import kotlinx.serialization.serializer
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
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 (e: Exception) {
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe() {
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
override fun isUnsubscribed(): Boolean {
|
||||
return call.isCanceled()
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.add(requestArbiter)
|
||||
subscriber.setProducer(requestArbiter)
|
||||
}
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback =
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response) {
|
||||
response.body.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(callback)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Call.await(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
return await(callStack)
|
||||
}
|
||||
|
||||
/**
|
||||
* @since extensions-lib 1.5
|
||||
*/
|
||||
suspend fun Call.awaitSuccess(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
val response = await(callStack)
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code).apply { stackTrace = callStack }
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCachelessCallWithProgress(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)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
inline fun <reified T> Response.parseAs(): T {
|
||||
return decodeFromJsonResponse(serializer(), this)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
fun <T> decodeFromJsonResponse(
|
||||
deserializer: DeserializationStrategy<T>,
|
||||
response: Response,
|
||||
): T {
|
||||
return response.body.source().use {
|
||||
decodeFromBufferedSource(deserializer, it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception that handles HTTP codes considered not successful by OkHttp.
|
||||
* Use it to have a standardized error message in the app across the extensions.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param code [Int] the HTTP status code
|
||||
*/
|
||||
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
|
@ -0,0 +1,5 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
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()
|
||||
|
||||
fun GET(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return GET(url.toHttpUrl(), headers, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* @since extensions-lib 1.4
|
||||
*/
|
||||
fun GET(
|
||||
url: HttpUrl,
|
||||
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()
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
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.network.AndroidCookieJar
|
||||
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
|
||||
import eu.kanade.tachiyomi.util.system.isOutdated
|
||||
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 tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
class CloudflareInterceptor(
|
||||
private val context: Context,
|
||||
private val cookieManager: AndroidCookieJar,
|
||||
defaultUserAgentProvider: () -> String,
|
||||
) : WebViewInterceptor(context, defaultUserAgentProvider) {
|
||||
|
||||
private val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
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()
|
||||
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
||||
val oldCookie = 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.stringResource(MR.strings.information_cloudflare_bypass_failure), e)
|
||||
} 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 = parseHeaders(originalRequest.headers)
|
||||
|
||||
executor.execute {
|
||||
webview = createWebView(originalRequest)
|
||||
|
||||
webview?.webViewClient = object : WebViewClientCompat() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
fun isCloudFlareBypassed(): Boolean {
|
||||
return 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)
|
||||
}
|
||||
|
||||
latch.awaitFor30Seconds()
|
||||
|
||||
executor.execute {
|
||||
if (!cloudflareBypassed) {
|
||||
isWebViewOutdated = webview?.isOutdated() == true
|
||||
}
|
||||
|
||||
webview?.run {
|
||||
stopLoading()
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Throw exception if we failed to bypass Cloudflare
|
||||
if (!cloudflareBypassed) {
|
||||
// Prompt user to update WebView if it seems too outdated
|
||||
if (isWebViewOutdated) {
|
||||
context.toast(MR.strings.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()
|
@ -0,0 +1,21 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* To use [okhttp3.brotli.BrotliInterceptor] as a network interceptor,
|
||||
* add [IgnoreGzipInterceptor] right before it.
|
||||
*
|
||||
* This nullifies the transparent gzip of [okhttp3.internal.http.BridgeInterceptor]
|
||||
* so gzip and Brotli are explicitly handled by the [okhttp3.brotli.BrotliInterceptor].
|
||||
*/
|
||||
class IgnoreGzipInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
var request = chain.request()
|
||||
if (request.header("Accept-Encoding") == "gzip") {
|
||||
request = request.newBuilder().removeHeader("Accept-Encoding").build()
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
@ -0,0 +1,128 @@
|
||||
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
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toDuration
|
||||
import kotlin.time.toDurationUnit
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles rate limiting.
|
||||
*
|
||||
* This uses `java.time` APIs and is the legacy method, kept
|
||||
* for compatibility reasons with existing extensions.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Deprecated("Use the version with kotlin.time APIs instead.")
|
||||
fun OkHttpClient.Builder.rateLimit(
|
||||
permits: Int,
|
||||
period: Long = 1,
|
||||
unit: TimeUnit = TimeUnit.SECONDS,
|
||||
) = addInterceptor(RateLimitInterceptor(null, permits, period.toDuration(unit.toDurationUnit())))
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* permits = 5, period = 1.seconds => 5 requests per second
|
||||
* permits = 10, period = 2.minutes => 10 requests per 2 minutes
|
||||
*
|
||||
* @since extension-lib 1.5
|
||||
*
|
||||
* @param permits [Int] Number of requests allowed within a period of units.
|
||||
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
|
||||
*/
|
||||
fun OkHttpClient.Builder.rateLimit(permits: Int, period: Duration = 1.seconds) =
|
||||
addInterceptor(RateLimitInterceptor(null, permits, period))
|
||||
|
||||
/** 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: Duration,
|
||||
) : Interceptor {
|
||||
|
||||
private val requestQueue = ArrayDeque<Long>(permits)
|
||||
private val rateLimitMillis = period.inWholeMilliseconds
|
||||
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() && 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
|
||||
}
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toDuration
|
||||
import kotlin.time.toDurationUnit
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles given url host's rate limiting.
|
||||
*
|
||||
* This uses Java Time APIs and is the legacy method, kept
|
||||
* for compatibility reasons with existing extensions.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Deprecated("Use the version with kotlin.time APIs instead.")
|
||||
fun OkHttpClient.Builder.rateLimitHost(
|
||||
httpUrl: HttpUrl,
|
||||
permits: Int,
|
||||
period: Long = 1,
|
||||
unit: TimeUnit = TimeUnit.SECONDS,
|
||||
) = addInterceptor(
|
||||
RateLimitInterceptor(httpUrl.host, permits, period.toDuration(unit.toDurationUnit())),
|
||||
)
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles given url host's rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* httpUrl = "https://api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1.seconds => 5 requests per second to api.manga.com
|
||||
* httpUrl = "https://imagecdn.manga.com".toHttpUrlOrNull(), permits = 10, period = 2.minutes => 10 requests per 2 minutes to imagecdn.manga.com
|
||||
*
|
||||
* @since extension-lib 1.5
|
||||
*
|
||||
* @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 [Duration] The limiting duration. Defaults to 1.seconds.
|
||||
*/
|
||||
@Suppress("UNUSED")
|
||||
fun OkHttpClient.Builder.rateLimitHost(
|
||||
httpUrl: HttpUrl,
|
||||
permits: Int,
|
||||
period: Duration = 1.seconds,
|
||||
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period))
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles given url host's rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* url = "https://api.manga.com", permits = 5, period = 1.seconds => 5 requests per second to api.manga.com
|
||||
* url = "https://imagecdn.manga.com", permits = 10, period = 2.minutes => 10 requests per 2 minutes to imagecdn.manga.com
|
||||
*
|
||||
* @since extension-lib 1.5
|
||||
*
|
||||
* @param url [String] 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 [Duration] The limiting duration. Defaults to 1.seconds.
|
||||
*/
|
||||
@Suppress("UNUSED")
|
||||
fun OkHttpClient.Builder.rateLimitHost(url: String, permits: Int, period: Duration = 1.seconds) =
|
||||
addInterceptor(RateLimitInterceptor(url.toHttpUrlOrNull()?.host, permits, period))
|
@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Catches any uncaught exceptions from later in the chain and rethrows as a non-fatal
|
||||
* IOException to avoid catastrophic failure.
|
||||
*
|
||||
* This should be the first interceptor in the client.
|
||||
*
|
||||
* See https://square.github.io/okhttp/4.x/okhttp/okhttp3/-interceptor/
|
||||
*/
|
||||
class UncaughtExceptionInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class UserAgentInterceptor(
|
||||
private val defaultUserAgentProvider: () -> String,
|
||||
) : Interceptor {
|
||||
|
||||
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", defaultUserAgentProvider())
|
||||
.build()
|
||||
chain.proceed(newRequest)
|
||||
} else {
|
||||
chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
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.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.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import tachiyomi.core.common.util.lang.launchUI
|
||||
import tachiyomi.i18n.MR
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class WebViewInterceptor(
|
||||
private val context: Context,
|
||||
private val defaultUserAgentProvider: () -> String,
|
||||
) : Interceptor {
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
try {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
} catch (_: Exception) {
|
||||
// Avoid some crashes like when Chrome/WebView is being updated.
|
||||
}
|
||||
}
|
||||
|
||||
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(MR.strings.information_webview_required, Toast.LENGTH_LONG)
|
||||
}
|
||||
return response
|
||||
}
|
||||
initWebView
|
||||
|
||||
return intercept(chain, request, response)
|
||||
}
|
||||
|
||||
fun parseHeaders(headers: Headers): Map<String, String> {
|
||||
return headers
|
||||
// Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT]
|
||||
.filter { (name, value) ->
|
||||
isRequestHeaderSafe(name, value)
|
||||
}
|
||||
.groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value }
|
||||
.mapValues { it.value.getOrNull(0).orEmpty() }
|
||||
}
|
||||
|
||||
fun CountDownLatch.awaitFor30Seconds() {
|
||||
await(30, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
fun createWebView(request: Request): WebView {
|
||||
return WebView(context).apply {
|
||||
setDefaultSettings()
|
||||
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
|
||||
settings.userAgentString = request.header("User-Agent") ?: defaultUserAgentProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on [IsRequestHeaderSafe] in
|
||||
// https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc
|
||||
private fun isRequestHeaderSafe(_name: String, _value: String): Boolean {
|
||||
val name = _name.lowercase(Locale.ENGLISH)
|
||||
val value = _value.lowercase(Locale.ENGLISH)
|
||||
if (name in unsafeHeaderNames || name.startsWith("proxy-")) return false
|
||||
if (name == "connection" && value == "upgrade") return false
|
||||
return true
|
||||
}
|
||||
private val unsafeHeaderNames = listOf(
|
||||
"content-length", "host", "trailer", "te", "upgrade", "cookie2", "keep-alive", "transfer-encoding", "set-cookie",
|
||||
)
|
@ -0,0 +1,44 @@
|
||||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object Hash {
|
||||
|
||||
private val chars = charArrayOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'a', 'b', 'c', 'd', 'e', 'f',
|
||||
)
|
||||
|
||||
private val MD5 get() = MessageDigest.getInstance("MD5")
|
||||
|
||||
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
fun sha256(bytes: ByteArray): String {
|
||||
return encodeHex(SHA256.digest(bytes))
|
||||
}
|
||||
|
||||
fun sha256(string: String): String {
|
||||
return sha256(string.toByteArray())
|
||||
}
|
||||
|
||||
fun md5(bytes: ByteArray): String {
|
||||
return encodeHex(MD5.digest(bytes))
|
||||
}
|
||||
|
||||
fun md5(string: String): String {
|
||||
return md5(string.toByteArray())
|
||||
}
|
||||
|
||||
private fun encodeHex(data: ByteArray): String {
|
||||
val l = data.size
|
||||
val out = CharArray(l shl 1)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < l) {
|
||||
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
|
||||
out[j++] = chars[15 and data[i].toInt()]
|
||||
i++
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package eu.kanade.tachiyomi.util.lang
|
||||
|
||||
import androidx.core.text.parseAsHtml
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] at its end.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.chop(count: Int, replacement: String = "…"): String {
|
||||
return if (length > count) {
|
||||
take(count - replacement.length) + replacement
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] near the center.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
|
||||
if (length <= count) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
|
||||
|
||||
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive natural comparator for strings.
|
||||
*/
|
||||
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return comparator.compare(this, other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the string as the number of bytes.
|
||||
*/
|
||||
fun String.byteSize(): Int {
|
||||
return toByteArray(StandardCharsets.UTF_8).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string containing the first [n] bytes from this string, or the entire string if this
|
||||
* string is shorter.
|
||||
*/
|
||||
fun String.takeBytes(n: Int): String {
|
||||
val bytes = toByteArray(StandardCharsets.UTF_8)
|
||||
return if (bytes.size <= n) {
|
||||
this
|
||||
} else {
|
||||
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-decode the string
|
||||
*/
|
||||
fun String.htmlDecode(): String {
|
||||
return this.parseAsHtml().toString()
|
||||
}
|
@ -0,0 +1,144 @@
|
||||
package eu.kanade.tachiyomi.util.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import java.io.File
|
||||
|
||||
object DiskUtil {
|
||||
|
||||
/**
|
||||
* Returns the root folders of all the available external storages.
|
||||
*/
|
||||
fun getExternalStorages(context: Context): List<File> {
|
||||
return ContextCompat.getExternalFilesDirs(context, null)
|
||||
.filterNotNull()
|
||||
.mapNotNull {
|
||||
val file = File(it.absolutePath.substringBefore("/Android/"))
|
||||
val state = Environment.getExternalStorageState(file)
|
||||
if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) {
|
||||
file
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hashKeyForDisk(key: String): String {
|
||||
return Hash.md5(key)
|
||||
}
|
||||
|
||||
fun getDirectorySize(f: File): Long {
|
||||
var size: Long = 0
|
||||
if (f.isDirectory) {
|
||||
for (file in f.listFiles().orEmpty()) {
|
||||
size += getDirectorySize(file)
|
||||
}
|
||||
} else {
|
||||
size = f.length()
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total space for the disk that a file path points to, in bytes.
|
||||
*/
|
||||
fun getTotalStorageSpace(file: File): Long {
|
||||
return try {
|
||||
val stat = StatFs(file.absolutePath)
|
||||
stat.blockCountLong * stat.blockSizeLong
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available space for the disk that a file path points to, in bytes.
|
||||
*/
|
||||
fun getAvailableStorageSpace(file: File): Long {
|
||||
return try {
|
||||
val stat = StatFs(file.absolutePath)
|
||||
stat.availableBlocksLong * stat.blockSizeLong
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available space for the disk that a file path points to, in bytes.
|
||||
*/
|
||||
fun getAvailableStorageSpace(f: UniFile): Long {
|
||||
return try {
|
||||
val stat = StatFs(f.uri.path)
|
||||
stat.availableBlocksLong * stat.blockSizeLong
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't display downloaded chapters in gallery apps creating `.nomedia`.
|
||||
*/
|
||||
fun createNoMediaFile(dir: UniFile?, context: Context?) {
|
||||
if (dir != null && dir.exists()) {
|
||||
val nomedia = dir.findFile(NOMEDIA_FILE)
|
||||
if (nomedia == null) {
|
||||
dir.createFile(NOMEDIA_FILE)
|
||||
context?.let { scanMedia(it, dir.uri) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the given file so that it can be shown in gallery apps, for example.
|
||||
*/
|
||||
fun scanMedia(context: Context, uri: Uri) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(uri.path), null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the given filename to make it valid for a FAT filesystem,
|
||||
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting
|
||||
* with a dot), but you can manually add it later.
|
||||
*/
|
||||
fun buildValidFilename(origName: String): String {
|
||||
val name = origName.trim('.', ' ')
|
||||
if (name.isEmpty()) {
|
||||
return "(invalid)"
|
||||
}
|
||||
val sb = StringBuilder(name.length)
|
||||
name.forEach { c ->
|
||||
if (isValidFatFilenameChar(c)) {
|
||||
sb.append(c)
|
||||
} else {
|
||||
sb.append('_')
|
||||
}
|
||||
}
|
||||
// Even though vfat allows 255 UCS-2 chars, we might eventually write to
|
||||
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters.
|
||||
return sb.toString().take(240)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given character is a valid filename character, false otherwise.
|
||||
*/
|
||||
private fun isValidFatFilenameChar(c: Char): Boolean {
|
||||
if (0x00.toChar() <= c && c <= 0x1f.toChar()) {
|
||||
return false
|
||||
}
|
||||
return when (c) {
|
||||
'"', '*', '/', ':', '<', '>', '?', '\\', '|', 0x7f.toChar() -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
const val NOMEDIA_FILE = ".nomedia"
|
||||
|
||||
// Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8)
|
||||
const val MAX_FILE_NAME_BYTES = 250
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
package eu.kanade.tachiyomi.util.storage
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Wrapper over ZipFile to load files in epub format.
|
||||
*/
|
||||
class EpubFile(file: File) : Closeable {
|
||||
|
||||
/**
|
||||
* Zip file of this epub.
|
||||
*/
|
||||
private val zip = ZipFile(file)
|
||||
|
||||
/**
|
||||
* Path separator used by this epub.
|
||||
*/
|
||||
private val pathSeparator = getPathSeparator()
|
||||
|
||||
/**
|
||||
* Closes the underlying zip file.
|
||||
*/
|
||||
override fun close() {
|
||||
zip.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an input stream for reading the contents of the specified zip file entry.
|
||||
*/
|
||||
fun getInputStream(entry: ZipEntry): InputStream {
|
||||
return zip.getInputStream(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the zip file entry for the specified name, or null if not found.
|
||||
*/
|
||||
fun getEntry(name: String): ZipEntry? {
|
||||
return zip.getEntry(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path of all the images found in the epub file.
|
||||
*/
|
||||
fun getImagesFromPages(): List<String> {
|
||||
val ref = getPackageHref()
|
||||
val doc = getPackageDocument(ref)
|
||||
val pages = getPagesFromDocument(doc)
|
||||
return getImagesFromPages(pages, ref)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the package document.
|
||||
*/
|
||||
fun getPackageHref(): String {
|
||||
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml"))
|
||||
if (meta != null) {
|
||||
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
|
||||
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
|
||||
if (path != null) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return resolveZipPath("OEBPS", "content.opf")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package document where all the files are listed.
|
||||
*/
|
||||
fun getPackageDocument(ref: String): Document {
|
||||
val entry = zip.getEntry(ref)
|
||||
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the pages from the epub.
|
||||
*/
|
||||
private fun getPagesFromDocument(document: Document): List<String> {
|
||||
val pages = document.select("manifest > item")
|
||||
.filter { node -> "application/xhtml+xml" == node.attr("media-type") }
|
||||
.associateBy { it.attr("id") }
|
||||
|
||||
val spine = document.select("spine > itemref").map { it.attr("idref") }
|
||||
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the images contained in every page from the epub.
|
||||
*/
|
||||
private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val basePath = getParentDirectory(packageHref)
|
||||
pages.forEach { page ->
|
||||
val entryPath = resolveZipPath(basePath, page)
|
||||
val entry = zip.getEntry(entryPath)
|
||||
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
val imageBasePath = getParentDirectory(entryPath)
|
||||
|
||||
document.allElements.forEach {
|
||||
when (it.tagName()) {
|
||||
"img" -> result.add(resolveZipPath(imageBasePath, it.attr("src")))
|
||||
"image" -> result.add(resolveZipPath(imageBasePath, it.attr("xlink:href")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path separator used by the epub file.
|
||||
*/
|
||||
private fun getPathSeparator(): String {
|
||||
val meta = zip.getEntry("META-INF\\container.xml")
|
||||
return if (meta != null) {
|
||||
"\\"
|
||||
} else {
|
||||
"/"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a zip path from base and relative components and a path separator.
|
||||
*/
|
||||
private fun resolveZipPath(basePath: String, relativePath: String): String {
|
||||
if (relativePath.startsWith(pathSeparator)) {
|
||||
// Path is absolute, so return as-is.
|
||||
return relativePath
|
||||
}
|
||||
|
||||
var fixedBasePath = basePath.replace(pathSeparator, File.separator)
|
||||
if (!fixedBasePath.startsWith(File.separator)) {
|
||||
fixedBasePath = "${File.separator}$fixedBasePath"
|
||||
}
|
||||
|
||||
val fixedRelativePath = relativePath.replace(pathSeparator, File.separator)
|
||||
val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath
|
||||
return resolvedPath.replace(File.separator, pathSeparator).substring(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent directory of a path.
|
||||
*/
|
||||
private fun getParentDirectory(path: String): String {
|
||||
val separatorIndex = path.lastIndexOf(pathSeparator)
|
||||
return if (separatorIndex >= 0) {
|
||||
path.substring(0, separatorIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.res.Resources
|
||||
|
||||
/**
|
||||
* Converts to px.
|
||||
*/
|
||||
val Int.dpToPx: Int
|
||||
get() = (this * Resources.getSystem().displayMetrics.density).toInt()
|
@ -0,0 +1,96 @@
|
||||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
object DeviceUtil {
|
||||
|
||||
val isMiui: Boolean by lazy {
|
||||
getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the MIUI major version code from a string like "V12.5.3.0.QFGMIXM".
|
||||
*
|
||||
* @return MIUI major version code (e.g., 13) or null if can't be parsed.
|
||||
*/
|
||||
val miuiMajorVersion: Int? by lazy {
|
||||
if (!isMiui) return@lazy null
|
||||
|
||||
Build.VERSION.INCREMENTAL
|
||||
.substringBefore('.')
|
||||
.trimStart('V')
|
||||
.toIntOrNull()
|
||||
}
|
||||
|
||||
@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: Boolean by lazy {
|
||||
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
|
||||
val oneUiVersion: Double? by lazy {
|
||||
try {
|
||||
val semPlatformIntField = Build.VERSION::class.java.getDeclaredField("SEM_PLATFORM_INT")
|
||||
val version = semPlatformIntField.getInt(null) - 90000
|
||||
if (version < 0) {
|
||||
1.0
|
||||
} else {
|
||||
((version / 10000).toString() + "." + version % 10000 / 100).toDouble()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val invalidDefaultBrowsers = listOf(
|
||||
"android",
|
||||
"com.huawei.android.internal.app",
|
||||
"com.zui.resolver",
|
||||
)
|
||||
|
||||
/**
|
||||
* ActivityManager#isLowRamDevice is based on a system property, which isn't
|
||||
* necessarily trustworthy. 1GB is supposedly the regular threshold.
|
||||
*
|
||||
* Instead, we consider anything with less than 3GB of RAM as low memory
|
||||
* considering how heavy image processing can be.
|
||||
*/
|
||||
fun isLowRamDevice(context: Context): Boolean {
|
||||
val memInfo = ActivityManager.MemoryInfo()
|
||||
context.getSystemService<ActivityManager>()!!.getMemoryInfo(memInfo)
|
||||
val totalMemBytes = memInfo.totalMem
|
||||
return totalMemBytes < 3L * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
@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,37 @@
|
||||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
|
||||
/**
|
||||
* Display a toast in this context.
|
||||
*
|
||||
* @param resource the text resource.
|
||||
* @param duration the duration of the toast. Defaults to short.
|
||||
*/
|
||||
fun Context.toast(
|
||||
resource: StringResource,
|
||||
duration: Int = Toast.LENGTH_SHORT,
|
||||
block: (Toast) -> Unit = {},
|
||||
): Toast {
|
||||
return toast(stringResource(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()
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
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,
|
||||
) {
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
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 kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
object WebViewUtil {
|
||||
const val SPOOF_PACKAGE_NAME = "org.chromium.chrome"
|
||||
|
||||
const val MINIMUM_WEBVIEW_VERSION = 118
|
||||
|
||||
/**
|
||||
* Uses the WebView's user agent string to create something similar to what Chrome on Android
|
||||
* would return.
|
||||
*
|
||||
* Example of WebView user agent string:
|
||||
* Mozilla/5.0 (Linux; Android 13; Pixel 7 Build/TQ3A.230901.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/116.0.0.0 Mobile Safari/537.36
|
||||
*
|
||||
* Example of Chrome on Android:
|
||||
* Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.3
|
||||
*/
|
||||
fun getInferredUserAgent(context: Context): String {
|
||||
return WebView(context)
|
||||
.getDefaultUserAgentString()
|
||||
.replace("; Android .*?\\)".toRegex(), "; Android 10; K)")
|
||||
.replace("Version/.* Chrome/".toRegex(), "Chrome/")
|
||||
}
|
||||
|
||||
fun getVersion(context: Context): String {
|
||||
val webView = WebView.getCurrentWebViewPackage() ?: return "how did you get here?"
|
||||
val pm = context.packageManager
|
||||
val label = webView.applicationInfo.loadLabel(pm)
|
||||
val version = webView.versionName
|
||||
return "$label $version"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
suspend fun WebView.getHtml(): String = suspendCancellableCoroutine {
|
||||
evaluateJavascript("document.documentElement.outerHTML") { html -> it.resume(html) }
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun WebView.setDefaultSettings() {
|
||||
with(settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
|
||||
// Allow zooming
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
}
|
||||
|
||||
CookieManager.getInstance().acceptThirdPartyCookies(this)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package tachiyomi.core.common
|
||||
|
||||
object Constants {
|
||||
const val URL_HELP = "https://mihon.app/docs/guides/troubleshooting/"
|
||||
|
||||
const val MANGA_EXTRA = "manga"
|
||||
|
||||
const val MAIN_ACTIVITY = "eu.kanade.tachiyomi.ui.main.MainActivity"
|
||||
|
||||
// Shortcut actions
|
||||
const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
|
||||
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
|
||||
const val SHORTCUT_UPDATES = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
|
||||
const val SHORTCUT_HISTORY = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
|
||||
const val SHORTCUT_SOURCES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
|
||||
const val SHORTCUT_EXTENSIONS = "eu.kanade.tachiyomi.EXTENSIONS"
|
||||
const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS"
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package tachiyomi.core.common.i18n
|
||||
|
||||
import android.content.Context
|
||||
import dev.icerock.moko.resources.PluralsResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import dev.icerock.moko.resources.desc.Plural
|
||||
import dev.icerock.moko.resources.desc.PluralFormatted
|
||||
import dev.icerock.moko.resources.desc.Resource
|
||||
import dev.icerock.moko.resources.desc.ResourceFormatted
|
||||
import dev.icerock.moko.resources.desc.StringDesc
|
||||
|
||||
fun Context.stringResource(resource: StringResource): String {
|
||||
return StringDesc.Resource(resource).toString(this).fixed()
|
||||
}
|
||||
|
||||
fun Context.stringResource(resource: StringResource, vararg args: Any): String {
|
||||
return StringDesc.ResourceFormatted(resource, *args).toString(this).fixed()
|
||||
}
|
||||
|
||||
fun Context.pluralStringResource(resource: PluralsResource, count: Int): String {
|
||||
return StringDesc.Plural(resource, count).toString(this).fixed()
|
||||
}
|
||||
|
||||
fun Context.pluralStringResource(resource: PluralsResource, count: Int, vararg args: Any): String {
|
||||
return StringDesc.PluralFormatted(resource, count, *args).toString(this).fixed()
|
||||
}
|
||||
|
||||
// TODO: janky workaround for https://github.com/icerockdev/moko-resources/issues/337
|
||||
private fun String.fixed() =
|
||||
this.replace("""\""", """"""")
|
@ -0,0 +1,194 @@
|
||||
package tachiyomi.core.common.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.Editor
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
sealed class AndroidPreference<T>(
|
||||
private val preferences: SharedPreferences,
|
||||
private val keyFlow: Flow<String?>,
|
||||
private val key: String,
|
||||
private val defaultValue: T,
|
||||
) : Preference<T> {
|
||||
|
||||
abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T
|
||||
|
||||
abstract fun write(key: String, value: T): Editor.() -> Unit
|
||||
|
||||
override fun key(): String {
|
||||
return key
|
||||
}
|
||||
|
||||
override fun get(): T {
|
||||
return try {
|
||||
read(preferences, key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
logcat { "Invalid value for $key; deleting" }
|
||||
delete()
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun set(value: T) {
|
||||
preferences.edit(action = write(key, value))
|
||||
}
|
||||
|
||||
override fun isSet(): Boolean {
|
||||
return preferences.contains(key)
|
||||
}
|
||||
|
||||
override fun delete() {
|
||||
preferences.edit {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun defaultValue(): T {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun changes(): Flow<T> {
|
||||
return keyFlow
|
||||
.filter { it == key || it == null }
|
||||
.onStart { emit("ignition") }
|
||||
.map { get() }
|
||||
.conflate()
|
||||
}
|
||||
|
||||
override fun stateIn(scope: CoroutineScope): StateFlow<T> {
|
||||
return changes().stateIn(scope, SharingStarted.Eagerly, get())
|
||||
}
|
||||
|
||||
class StringPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
) : AndroidPreference<String>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(
|
||||
preferences: SharedPreferences,
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
): String {
|
||||
return preferences.getString(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun write(key: String, value: String): Editor.() -> Unit = {
|
||||
putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class LongPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Long,
|
||||
) : AndroidPreference<Long>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long {
|
||||
return preferences.getLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Long): Editor.() -> Unit = {
|
||||
putLong(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class IntPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
) : AndroidPreference<Int>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int {
|
||||
return preferences.getInt(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Int): Editor.() -> Unit = {
|
||||
putInt(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class FloatPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Float,
|
||||
) : AndroidPreference<Float>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float {
|
||||
return preferences.getFloat(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Float): Editor.() -> Unit = {
|
||||
putFloat(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
) : AndroidPreference<Boolean>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(
|
||||
preferences: SharedPreferences,
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
): Boolean {
|
||||
return preferences.getBoolean(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Boolean): Editor.() -> Unit = {
|
||||
putBoolean(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class StringSetPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Set<String>,
|
||||
) : AndroidPreference<Set<String>>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(
|
||||
preferences: SharedPreferences,
|
||||
key: String,
|
||||
defaultValue: Set<String>,
|
||||
): Set<String> {
|
||||
return preferences.getStringSet(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Set<String>): Editor.() -> Unit = {
|
||||
putStringSet(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class Object<T>(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
val serializer: (T) -> String,
|
||||
val deserializer: (String) -> T,
|
||||
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
|
||||
return try {
|
||||
preferences.getString(key, null)?.let(deserializer) ?: defaultValue
|
||||
} catch (e: Exception) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: T): Editor.() -> Unit = {
|
||||
putString(key, serializer(value))
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package tachiyomi.core.common.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import tachiyomi.core.common.preference.AndroidPreference.BooleanPrimitive
|
||||
import tachiyomi.core.common.preference.AndroidPreference.FloatPrimitive
|
||||
import tachiyomi.core.common.preference.AndroidPreference.IntPrimitive
|
||||
import tachiyomi.core.common.preference.AndroidPreference.LongPrimitive
|
||||
import tachiyomi.core.common.preference.AndroidPreference.Object
|
||||
import tachiyomi.core.common.preference.AndroidPreference.StringPrimitive
|
||||
import tachiyomi.core.common.preference.AndroidPreference.StringSetPrimitive
|
||||
|
||||
class AndroidPreferenceStore(
|
||||
context: Context,
|
||||
private val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context),
|
||||
) : PreferenceStore {
|
||||
|
||||
private val keyFlow = sharedPreferences.keyFlow
|
||||
|
||||
override fun getString(key: String, defaultValue: String): Preference<String> {
|
||||
return StringPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Preference<Long> {
|
||||
return LongPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Preference<Int> {
|
||||
return IntPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Preference<Float> {
|
||||
return FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> {
|
||||
return BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> {
|
||||
return StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun <T> getObject(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T> {
|
||||
return Object(
|
||||
preferences = sharedPreferences,
|
||||
keyFlow = keyFlow,
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = serializer,
|
||||
deserializer = deserializer,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAll(): Map<String, *> {
|
||||
return sharedPreferences.all ?: emptyMap<String, Any>()
|
||||
}
|
||||
}
|
||||
|
||||
private val SharedPreferences.keyFlow
|
||||
get() = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? ->
|
||||
trySend(
|
||||
key,
|
||||
)
|
||||
}
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
package tachiyomi.core.common.preference
|
||||
|
||||
sealed class CheckboxState<T>(open val value: T) {
|
||||
|
||||
abstract fun next(): CheckboxState<T>
|
||||
|
||||
sealed class State<T>(override val value: T) : CheckboxState<T>(value) {
|
||||
data class Checked<T>(override val value: T) : State<T>(value)
|
||||
data class None<T>(override val value: T) : State<T>(value)
|
||||
|
||||
val isChecked: Boolean
|
||||
get() = this is Checked
|
||||
|
||||
override fun next(): CheckboxState<T> {
|
||||
return when (this) {
|
||||
is Checked -> None(value)
|
||||
is None -> Checked(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TriState<T>(override val value: T) : CheckboxState<T>(value) {
|
||||
data class Include<T>(override val value: T) : TriState<T>(value)
|
||||
data class Exclude<T>(override val value: T) : TriState<T>(value)
|
||||
data class None<T>(override val value: T) : TriState<T>(value)
|
||||
|
||||
override fun next(): CheckboxState<T> {
|
||||
return when (this) {
|
||||
is Exclude -> None(value)
|
||||
is Include -> Exclude(value)
|
||||
is None -> Include(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> T.asCheckboxState(condition: (T) -> Boolean): CheckboxState.State<T> {
|
||||
return if (condition(this)) {
|
||||
CheckboxState.State.Checked(this)
|
||||
} else {
|
||||
CheckboxState.State.None(this)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> List<T>.mapAsCheckboxState(condition: (T) -> Boolean): List<CheckboxState.State<T>> {
|
||||
return this.map { it.asCheckboxState(condition) }
|
||||
}
|
@ -0,0 +1,97 @@
|
||||
package tachiyomi.core.common.preference
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Local-copy implementation of PreferenceStore mostly for test and preview purposes
|
||||
*/
|
||||
class InMemoryPreferenceStore(
|
||||
initialPreferences: Sequence<InMemoryPreference<*>> = sequenceOf(),
|
||||
) : PreferenceStore {
|
||||
|
||||
private val preferences: Map<String, Preference<*>> =
|
||||
initialPreferences.toList().associateBy { it.key() }
|
||||
|
||||
override fun getString(key: String, defaultValue: String): Preference<String> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: String? = preferences[key]?.get() as? String
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Preference<Long> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Long? = preferences[key]?.get() as? Long
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Preference<Int> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Int? = preferences[key]?.get() as? Int
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Preference<Float> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Float? = preferences[key]?.get() as? Float
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Boolean? = preferences[key]?.get() as? Boolean
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> getObject(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: T? = preferences[key]?.get() as? T
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getAll(): Map<String, *> {
|
||||
return preferences
|
||||
}
|
||||
|
||||
class InMemoryPreference<T>(
|
||||
private val key: String,
|
||||
private var data: T?,
|
||||
private val defaultValue: T,
|
||||
) : Preference<T> {
|
||||
override fun key(): String = key
|
||||
|
||||
override fun get(): T = data ?: defaultValue()
|
||||
|
||||
override fun isSet(): Boolean = data != null
|
||||
|
||||
override fun delete() {
|
||||
data = null
|
||||
}
|
||||
|
||||
override fun defaultValue(): T = defaultValue
|
||||
|
||||
override fun changes(): Flow<T> = flow { data }
|
||||
|
||||
override fun stateIn(scope: CoroutineScope): StateFlow<T> {
|
||||
return changes().stateIn(scope, SharingStarted.Eagerly, get())
|
||||
}
|
||||
|
||||
override fun set(value: T) {
|
||||
data = value
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package tachiyomi.core.common.preference
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface Preference<T> {
|
||||
|
||||
fun key(): String
|
||||
|
||||
fun get(): T
|
||||
|
||||
fun set(value: T)
|
||||
|
||||
fun isSet(): Boolean
|
||||
|
||||
fun delete()
|
||||
|
||||
fun defaultValue(): T
|
||||
|
||||
fun changes(): Flow<T>
|
||||
|
||||
fun stateIn(scope: CoroutineScope): StateFlow<T>
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A preference that should not be exposed in places like backups without user consent.
|
||||
*/
|
||||
fun isPrivate(key: String): Boolean {
|
||||
return key.startsWith(PRIVATE_PREFIX)
|
||||
}
|
||||
fun privateKey(key: String): String {
|
||||
return "$PRIVATE_PREFIX$key"
|
||||
}
|
||||
|
||||
/**
|
||||
* A preference used for internal app state that isn't really a user preference
|
||||
* and therefore should not be in places like backups.
|
||||
*/
|
||||
fun isAppState(key: String): Boolean {
|
||||
return key.startsWith(APP_STATE_PREFIX)
|
||||
}
|
||||
fun appStateKey(key: String): String {
|
||||
return "$APP_STATE_PREFIX$key"
|
||||
}
|
||||
|
||||
private const val APP_STATE_PREFIX = "__APP_STATE_"
|
||||
private const val PRIVATE_PREFIX = "__PRIVATE_"
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T, R : T> Preference<T>.getAndSet(crossinline block: (T) -> R) = set(
|
||||
block(get()),
|
||||
)
|
||||
|
||||
operator fun <T> Preference<Set<T>>.plusAssign(item: T) {
|
||||
set(get() + item)
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.minusAssign(item: T) {
|
||||
set(get() - item)
|
||||
}
|
||||
|
||||
fun Preference<Boolean>.toggle(): Boolean {
|
||||
set(!get())
|
||||
return get()
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package tachiyomi.core.common.preference
|
||||
|
||||
interface PreferenceStore {
|
||||
|
||||
fun getString(key: String, defaultValue: String = ""): Preference<String>
|
||||
|
||||
fun getLong(key: String, defaultValue: Long = 0): Preference<Long>
|
||||
|
||||
fun getInt(key: String, defaultValue: Int = 0): Preference<Int>
|
||||
|
||||
fun getFloat(key: String, defaultValue: Float = 0f): Preference<Float>
|
||||
|
||||
fun getBoolean(key: String, defaultValue: Boolean = false): Preference<Boolean>
|
||||
|
||||
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
|
||||
|
||||
fun <T> getObject(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T>
|
||||
|
||||
fun getAll(): Map<String, *>
|
||||
}
|
||||
|
||||
inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
): Preference<T> {
|
||||
return getObject(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = { it.name },
|
||||
deserializer = {
|
||||
try {
|
||||
enumValueOf(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
defaultValue
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package tachiyomi.core.common.preference
|
||||
|
||||
enum class TriState {
|
||||
DISABLED, // Disable filter
|
||||
ENABLED_IS, // Enabled with "is" filter
|
||||
ENABLED_NOT, // Enabled with "not" filter
|
||||
;
|
||||
|
||||
fun next(): TriState {
|
||||
return when (this) {
|
||||
DISABLED -> ENABLED_IS
|
||||
ENABLED_IS -> ENABLED_NOT
|
||||
ENABLED_NOT -> DISABLED
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package tachiyomi.core.common.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.core.net.toUri
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import java.io.File
|
||||
|
||||
class AndroidStorageFolderProvider(
|
||||
private val context: Context,
|
||||
) : FolderProvider {
|
||||
|
||||
override fun directory(): File {
|
||||
return File(
|
||||
Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||
context.stringResource(MR.strings.app_name),
|
||||
)
|
||||
}
|
||||
|
||||
override fun path(): String {
|
||||
return directory().toUri().toString()
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package tachiyomi.core.common.storage
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface FolderProvider {
|
||||
|
||||
fun directory(): File
|
||||
|
||||
fun path(): String
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package tachiyomi.core.common.storage
|
||||
|
||||
import com.hippo.unifile.UniFile
|
||||
|
||||
val UniFile.extension: String?
|
||||
get() = name?.substringAfterLast('.')
|
||||
|
||||
val UniFile.nameWithoutExtension: String?
|
||||
get() = name?.substringBeforeLast('.')
|
||||
|
||||
val UniFile.displayablePath: String
|
||||
get() = filePath ?: uri.toString()
|
@ -0,0 +1,46 @@
|
||||
package tachiyomi.core.common.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.FileUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
|
||||
class UniFileTempFileManager(
|
||||
private val context: Context,
|
||||
) {
|
||||
|
||||
private val dir = File(context.externalCacheDir, "tmp")
|
||||
|
||||
fun createTempFile(file: UniFile): File {
|
||||
dir.mkdirs()
|
||||
|
||||
val inputStream = context.contentResolver.openInputStream(file.uri)!!
|
||||
val tempFile = File.createTempFile(
|
||||
file.nameWithoutExtension.orEmpty().padEnd(3), // Prefix must be 3+ chars
|
||||
null,
|
||||
dir,
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
FileUtils.copy(inputStream, tempFile.outputStream())
|
||||
} else {
|
||||
BufferedOutputStream(tempFile.outputStream()).use { tmpOut ->
|
||||
inputStream.use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var count: Int
|
||||
while (input.read(buffer).also { count = it } > 0) {
|
||||
tmpOut.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tempFile
|
||||
}
|
||||
|
||||
fun deleteTempFiles() {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package tachiyomi.core.common.util.lang
|
||||
|
||||
fun Boolean.toLong() = if (this) 1L else 0L
|
@ -0,0 +1,66 @@
|
||||
package tachiyomi.core.common.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.launchNonCancellable(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,
|
||||
)
|
||||
|
||||
suspend fun <T> withNonCancellableContext(block: suspend CoroutineScope.() -> T) =
|
||||
withContext(NonCancellable, block)
|
@ -0,0 +1,58 @@
|
||||
package tachiyomi.core.common.util.lang
|
||||
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.InternalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
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()
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
|
||||
invokeOnCancellation { sub.unsubscribe() }
|
@ -0,0 +1,15 @@
|
||||
package tachiyomi.core.common.util.lang
|
||||
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
private val collator by lazy {
|
||||
val locale = Locale.getDefault()
|
||||
Collator.getInstance(locale).apply {
|
||||
strength = Collator.PRIMARY
|
||||
}
|
||||
}
|
||||
|
||||
fun String.compareToWithCollator(other: String): Int {
|
||||
return collator.compare(this, other)
|
||||
}
|
@ -0,0 +1,582 @@
|
||||
package tachiyomi.core.common.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import com.hippo.unifile.UniFile
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.decoder.Format
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URLConnection
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object ImageUtil {
|
||||
|
||||
fun isImage(name: String?, openStream: (() -> InputStream)? = null): Boolean {
|
||||
if (name == null) return false
|
||||
|
||||
val contentType = try {
|
||||
URLConnection.guessContentTypeFromName(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: openStream?.let { findImageType(it)?.mime }
|
||||
return contentType?.startsWith("image/") ?: false
|
||||
}
|
||||
|
||||
fun findImageType(openStream: () -> InputStream): ImageType? {
|
||||
return openStream().use { findImageType(it) }
|
||||
}
|
||||
|
||||
fun findImageType(stream: InputStream): ImageType? {
|
||||
return try {
|
||||
when (getImageType(stream)?.format) {
|
||||
Format.Avif -> ImageType.AVIF
|
||||
Format.Gif -> ImageType.GIF
|
||||
Format.Heif -> ImageType.HEIF
|
||||
Format.Jpeg -> ImageType.JPEG
|
||||
Format.Jxl -> ImageType.JXL
|
||||
Format.Png -> ImageType.PNG
|
||||
Format.Webp -> ImageType.WEBP
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getExtensionFromMimeType(mime: String?): String {
|
||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
|
||||
?: SUPPLEMENTARY_MIMETYPE_MAPPING[mime]
|
||||
?: "jpg"
|
||||
}
|
||||
|
||||
fun isAnimatedAndSupported(stream: InputStream): Boolean {
|
||||
try {
|
||||
val type = getImageType(stream) ?: return false
|
||||
return when (type.format) {
|
||||
Format.Gif -> true
|
||||
// Coil supports animated WebP on Android 9.0+
|
||||
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
|
||||
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
else -> false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
/* Do Nothing */
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? {
|
||||
val bytes = ByteArray(32)
|
||||
|
||||
val length = if (stream.markSupported()) {
|
||||
stream.mark(bytes.size)
|
||||
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||
} else {
|
||||
stream.read(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
if (length == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ImageDecoder.findType(bytes)
|
||||
}
|
||||
|
||||
enum class ImageType(val mime: String, val extension: String) {
|
||||
AVIF("image/avif", "avif"),
|
||||
GIF("image/gif", "gif"),
|
||||
HEIF("image/heif", "heif"),
|
||||
JPEG("image/jpeg", "jpg"),
|
||||
JXL("image/jxl", "jxl"),
|
||||
PNG("image/png", "png"),
|
||||
WEBP("image/webp", "webp"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the image is wide (which we consider a double-page spread).
|
||||
*
|
||||
* @return true if the width is greater than the height
|
||||
*/
|
||||
fun isWideImage(imageStream: BufferedInputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream)
|
||||
return options.outWidth > options.outHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'side' part from imageStream and return it as InputStream.
|
||||
*/
|
||||
fun splitInHalf(imageStream: InputStream, side: Side): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
|
||||
val singlePage = Rect(0, 0, width / 2, height)
|
||||
|
||||
val half = createBitmap(width / 2, height)
|
||||
val part = when (side) {
|
||||
Side.RIGHT -> Rect(width - width / 2, 0, width, height)
|
||||
Side.LEFT -> Rect(0, 0, width / 2, height)
|
||||
}
|
||||
half.applyCanvas {
|
||||
drawBitmap(imageBitmap, part, singlePage, null)
|
||||
}
|
||||
val output = ByteArrayOutputStream()
|
||||
half.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
|
||||
fun rotateImage(imageStream: InputStream, degrees: Float): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
val rotated = rotateBitMap(imageBitmap, degrees)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
rotated.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
|
||||
private fun rotateBitMap(bitmap: Bitmap, degrees: Float): Bitmap {
|
||||
val matrix = Matrix().apply { postRotate(degrees) }
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the image into left and right parts, then merge them into a new image.
|
||||
*/
|
||||
fun splitAndMerge(imageStream: InputStream, upperSide: Side): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
|
||||
val result = createBitmap(width / 2, height * 2)
|
||||
result.applyCanvas {
|
||||
// right -> upper
|
||||
val rightPart = when (upperSide) {
|
||||
Side.RIGHT -> Rect(width - width / 2, 0, width, height)
|
||||
Side.LEFT -> Rect(0, 0, width / 2, height)
|
||||
}
|
||||
val upperPart = Rect(0, 0, width / 2, height)
|
||||
drawBitmap(imageBitmap, rightPart, upperPart, null)
|
||||
// left -> bottom
|
||||
val leftPart = when (upperSide) {
|
||||
Side.LEFT -> Rect(width - width / 2, 0, width, height)
|
||||
Side.RIGHT -> Rect(0, 0, width / 2, height)
|
||||
}
|
||||
val bottomPart = Rect(0, height, width / 2, height * 2)
|
||||
drawBitmap(imageBitmap, leftPart, bottomPart, null)
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
|
||||
enum class Side {
|
||||
RIGHT,
|
||||
LEFT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the image is considered a tall image.
|
||||
*
|
||||
* @return true if the height:width ratio is greater than 3.
|
||||
*/
|
||||
private fun isTallImage(imageStream: InputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream, resetAfterExtraction = false)
|
||||
return (options.outHeight / options.outWidth) > 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits tall images to improve performance of reader
|
||||
*/
|
||||
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean {
|
||||
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
|
||||
return true
|
||||
}
|
||||
|
||||
val bitmapRegionDecoder = getBitmapRegionDecoder(imageFile.openInputStream())
|
||||
if (bitmapRegionDecoder == null) {
|
||||
logcat { "Failed to create new instance of BitmapRegionDecoder" }
|
||||
return false
|
||||
}
|
||||
|
||||
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
|
||||
inJustDecodeBounds = false
|
||||
}
|
||||
|
||||
val splitDataList = options.splitData
|
||||
|
||||
return try {
|
||||
splitDataList.forEach { splitData ->
|
||||
val splitImageName = splitImageName(filenamePrefix, splitData.index)
|
||||
// Remove pre-existing split if exists (this split shouldn't exist under normal circumstances)
|
||||
tmpDir.findFile(splitImageName)?.delete()
|
||||
|
||||
val splitFile = tmpDir.createFile(splitImageName)!!
|
||||
|
||||
val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset)
|
||||
|
||||
splitFile.openOutputStream().use { outputStream ->
|
||||
val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options)
|
||||
splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
||||
splitBitmap.recycle()
|
||||
}
|
||||
logcat {
|
||||
"Success: Split #${splitData.index + 1} with topOffset=${splitData.topOffset} " +
|
||||
"height=${splitData.splitHeight} bottomOffset=${splitData.bottomOffset}"
|
||||
}
|
||||
}
|
||||
imageFile.delete()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
// Image splits were not successfully saved so delete them and keep the original image
|
||||
splitDataList
|
||||
.map { splitImageName(filenamePrefix, it.index) }
|
||||
.forEach { tmpDir.findFile(it)?.delete() }
|
||||
logcat(LogPriority.ERROR, e)
|
||||
false
|
||||
} finally {
|
||||
bitmapRegionDecoder.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitImageName(filenamePrefix: String, index: Int) = "${filenamePrefix}__${"%03d".format(
|
||||
Locale.ENGLISH,
|
||||
index + 1,
|
||||
)}.jpg"
|
||||
|
||||
private val BitmapFactory.Options.splitData
|
||||
get(): List<SplitData> {
|
||||
val imageHeight = outHeight
|
||||
val imageWidth = outWidth
|
||||
|
||||
// -1 so it doesn't try to split when imageHeight = optimalImageHeight
|
||||
val partCount = (imageHeight - 1) / optimalImageHeight + 1
|
||||
val optimalSplitHeight = imageHeight / partCount
|
||||
|
||||
logcat {
|
||||
"Generating SplitData for image (height: $imageHeight): " +
|
||||
"$partCount parts @ ${optimalSplitHeight}px height per part"
|
||||
}
|
||||
|
||||
return buildList {
|
||||
val range = 0..<partCount
|
||||
for (index in range) {
|
||||
// Only continue if the list is empty or there is image remaining
|
||||
if (isNotEmpty() && imageHeight <= last().bottomOffset) break
|
||||
|
||||
val topOffset = index * optimalSplitHeight
|
||||
var splitHeight = min(optimalSplitHeight, imageHeight - topOffset)
|
||||
|
||||
if (index == range.last) {
|
||||
val remainingHeight = imageHeight - (topOffset + splitHeight)
|
||||
splitHeight += remainingHeight
|
||||
}
|
||||
|
||||
add(SplitData(index, topOffset, splitHeight, imageWidth))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SplitData(
|
||||
val index: Int,
|
||||
val topOffset: Int,
|
||||
val splitHeight: Int,
|
||||
val splitWidth: Int,
|
||||
) {
|
||||
val bottomOffset = topOffset + splitHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Algorithm for determining what background to accompany a comic/manga page
|
||||
*/
|
||||
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
|
||||
val decoder = ImageDecoder.newInstance(imageStream)
|
||||
val image = decoder?.decode()
|
||||
decoder?.recycle()
|
||||
|
||||
val whiteColor = Color.WHITE
|
||||
if (image == null) return ColorDrawable(whiteColor)
|
||||
if (image.width < 50 || image.height < 50) {
|
||||
return ColorDrawable(whiteColor)
|
||||
}
|
||||
|
||||
val top = 5
|
||||
val bot = image.height - 5
|
||||
val left = (image.width * 0.0275).toInt()
|
||||
val right = image.width - left
|
||||
val midX = image.width / 2
|
||||
val midY = image.height / 2
|
||||
val offsetX = (image.width * 0.01).toInt()
|
||||
val leftOffsetX = left - offsetX
|
||||
val rightOffsetX = right + offsetX
|
||||
|
||||
val topLeftPixel = image[left, top]
|
||||
val topRightPixel = image[right, top]
|
||||
val midLeftPixel = image[left, midY]
|
||||
val midRightPixel = image[right, midY]
|
||||
val topCenterPixel = image[midX, top]
|
||||
val botLeftPixel = image[left, bot]
|
||||
val bottomCenterPixel = image[midX, bot]
|
||||
val botRightPixel = image[right, bot]
|
||||
|
||||
val topLeftIsDark = topLeftPixel.isDark()
|
||||
val topRightIsDark = topRightPixel.isDark()
|
||||
val midLeftIsDark = midLeftPixel.isDark()
|
||||
val midRightIsDark = midRightPixel.isDark()
|
||||
val topMidIsDark = topCenterPixel.isDark()
|
||||
val botLeftIsDark = botLeftPixel.isDark()
|
||||
val botRightIsDark = botRightPixel.isDark()
|
||||
|
||||
var darkBG =
|
||||
(topLeftIsDark && (botLeftIsDark || botRightIsDark || topRightIsDark || midLeftIsDark || topMidIsDark)) ||
|
||||
(topRightIsDark && (botRightIsDark || botLeftIsDark || midRightIsDark || topMidIsDark))
|
||||
|
||||
val topAndBotPixels =
|
||||
listOf(topLeftPixel, topCenterPixel, topRightPixel, botRightPixel, bottomCenterPixel, botLeftPixel)
|
||||
val isNotWhiteAndCloseTo = topAndBotPixels.mapIndexed { index, color ->
|
||||
val other = topAndBotPixels[(index + 1) % topAndBotPixels.size]
|
||||
!color.isWhite() && color.isCloseTo(other)
|
||||
}
|
||||
if (isNotWhiteAndCloseTo.all { it }) {
|
||||
return ColorDrawable(topLeftPixel)
|
||||
}
|
||||
|
||||
val cornerPixels = listOf(topLeftPixel, topRightPixel, botLeftPixel, botRightPixel)
|
||||
val numberOfWhiteCorners = cornerPixels.map { cornerPixel -> cornerPixel.isWhite() }
|
||||
.filter { it }
|
||||
.size
|
||||
if (numberOfWhiteCorners > 2) {
|
||||
darkBG = false
|
||||
}
|
||||
|
||||
var blackColor = when {
|
||||
topLeftIsDark -> topLeftPixel
|
||||
topRightIsDark -> topRightPixel
|
||||
botLeftIsDark -> botLeftPixel
|
||||
botRightIsDark -> botRightPixel
|
||||
else -> whiteColor
|
||||
}
|
||||
|
||||
var overallWhitePixels = 0
|
||||
var overallBlackPixels = 0
|
||||
var topBlackStreak = 0
|
||||
var topWhiteStreak = 0
|
||||
var botBlackStreak = 0
|
||||
var botWhiteStreak = 0
|
||||
outer@ for (x in intArrayOf(left, right, leftOffsetX, rightOffsetX)) {
|
||||
var whitePixelsStreak = 0
|
||||
var whitePixels = 0
|
||||
var blackPixelsStreak = 0
|
||||
var blackPixels = 0
|
||||
var blackStreak = false
|
||||
var whiteStreak = false
|
||||
val notOffset = x == left || x == right
|
||||
inner@ for ((index, y) in (0..<image.height step image.height / 25).withIndex()) {
|
||||
val pixel = image[x, y]
|
||||
val pixelOff = image[x + (if (x < image.width / 2) -offsetX else offsetX), y]
|
||||
if (pixel.isWhite()) {
|
||||
whitePixelsStreak++
|
||||
whitePixels++
|
||||
if (notOffset) {
|
||||
overallWhitePixels++
|
||||
}
|
||||
if (whitePixelsStreak > 14) {
|
||||
whiteStreak = true
|
||||
}
|
||||
if (whitePixelsStreak > 6 && whitePixelsStreak >= index - 1) {
|
||||
topWhiteStreak = whitePixelsStreak
|
||||
}
|
||||
} else {
|
||||
whitePixelsStreak = 0
|
||||
if (pixel.isDark() && pixelOff.isDark()) {
|
||||
blackPixels++
|
||||
if (notOffset) {
|
||||
overallBlackPixels++
|
||||
}
|
||||
blackPixelsStreak++
|
||||
if (blackPixelsStreak >= 14) {
|
||||
blackStreak = true
|
||||
}
|
||||
continue@inner
|
||||
}
|
||||
}
|
||||
if (blackPixelsStreak > 6 && blackPixelsStreak >= index - 1) {
|
||||
topBlackStreak = blackPixelsStreak
|
||||
}
|
||||
blackPixelsStreak = 0
|
||||
}
|
||||
if (blackPixelsStreak > 6) {
|
||||
botBlackStreak = blackPixelsStreak
|
||||
} else if (whitePixelsStreak > 6) {
|
||||
botWhiteStreak = whitePixelsStreak
|
||||
}
|
||||
when {
|
||||
blackPixels > 22 -> {
|
||||
if (x == right || x == rightOffsetX) {
|
||||
blackColor = when {
|
||||
topRightIsDark -> topRightPixel
|
||||
botRightIsDark -> botRightPixel
|
||||
else -> blackColor
|
||||
}
|
||||
}
|
||||
darkBG = true
|
||||
overallWhitePixels = 0
|
||||
break@outer
|
||||
}
|
||||
blackStreak -> {
|
||||
darkBG = true
|
||||
if (x == right || x == rightOffsetX) {
|
||||
blackColor = when {
|
||||
topRightIsDark -> topRightPixel
|
||||
botRightIsDark -> botRightPixel
|
||||
else -> blackColor
|
||||
}
|
||||
}
|
||||
if (blackPixels > 18) {
|
||||
overallWhitePixels = 0
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
whiteStreak || whitePixels > 22 -> darkBG = false
|
||||
}
|
||||
}
|
||||
|
||||
val topIsBlackStreak = topBlackStreak > topWhiteStreak
|
||||
val bottomIsBlackStreak = botBlackStreak > botWhiteStreak
|
||||
if (overallWhitePixels > 9 && overallWhitePixels > overallBlackPixels) {
|
||||
darkBG = false
|
||||
}
|
||||
if (topIsBlackStreak && bottomIsBlackStreak) {
|
||||
darkBG = true
|
||||
}
|
||||
|
||||
val isLandscape = context.resources.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
if (isLandscape) {
|
||||
return when {
|
||||
darkBG -> ColorDrawable(blackColor)
|
||||
else -> ColorDrawable(whiteColor)
|
||||
}
|
||||
}
|
||||
|
||||
val botCornersIsWhite = botLeftPixel.isWhite() && botRightPixel.isWhite()
|
||||
val topCornersIsWhite = topLeftPixel.isWhite() && topRightPixel.isWhite()
|
||||
|
||||
val topCornersIsDark = topLeftIsDark && topRightIsDark
|
||||
val botCornersIsDark = botLeftIsDark && botRightIsDark
|
||||
|
||||
val topOffsetCornersIsDark = image[leftOffsetX, top].isDark() && image[rightOffsetX, top].isDark()
|
||||
val botOffsetCornersIsDark = image[leftOffsetX, bot].isDark() && image[rightOffsetX, bot].isDark()
|
||||
|
||||
val gradient = when {
|
||||
darkBG && botCornersIsWhite -> {
|
||||
intArrayOf(blackColor, blackColor, whiteColor, whiteColor)
|
||||
}
|
||||
darkBG && topCornersIsWhite -> {
|
||||
intArrayOf(whiteColor, whiteColor, blackColor, blackColor)
|
||||
}
|
||||
darkBG -> {
|
||||
return ColorDrawable(blackColor)
|
||||
}
|
||||
topIsBlackStreak || (
|
||||
topCornersIsDark && topOffsetCornersIsDark &&
|
||||
(topMidIsDark || overallBlackPixels > 9)
|
||||
) -> {
|
||||
intArrayOf(blackColor, blackColor, whiteColor, whiteColor)
|
||||
}
|
||||
bottomIsBlackStreak || (
|
||||
botCornersIsDark && botOffsetCornersIsDark &&
|
||||
(bottomCenterPixel.isDark() || overallBlackPixels > 9)
|
||||
) -> {
|
||||
intArrayOf(whiteColor, whiteColor, blackColor, blackColor)
|
||||
}
|
||||
else -> {
|
||||
return ColorDrawable(whiteColor)
|
||||
}
|
||||
}
|
||||
|
||||
return GradientDrawable(
|
||||
GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
gradient,
|
||||
)
|
||||
}
|
||||
|
||||
private fun @receiver:ColorInt Int.isDark(): Boolean =
|
||||
red < 40 && blue < 40 && green < 40 && alpha > 200
|
||||
|
||||
private fun @receiver:ColorInt Int.isCloseTo(other: Int): Boolean =
|
||||
abs(red - other.red) < 30 && abs(green - other.green) < 30 && abs(blue - other.blue) < 30
|
||||
|
||||
private fun @receiver:ColorInt Int.isWhite(): Boolean =
|
||||
red + blue + green > 740
|
||||
|
||||
/**
|
||||
* Used to check an image's dimensions without loading it in the memory.
|
||||
*/
|
||||
private fun extractImageOptions(
|
||||
imageStream: InputStream,
|
||||
resetAfterExtraction: Boolean = true,
|
||||
): BitmapFactory.Options {
|
||||
imageStream.mark(imageStream.available() + 1)
|
||||
|
||||
val imageBytes = imageStream.readBytes()
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
||||
if (resetAfterExtraction) imageStream.reset()
|
||||
return options
|
||||
}
|
||||
|
||||
private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(imageStream)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
BitmapRegionDecoder.newInstance(imageStream, false)
|
||||
}
|
||||
}
|
||||
|
||||
private val optimalImageHeight = getDisplayMaxHeightInPx * 2
|
||||
|
||||
// Android doesn't include some mappings
|
||||
private val SUPPLEMENTARY_MIMETYPE_MAPPING = mapOf(
|
||||
// https://issuetracker.google.com/issues/182703810
|
||||
"image/jxl" to "jxl",
|
||||
)
|
||||
}
|
||||
|
||||
val getDisplayMaxHeightInPx: Int
|
||||
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }
|
@ -0,0 +1,18 @@
|
||||
package tachiyomi.core.common.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
|
||||
}
|
Reference in New Issue
Block a user