Extract source api from app module (#8014)

* Extract source api from app module

* Extract source online api from app module

(cherry picked from commit 86fe850794)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt
#	core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt
#	source-api/src/main/java/eu/kanade/tachiyomi/source/Source.kt
#	source-api/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt
This commit is contained in:
Andreas
2022-09-16 00:12:27 +02:00
committed by Jobobby04
parent b975b9b86f
commit 8a322ea28e
117 changed files with 547 additions and 422 deletions
@@ -20,6 +20,7 @@ import eu.kanade.domain.manga.interactor.GetExhFavoriteMangaWithMetadata
import eu.kanade.domain.manga.interactor.GetFavoriteEntries
import eu.kanade.domain.manga.interactor.GetFlatMetadataById
import eu.kanade.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.GetMangaBySource
import eu.kanade.domain.manga.interactor.GetMergedManga
import eu.kanade.domain.manga.interactor.GetMergedMangaById
@@ -65,6 +66,7 @@ import eu.kanade.domain.source.interactor.ToggleExcludeFromDataSaver
import eu.kanade.domain.source.interactor.ToggleSources
import eu.kanade.domain.source.repository.FeedSavedSearchRepository
import eu.kanade.domain.source.repository.SavedSearchRepository
import eu.kanade.tachiyomi.source.online.MetadataSource
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
@@ -100,6 +102,11 @@ class SYDomainModule : InjektModule {
addFactory { ReorderSortTag(get(), get()) }
addFactory { GetPagePreviews(get()) }
// Required for [MetadataSource]
addFactory<MetadataSource.GetMangaId> { GetManga(get()) }
addFactory<MetadataSource.GetFlatMetadataById> { GetFlatMetadataById(get()) }
addFactory<MetadataSource.InsertFlatMetadata> { InsertFlatMetadata(get()) }
addSingletonFactory<MangaMetadataRepository> { MangaMetadataRepositoryImpl(get()) }
addFactory { GetFlatMetadataById(get()) }
addFactory { InsertFlatMetadata(get()) }
@@ -1,6 +1,7 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.repository.MangaMetadataRepository
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.util.system.logcat
import exh.metadata.metadata.base.FlatMetadata
import kotlinx.coroutines.flow.Flow
@@ -9,9 +10,9 @@ import logcat.LogPriority
class GetFlatMetadataById(
private val mangaMetadataRepository: MangaMetadataRepository,
) {
) : MetadataSource.GetFlatMetadataById {
suspend fun await(id: Long): FlatMetadata? {
override suspend fun await(id: Long): FlatMetadata? {
return try {
val meta = mangaMetadataRepository.getMetadataById(id)
return if (meta != null) {
@@ -2,13 +2,14 @@ package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.repository.MangaRepository
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.Flow
import logcat.LogPriority
class GetManga(
private val mangaRepository: MangaRepository,
) {
) : MetadataSource.GetMangaId {
suspend fun await(id: Long): Manga? {
return try {
@@ -30,4 +31,10 @@ class GetManga(
fun subscribe(url: String, sourceId: Long): Flow<Manga?> {
return mangaRepository.getMangaByUrlAndSourceIdAsFlow(url, sourceId)
}
// SY -->
override suspend fun awaitId(url: String, sourceId: Long): Long? {
return await(url, sourceId)?.id
}
// SY <--
}
@@ -1,6 +1,7 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.repository.MangaMetadataRepository
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.util.system.logcat
import exh.metadata.metadata.base.FlatMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
@@ -8,7 +9,7 @@ import logcat.LogPriority
class InsertFlatMetadata(
private val mangaMetadataRepository: MangaMetadataRepository,
) {
) : MetadataSource.InsertFlatMetadata {
suspend fun await(flatMetadata: FlatMetadata) {
try {
@@ -18,7 +19,7 @@ class InsertFlatMetadata(
}
}
suspend fun await(metadata: RaisedSearchMetadata) {
override suspend fun await(metadata: RaisedSearchMetadata) {
try {
mangaMetadataRepository.insertMetadata(metadata)
} catch (e: Exception) {
@@ -72,14 +72,33 @@ import eu.kanade.presentation.util.isScrollingUp
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.source.online.all.Hitomi
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.source.online.all.PervEden
import eu.kanade.tachiyomi.source.online.english.EightMuses
import eu.kanade.tachiyomi.source.online.english.HBrowse
import eu.kanade.tachiyomi.source.online.english.Pururin
import eu.kanade.tachiyomi.source.online.english.Tsumino
import eu.kanade.tachiyomi.ui.manga.ChapterItem
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.ui.manga.PagePreviewState
import exh.source.MERGED_SOURCE_ID
import exh.source.getMainSource
import exh.ui.metadata.adapters.EHentaiDescription
import exh.ui.metadata.adapters.EightMusesDescription
import exh.ui.metadata.adapters.HBrowseDescription
import exh.ui.metadata.adapters.HitomiDescription
import exh.ui.metadata.adapters.MangaDexDescription
import exh.ui.metadata.adapters.NHentaiDescription
import exh.ui.metadata.adapters.PervEdenDescription
import exh.ui.metadata.adapters.PururinDescription
import exh.ui.metadata.adapters.TsuminoDescription
@Composable
fun MangaScreen(
@@ -255,7 +274,7 @@ private fun MangaScreenSmallImpl(
val chapters = remember(state) { state.processedChapters.toList() }
// SY -->
val metadataSource = remember(state.source.id) { state.source.getMainSource<MetadataSource<*, *>>() }
val metadataDescription = metadataDescription(state.source)
// SY <--
val internalOnBackPressed = {
@@ -408,12 +427,12 @@ private fun MangaScreenSmallImpl(
}
// SY -->
if (metadataSource != null) {
if (metadataDescription != null) {
item(
key = MangaScreenItem.METADATA_INFO,
contentType = MangaScreenItem.METADATA_INFO,
) {
metadataSource.DescriptionComposable(
metadataDescription(
state = state,
openMetadataViewer = onMetadataViewerClicked,
search = { onSearch(it, false) },
@@ -540,7 +559,7 @@ fun MangaScreenLargeImpl(
val chapters = remember(state) { state.processedChapters.toList() }
// SY -->
val metadataSource = remember(state.source.id) { state.source.getMainSource<MetadataSource<*, *>>() }
val metadataDescription = metadataDescription(state.source)
// SY <--
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
@@ -681,7 +700,7 @@ fun MangaScreenLargeImpl(
// SY <--
)
// SY -->
metadataSource?.DescriptionComposable(
metadataDescription?.invoke(
state = state,
openMetadataViewer = onMetadataViewerClicked,
search = { onSearch(it, false) },
@@ -847,3 +866,42 @@ private fun onChapterItemClick(
else -> onChapterClicked(chapterItem.chapter)
}
}
typealias MetadataDescriptionComposable = @Composable (state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) -> Unit
@Composable
fun metadataDescription(source: Source): MetadataDescriptionComposable? {
val metadataSource = remember(source.id) { source.getMainSource<MetadataSource<*, *>>() }
return remember(metadataSource) {
when (metadataSource) {
is EHentai -> { state, openMetadataViewer, search ->
EHentaiDescription(state, openMetadataViewer, search)
}
is Hitomi -> { state, openMetadataViewer, _ ->
HitomiDescription(state, openMetadataViewer)
}
is MangaDex -> { state, openMetadataViewer, _ ->
MangaDexDescription(state, openMetadataViewer)
}
is NHentai -> { state, openMetadataViewer, _ ->
NHentaiDescription(state, openMetadataViewer)
}
is PervEden -> { state, openMetadataViewer, _ ->
PervEdenDescription(state, openMetadataViewer)
}
is EightMuses -> { state, openMetadataViewer, _ ->
EightMusesDescription(state, openMetadataViewer)
}
is HBrowse -> { state, openMetadataViewer, _ ->
HBrowseDescription(state, openMetadataViewer)
}
is Pururin -> { state, openMetadataViewer, _ ->
PururinDescription(state, openMetadataViewer)
}
is Tsumino -> { state, openMetadataViewer, _ ->
TsuminoDescription(state, openMetadataViewer)
}
else -> null
}
}
}
@@ -48,6 +48,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.library.CustomMangaManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.copyFrom
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toLong
@@ -1,6 +1,10 @@
package eu.kanade.tachiyomi.data.database.models
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.CustomMangaManager
import eu.kanade.tachiyomi.source.model.SManga
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
open class MangaImpl : Manga {
@@ -77,6 +81,19 @@ open class MangaImpl : Manga {
private set
var ogStatus: Int = 0
private set
override val originalTitle: String
get() = ogTitle
override val originalAuthor: String?
get() = ogAuthor ?: author
override val originalArtist: String?
get() = ogArtist ?: artist
override val originalDescription: String?
get() = ogDesc ?: description
override val originalGenre: String?
get() = ogGenre ?: genre
override val originalStatus: Int
get() = ogStatus
// SY <--
override fun equals(other: Any?): Boolean {
@@ -93,6 +110,18 @@ open class MangaImpl : Manga {
}
// SY -->
override fun copyFrom(other: SManga) {
// EXH -->
if (other.title.isNotBlank() && originalTitle != other.title) {
val source = (this as? Manga)?.source
if (source != null) {
Injekt.get<DownloadManager>().renameMangaDir(originalTitle, other.originalTitle, source)
}
}
// EXH <--
super.copyFrom(other)
}
companion object {
private val customMangaManager: CustomMangaManager by injectLazy()
}
@@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.isDevFlavor
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import java.io.File
import java.text.DateFormat
@@ -1,54 +0,0 @@
package eu.kanade.tachiyomi.network
import android.webkit.CookieManager
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
class AndroidCookieJar : CookieJar {
private val manager = CookieManager.getInstance()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val urlString = url.toString()
cookies.forEach { manager.setCookie(urlString, it.toString()) }
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return get(url)
}
fun get(url: HttpUrl): List<Cookie> {
val cookies = manager.getCookie(url.toString())
return if (cookies != null && cookies.isNotEmpty()) {
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
} else {
emptyList()
}
}
fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1): Int {
val urlString = url.toString()
val cookies = manager.getCookie(urlString) ?: return 0
fun List<String>.filterNames(): List<String> {
return if (cookieNames != null) {
this.filter { it in cookieNames }
} else {
this
}
}
return cookies.split(";")
.map { it.substringBefore("=") }
.filterNames()
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
.count()
}
fun removeAll() {
manager.removeAllCookies {}
}
}
@@ -1,174 +0,0 @@
package eu.kanade.tachiyomi.network
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import java.net.InetAddress
/**
* Based on https://github.com/square/okhttp/blob/ef5d0c83f7bbd3a0c0534e7ca23cbc4ee7550f3b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DohProviders.java
*/
const val PREF_DOH_CLOUDFLARE = 1
const val PREF_DOH_GOOGLE = 2
const val PREF_DOH_ADGUARD = 3
const val PREF_DOH_QUAD9 = 4
const val PREF_DOH_ALIDNS = 5
const val PREF_DOH_DNSPOD = 6
const val PREF_DOH_360 = 7
const val PREF_DOH_QUAD101 = 8
const val PREF_DOH_MULLVAD = 9
const val PREF_DOH_CONTROLD = 10
const val PREF_DOH_NJALLA = 11
fun OkHttpClient.Builder.dohCloudflare() = dns(
DnsOverHttps.Builder().client(build())
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("162.159.36.1"),
InetAddress.getByName("162.159.46.1"),
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1"),
InetAddress.getByName("162.159.132.53"),
InetAddress.getByName("2606:4700:4700::1111"),
InetAddress.getByName("2606:4700:4700::1001"),
InetAddress.getByName("2606:4700:4700::0064"),
InetAddress.getByName("2606:4700:4700::6400"),
)
.build(),
)
fun OkHttpClient.Builder.dohGoogle() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.google/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("8.8.4.4"),
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("2001:4860:4860::8888"),
InetAddress.getByName("2001:4860:4860::8844"),
)
.build(),
)
// AdGuard "Default" DNS works too but for the sake of making sure no site is blacklisted,
// we use "Unfiltered"
fun OkHttpClient.Builder.dohAdGuard() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("94.140.14.140"),
InetAddress.getByName("94.140.14.141"),
InetAddress.getByName("2a10:50c0::1:ff"),
InetAddress.getByName("2a10:50c0::2:ff"),
)
.build(),
)
fun OkHttpClient.Builder.dohQuad9() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.quad9.net/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("9.9.9.9"),
InetAddress.getByName("149.112.112.112"),
InetAddress.getByName("2620:fe::fe"),
InetAddress.getByName("2620:fe::9"),
)
.build(),
)
fun OkHttpClient.Builder.dohAliDNS() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.alidns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("223.5.5.5"),
InetAddress.getByName("223.6.6.6"),
InetAddress.getByName("2400:3200::1"),
InetAddress.getByName("2400:3200:baba::1"),
)
.build(),
)
fun OkHttpClient.Builder.dohDNSPod() = dns(
DnsOverHttps.Builder().client(build())
.url("https://doh.pub/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("1.12.12.12"),
InetAddress.getByName("120.53.53.53"),
)
.build(),
)
fun OkHttpClient.Builder.doh360() = dns(
DnsOverHttps.Builder().client(build())
.url("https://doh.360.cn/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("101.226.4.6"),
InetAddress.getByName("218.30.118.6"),
InetAddress.getByName("123.125.81.6"),
InetAddress.getByName("140.207.198.6"),
InetAddress.getByName("180.163.249.75"),
InetAddress.getByName("101.199.113.208"),
InetAddress.getByName("36.99.170.86"),
)
.build(),
)
fun OkHttpClient.Builder.dohQuad101() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.twnic.tw/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("101.101.101.101"),
InetAddress.getByName("2001:de4::101"),
InetAddress.getByName("2001:de4::102"),
)
.build(),
)
/*
* Mullvad DoH
* without ad blocking option
* Source : https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/
*/
fun OkHttpClient.Builder.dohMullvad() = dns(
DnsOverHttps.Builder().client(build())
.url("https://doh.mullvad.net/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("194.242.2.2"),
InetAddress.getByName("193.19.108.2"),
InetAddress.getByName("2a07:e340::2"),
)
.build(),
)
/*
* Control D
* unfiltered option
* Source : https://controld.com/free-dns/?
*/
fun OkHttpClient.Builder.dohControlD() = dns(
DnsOverHttps.Builder().client(build())
.url("https://freedns.controld.com/p0".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("76.76.2.0"),
InetAddress.getByName("76.76.10.0"),
InetAddress.getByName("2606:1a40::"),
InetAddress.getByName("2606:1a40:1::"),
)
.build(),
)
/*
* Njalla
*
* Non logging and uncensored
*/
fun OkHttpClient.Builder.dohNajalla() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.njal.la/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("95.215.19.53"),
InetAddress.getByName("2001:67c:2354:2::53"),
)
.build(),
)
@@ -1,79 +0,0 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.Http103Interceptor
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.concurrent.TimeUnit
/* SY --> */
open /* SY <-- */ class NetworkHelper(context: Context) {
private val preferences: PreferencesHelper by injectLazy()
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
/* SY --> */
open /* SY <-- */val cookieManager = AndroidCookieJar()
private val userAgentInterceptor by lazy { UserAgentInterceptor() }
private val http103Interceptor by lazy { Http103Interceptor(context) }
private val cloudflareInterceptor by lazy { CloudflareInterceptor(context) }
private val baseClientBuilder: OkHttpClient.Builder
get() {
val builder = OkHttpClient.Builder()
.cookieJar(cookieManager)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.fastFallback(true)
.addInterceptor(userAgentInterceptor)
.addNetworkInterceptor(http103Interceptor)
if (BuildConfig.DEBUG) {
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS
}
builder.addNetworkInterceptor(httpLoggingInterceptor)
}
when (preferences.dohProvider()) {
PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()
PREF_DOH_GOOGLE -> builder.dohGoogle()
PREF_DOH_ADGUARD -> builder.dohAdGuard()
PREF_DOH_QUAD9 -> builder.dohQuad9()
PREF_DOH_ALIDNS -> builder.dohAliDNS()
PREF_DOH_DNSPOD -> builder.dohDNSPod()
PREF_DOH_360 -> builder.doh360()
PREF_DOH_QUAD101 -> builder.dohQuad101()
PREF_DOH_MULLVAD -> builder.dohMullvad()
PREF_DOH_CONTROLD -> builder.dohControlD()
PREF_DOH_NJALLA -> builder.dohNajalla()
}
return builder
}
/* SY --> */
open /* SY <-- */val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
/* SY --> */
open /* SY <-- */val cloudflareClient by lazy {
client.newBuilder()
.addInterceptor(cloudflareInterceptor)
.build()
}
val defaultUserAgent by lazy {
preferences.defaultUserAgent().get()
}
}
@@ -1,127 +0,0 @@
package eu.kanade.tachiyomi.network
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.closeQuietly
import rx.Observable
import rx.Producer
import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.fullType
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resumeWithException
val jsonMime = "application/json; charset=utf-8".toMediaType()
fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber.
val call = clone()
// Wrap the call in a helper which handles both unsubscription and backpressure.
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
override fun request(n: Long) {
if (n == 0L || !compareAndSet(false, true)) return
try {
val response = call.execute()
if (!subscriber.isUnsubscribed) {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
}
}
}
override fun unsubscribe() {
call.cancel()
}
override fun isUnsubscribed(): Boolean {
return call.isCanceled()
}
}
subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter)
}
}
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
suspend fun Call.await(): Response {
return suspendCancellableCoroutine { continuation ->
enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
continuation.resumeWithException(HttpException(response.code))
return
}
continuation.resume(response) {
response.body.closeQuietly()
}
}
override fun onFailure(call: Call, e: IOException) {
// Don't bother with resuming the continuation if it is already cancelled.
if (continuation.isCancelled) return
continuation.resumeWithException(e)
}
},
)
continuation.invokeOnCancellation {
try {
cancel()
} catch (ex: Throwable) {
// Ignore cancel exception
}
}
}
}
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code)
}
}
}
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body, listener))
.build()
}
.build()
return progressClient.newCall(request)
}
inline fun <reified T> Response.parseAs(/* SY --> */ json: Json = Injekt.getInstance(fullType<Json>().type) /* SY <-- */): T {
// Avoiding Injekt.get<Json>() due to compiler issues
// val json = Injekt.getInstance<Json>(fullType<Json>().type)
this.use {
val responseBody = it.body.string()
return json.decodeFromString(responseBody)
}
}
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
@@ -1,5 +0,0 @@
package eu.kanade.tachiyomi.network
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
}
@@ -1,44 +0,0 @@
package eu.kanade.tachiyomi.network
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Source
import okio.buffer
import java.io.IOException
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType? {
return responseBody.contentType()
}
override fun contentLength(): Long {
return responseBody.contentLength()
}
override fun source(): BufferedSource {
return bufferedSource
}
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead = 0L
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
}
@@ -1,67 +0,0 @@
package eu.kanade.tachiyomi.network
import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.Request
import okhttp3.RequestBody
import java.util.concurrent.TimeUnit.MINUTES
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build()
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
internal val CACHE_CONTROL_NO_STORE = CacheControl.Builder().noStore().build()
fun GET(
url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
}
fun POST(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.post(body)
.headers(headers)
.cacheControl(cache)
.build()
}
fun PUT(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.put(body)
.headers(headers)
.cacheControl(cache)
.build()
}
fun DELETE(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.delete(body)
.headers(headers)
.cacheControl(cache)
.build()
}
@@ -1,149 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.WebView
import android.widget.Toast
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import eu.kanade.tachiyomi.util.system.isOutdated
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.toast
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class CloudflareInterceptor(private val context: Context) : WebViewInterceptor(context) {
private val executor = ContextCompat.getMainExecutor(context)
private val networkHelper: NetworkHelper by injectLazy()
override fun shouldIntercept(response: Response): Boolean {
// Check if Cloudflare anti-bot is on
return response.code in ERROR_CODES && response.header("Server") in SERVER_CHECK
}
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
try {
response.close()
networkHelper.cookieManager.remove(request.url, COOKIE_NAMES, 0)
val oldCookie = networkHelper.cookieManager.get(request.url)
.firstOrNull { it.name == "cf_clearance" }
resolveWithWebView(request, oldCookie)
return chain.proceed(request)
}
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
catch (e: CloudflareBypassException) {
throw IOException(context.getString(R.string.information_cloudflare_bypass_failure))
} catch (e: Exception) {
throw IOException(e)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) {
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
var webView: WebView? = null
var challengeFound = false
var cloudflareBypassed = false
var isWebViewOutdated = false
val origRequestUrl = originalRequest.url.toString()
val headers = originalRequest.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap()
executor.execute {
val webview = WebView(context)
webView = webview
webview.setDefaultSettings()
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
webview.settings.userAgentString = originalRequest.header("User-Agent")
?: networkHelper.defaultUserAgent
webview.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean {
return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl())
.firstOrNull { it.name == "cf_clearance" }
.let { it != null && it != oldCookie }
}
if (isCloudFlareBypassed()) {
cloudflareBypassed = true
latch.countDown()
}
if (url == origRequestUrl && !challengeFound) {
// The first request didn't return the challenge, abort.
latch.countDown()
}
}
override fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean,
) {
if (isMainFrame) {
if (errorCode in ERROR_CODES) {
// Found the Cloudflare challenge page.
challengeFound = true
} else {
// Unlock thread, the challenge wasn't found.
latch.countDown()
}
}
}
}
webView?.loadUrl(origRequestUrl, headers)
}
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
// around 4 seconds but it can take more due to slow networks or server issues.
latch.await(12, TimeUnit.SECONDS)
executor.execute {
if (!cloudflareBypassed) {
isWebViewOutdated = webView?.isOutdated() == true
}
webView?.stopLoading()
webView?.destroy()
webView = null
}
// Throw exception if we failed to bypass Cloudflare
if (!cloudflareBypassed) {
// Prompt user to update WebView if it seems too outdated
if (isWebViewOutdated) {
context.toast(R.string.information_webview_outdated, Toast.LENGTH_LONG)
}
throw CloudflareBypassException()
}
}
}
private val ERROR_CODES = listOf(403, 503)
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("cf_clearance")
private class CloudflareBypassException : Exception()
@@ -1,112 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import eu.kanade.tachiyomi.util.system.logcat
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
// TODO: Remove when OkHttp can handle HTTP 103 responses
class Http103Interceptor(context: Context) : WebViewInterceptor(context) {
private val executor = ContextCompat.getMainExecutor(context)
override fun shouldIntercept(response: Response): Boolean {
return response.code == 103
}
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
logcat { "Proceeding with WebView for request $request" }
try {
return proceedWithWebView(request, response)
} catch (e: Exception) {
throw IOException(e)
}
}
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
private fun proceedWithWebView(originalRequest: Request, originalResponse: Response): Response {
// We need to lock this thread until the WebView loads the page, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
val jsInterface = JsInterface(latch)
var outerWebView: WebView? = null
var exception: Exception? = null
val requestUrl = originalRequest.url.toString()
val headers = originalRequest.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap()
executor.execute {
val webview = createWebView(originalRequest).also { outerWebView = it }
webview.addJavascriptInterface(jsInterface, "android")
webview.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) {
view.evaluateJavascript(jsScript) {}
}
override fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean,
) {
if (isMainFrame) {
exception = Exception("Error $errorCode - $description")
latch.countDown()
}
}
}
webview.loadUrl(requestUrl, headers)
}
latch.await(10, TimeUnit.SECONDS)
executor.execute {
outerWebView?.run {
stopLoading()
destroy()
}
outerWebView = null
}
exception?.let { throw it }
val responseHtml = jsInterface.responseHtml ?: throw Exception("Couldn't fetch site through webview")
return originalResponse.newBuilder()
.code(200)
.protocol(Protocol.HTTP_1_1)
.message("OK")
.body(responseHtml.toResponseBody(htmlMediaType))
.build()
}
}
internal class JsInterface(private val latch: CountDownLatch, var responseHtml: String? = null) {
@Suppress("UNUSED")
@JavascriptInterface
fun passPayload(passedPayload: String) {
responseHtml = passedPayload
latch.countDown()
}
}
private const val jsScript = "window.android.passPayload(document.querySelector('html').outerHTML)"
private val htmlMediaType = "text/html".toMediaType()
@@ -1,105 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import android.os.SystemClock
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import java.io.IOException
import java.util.ArrayDeque
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
/**
* An OkHttp interceptor that handles rate limiting.
*
* Examples:
*
* permits = 5, period = 1, unit = seconds => 5 requests per second
* permits = 10, period = 2, unit = minutes => 10 requests per 2 minutes
*
* @since extension-lib 1.3
*
* @param permits {Int} Number of requests allowed within a period of units.
* @param period {Long} The limiting duration. Defaults to 1.
* @param unit {TimeUnit} The unit of time for the period. Defaults to seconds.
*/
fun OkHttpClient.Builder.rateLimit(
permits: Int,
period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS,
) = addInterceptor(RateLimitInterceptor(null, permits, period, unit))
/** We can probably accept domains or wildcards by comparing with [endsWith], etc. */
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
internal class RateLimitInterceptor(
private val host: String?,
private val permits: Int,
period: Long,
unit: TimeUnit,
) : Interceptor {
private val requestQueue = ArrayDeque<Long>(permits)
private val rateLimitMillis = unit.toMillis(period)
private val fairLock = Semaphore(1, true)
override fun intercept(chain: Interceptor.Chain): Response {
val call = chain.call()
if (call.isCanceled()) throw IOException("Canceled")
val request = chain.request()
when (host) {
null, request.url.host -> {} // need rate limit
else -> return chain.proceed(request)
}
try {
fairLock.acquire()
} catch (e: InterruptedException) {
throw IOException(e)
}
val requestQueue = this.requestQueue
val timestamp: Long
try {
synchronized(requestQueue) {
while (requestQueue.size >= permits) { // queue is full, remove expired entries
val periodStart = SystemClock.elapsedRealtime() - rateLimitMillis
var hasRemovedExpired = false
while (requestQueue.isEmpty().not() && requestQueue.first <= periodStart) {
requestQueue.removeFirst()
hasRemovedExpired = true
}
if (call.isCanceled()) {
throw IOException("Canceled")
} else if (hasRemovedExpired) {
break
} else {
try { // wait for the first entry to expire, or notified by cached response
(requestQueue as Object).wait(requestQueue.first - periodStart)
} catch (_: InterruptedException) {
continue
}
}
}
// add request to queue
timestamp = SystemClock.elapsedRealtime()
requestQueue.addLast(timestamp)
}
} finally {
fairLock.release()
}
val response = chain.proceed(request)
if (response.networkResponse == null) { // response is cached, remove it from queue
synchronized(requestQueue) {
if (requestQueue.isEmpty() || timestamp < requestQueue.first) return@synchronized
requestQueue.removeFirstOccurrence(timestamp)
(requestQueue as Object).notifyAll()
}
}
return response
}
}
@@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* An OkHttp interceptor that handles given url host's rate limiting.
*
* Examples:
*
* httpUrl = "api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1, unit = seconds => 5 requests per second to api.manga.com
* httpUrl = "imagecdn.manga.com".toHttpUrlOrNull(), permits = 10, period = 2, unit = minutes => 10 requests per 2 minutes to imagecdn.manga.com
*
* @since extension-lib 1.3
*
* @param httpUrl {HttpUrl} The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
* @param permits {Int} Number of requests allowed within a period of units.
* @param period {Long} The limiting duration. Defaults to 1.
* @param unit {TimeUnit} The unit of time for the period. Defaults to seconds.
*/
fun OkHttpClient.Builder.rateLimitHost(
httpUrl: HttpUrl,
permits: Int,
period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS,
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period, unit))
@@ -1,26 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class UserAgentInterceptor : Interceptor {
private val networkHelper: NetworkHelper by injectLazy()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
return if (originalRequest.header("User-Agent").isNullOrEmpty()) {
val newRequest = originalRequest
.newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent", networkHelper.defaultUserAgent)
.build()
chain.proceed(newRequest)
} else {
chain.proceed(originalRequest)
}
}
}
@@ -1,68 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import android.content.Context
import android.os.Build
import android.webkit.WebSettings
import android.webkit.WebView
import android.widget.Toast
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.toast
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
abstract class WebViewInterceptor(private val context: Context) : Interceptor {
private val networkHelper: NetworkHelper by injectLazy()
/**
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
* blocking the main thread too much. If used too often we could consider moving it to the
* Application class.
*/
private val initWebView by lazy {
// Crashes on some devices. We skip this in some cases since the only impact is slower
// WebView init in those rare cases.
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1279562
if (DeviceUtil.isMiui || Build.VERSION.SDK_INT == Build.VERSION_CODES.S && DeviceUtil.isSamsung) {
return@lazy
}
WebSettings.getDefaultUserAgent(context)
}
abstract fun shouldIntercept(response: Response): Boolean
abstract fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (!shouldIntercept(response)) {
return response
}
if (!WebViewUtil.supportsWebView(context)) {
launchUI {
context.toast(R.string.information_webview_required, Toast.LENGTH_LONG)
}
return response
}
initWebView
return intercept(chain, request, response)
}
fun createWebView(request: Request): WebView {
val webview = WebView(context)
webview.setDefaultSettings()
webview.settings.userAgentString = request.header("User-Agent") ?: networkHelper.defaultUserAgent
return webview
}
}
@@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import rx.Observable
interface CatalogueSource : Source {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
*/
fun fetchPopularManga(page: Int): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
/**
* Returns the list of filters for the source.
*/
fun getFilterList(): FilterList
}
@@ -1,8 +0,0 @@
package eu.kanade.tachiyomi.source
import androidx.preference.PreferenceScreen
interface ConfigurableSource : Source {
fun setupPreferenceScreen(screen: PreferenceScreen)
}
@@ -1,44 +0,0 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import okhttp3.CacheControl
import okhttp3.Response
interface PagePreviewSource : Source {
suspend fun getPagePreviewList(manga: SManga, page: Int): PagePreviewPage
suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl? = null): Response
}
@Serializable
data class PagePreviewPage(
val page: Int,
val pagePreviews: List<PagePreviewInfo>,
val hasNextPage: Boolean,
val pagePreviewPages: Int?,
)
@Serializable
data class PagePreviewInfo(
val index: Int,
val imageUrl: String,
@Transient
private val _progress: MutableStateFlow<Int> = MutableStateFlow(-1),
) : ProgressListener {
@Transient
val progress = _progress.asStateFlow()
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
_progress.value = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
}
@@ -1,140 +0,0 @@
package eu.kanade.tachiyomi.source
import android.graphics.drawable.Drawable
import eu.kanade.domain.source.model.SourceData
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.awaitSingle
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface Source {
/**
* Id for the source. Must be unique.
*/
val id: Long
/**
* Name of the source.
*/
val name: String
val lang: String
get() = ""
/**
* Returns an observable with the updated details for a manga.
*
* @param manga the manga to update.
*/
@Deprecated(
"Use the 1.x API instead",
ReplaceWith("getMangaDetails"),
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
/**
* Returns an observable with all the available chapters for a manga.
*
* @param manga the manga to update.
*/
@Deprecated(
"Use the 1.x API instead",
ReplaceWith("getChapterList"),
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
// TODO: remove direct usages on this method
/**
* Returns an observable with the list of pages a chapter has.
*
* @param chapter the chapter.
*/
@Deprecated(
"Use the 1.x API instead",
ReplaceWith("getPageList"),
)
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
/**
* [1.x API] Get the updated details for a manga.
*/
@Suppress("DEPRECATION")
suspend fun getMangaDetails(manga: SManga): SManga {
return fetchMangaDetails(manga).awaitSingle()
}
/**
* [1.x API] Get all the available chapters for a manga.
*/
@Suppress("DEPRECATION")
suspend fun getChapterList(manga: SManga): List<SChapter> {
return fetchChapterList(manga).awaitSingle()
}
/**
* [1.x API] Get the list of pages a chapter has.
*/
@Suppress("DEPRECATION")
suspend fun getPageList(chapter: SChapter): List<Page> {
return fetchPageList(chapter).awaitSingle()
}
}
fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
fun Source.getPreferenceKey(): String = "source_$id"
fun Source.toSourceData(): SourceData = SourceData(id = id, lang = lang, name = name)
fun Source.getNameForMangaInfo(mergeSources: List<Source>?): String {
val preferences = Injekt.get<PreferencesHelper>()
val enabledLanguages = preferences.enabledLanguages().get()
.filterNot { it in listOf("all", "other") }
val hasOneActiveLanguages = enabledLanguages.size == 1
val isInEnabledLanguages = lang in enabledLanguages
return when {
// SY -->
!mergeSources.isNullOrEmpty() -> getMergedSourcesString(
mergeSources,
enabledLanguages,
hasOneActiveLanguages,
)
// SY <--
// For edge cases where user disables a source they got manga of in their library.
hasOneActiveLanguages && !isInEnabledLanguages -> toString()
// Hide the language tag when only one language is used.
hasOneActiveLanguages && isInEnabledLanguages -> name
else -> toString()
}
}
// SY -->
private fun getMergedSourcesString(
mergeSources: List<Source>,
enabledLangs: List<String>,
onlyName: Boolean,
): String {
return if (onlyName) {
mergeSources.joinToString { source ->
if (source.lang !in enabledLangs) {
source.toString()
} else {
source.name
}
}
} else {
mergeSources.joinToString()
}
}
// SY <--
fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource
@@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.source
import android.graphics.drawable.Drawable
import eu.kanade.domain.source.model.SourceData
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.ExtensionManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
fun Source.getPreferenceKey(): String = "source_$id"
fun Source.toSourceData(): SourceData = SourceData(id = id, lang = lang, name = name)
fun Source.getNameForMangaInfo(mergeSources: List<Source>?): String {
val preferences = Injekt.get<PreferencesHelper>()
val enabledLanguages = preferences.enabledLanguages().get()
.filterNot { it in listOf("all", "other") }
val hasOneActiveLanguages = enabledLanguages.size == 1
val isInEnabledLanguages = lang in enabledLanguages
return when {
// SY -->
!mergeSources.isNullOrEmpty() -> getMergedSourcesString(
mergeSources,
enabledLanguages,
hasOneActiveLanguages,
)
// SY <--
// For edge cases where user disables a source they got manga of in their library.
hasOneActiveLanguages && !isInEnabledLanguages -> toString()
// Hide the language tag when only one language is used.
hasOneActiveLanguages && isInEnabledLanguages -> name
else -> toString()
}
}
// SY -->
private fun getMergedSourcesString(
mergeSources: List<Source>,
enabledLangs: List<String>,
onlyName: Boolean,
): String {
return if (onlyName) {
mergeSources.joinToString { source ->
if (source.lang !in enabledLangs) {
source.toString()
} else {
source.name
}
}
} else {
mergeSources.joinToString()
}
}
// SY <--
fun Source.isLocalOrStub(): Boolean = id == LocalSource.ID || this is SourceManager.StubSource
@@ -1,12 +0,0 @@
package eu.kanade.tachiyomi.source
/**
* A factory for creating sources at runtime.
*/
interface SourceFactory {
/**
* Create a new copy of the sources
* @return The created sources
*/
fun createSources(): List<Source>
}
@@ -133,6 +133,7 @@ class SourceManager(
val enhancedSource = EnhancedHttpSource(
this,
delegate.newSourceClass.constructors.find { it.parameters.size == 2 }!!.call(this, context),
::delegateSources,
)
currentDelegatedSources[enhancedSource.originalSource.id] = DelegatedSource(
@@ -156,6 +157,8 @@ class SourceManager(
// EXH <--
}
private fun delegateSources() = preferences.delegateSources().get()
fun get(sourceKey: Long): Source? {
return sourcesMap[sourceKey]
}
@@ -1,8 +0,0 @@
package eu.kanade.tachiyomi.source
/**
* A source that explicitly doesn't require traffic considerations.
*
* This typically applies for self-hosted sources.
*/
interface UnmeteredSource
@@ -1,51 +0,0 @@
package eu.kanade.tachiyomi.source.model
sealed class Filter<T>(val name: String, var state: T) {
open class Header(name: String) : Filter<Any>(name, 0)
open class Separator(name: String = "") : Filter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
fun isIgnored() = state == STATE_IGNORE
fun isIncluded() = state == STATE_INCLUDE
fun isExcluded() = state == STATE_EXCLUDE
companion object {
const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1
const val STATE_EXCLUDE = 2
}
}
abstract class Group<V>(name: String, state: List<V>) : Filter<List<V>>(name, state)
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
Filter<Sort.Selection?>(name, state) {
data class Selection(val index: Int, val ascending: Boolean)
}
// SY -->
abstract class AutoComplete(
name: String,
val hint: String,
val values: List<String>,
val skipAutoFillTags: List<String> = emptyList(),
val excludePrefix: String? = null,
state: List<String>,
) : Filter<List<String>>(name, state)
// SY <--
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Filter<*>) return false
return name == other.name && state == other.state
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + (state?.hashCode() ?: 0)
return result
}
}
@@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.source.model
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
override fun equals(other: Any?): Boolean {
return false
}
override fun hashCode(): Int {
return list.hashCode()
}
}
@@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.source.model
import exh.metadata.metadata.base.RaisedSearchMetadata
/* SY --> */
open /* SY <-- */ class MangasPage(open val mangas: List<SManga>, open val hasNextPage: Boolean) {
// SY -->
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MangasPage) return false
if (mangas != other.mangas) return false
if (hasNextPage != other.hasNextPage) return false
return true
}
override fun hashCode(): Int {
var result = mangas.hashCode()
result = 31 * result + hasNextPage.hashCode()
return result
}
// SY <--
fun copy(mangas: List<SManga> = this.mangas, hasNextPage: Boolean = this.hasNextPage): MangasPage {
return MangasPage(mangas, hasNextPage)
}
}
// SY -->
data class MetadataMangasPage(override val mangas: List<SManga>, override val hasNextPage: Boolean, val mangasMetadata: List<RaisedSearchMetadata>) : MangasPage(mangas, hasNextPage)
// SY <--
@@ -1,67 +0,0 @@
package eu.kanade.tachiyomi.source.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import rx.subjects.Subject
@Serializable
open class Page(
val index: Int,
/* SY --> */
var /* SY <-- */ url: String = "",
var imageUrl: String? = null,
@Transient var uri: Uri? = null, // Deprecated but can't be deleted due to extensions
) : ProgressListener {
val number: Int
get() = index + 1
@Transient
@Volatile
var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
statusCallback?.invoke(this)
}
@Transient
@Volatile
var progress: Int = 0
set(value) {
field = value
statusCallback?.invoke(this)
}
@Transient
private var statusSubject: Subject<Int, Int>? = null
@Transient
private var statusCallback: ((Page) -> Unit)? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
fun setStatusCallback(f: ((Page) -> Unit)?) {
statusCallback = f
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}
@@ -1,57 +0,0 @@
package eu.kanade.tachiyomi.source.model
import data.Chapters
import java.io.Serializable
interface SChapter : Serializable {
var url: String
var name: String
var date_upload: Long
var chapter_number: Float
var scanlator: String?
fun copyFrom(other: SChapter) {
name = other.name
url = other.url
date_upload = other.date_upload
chapter_number = other.chapter_number
scanlator = other.scanlator
}
fun copyFrom(other: Chapters) {
name = other.name
url = other.url
date_upload = other.date_upload
chapter_number = other.chapter_number
scanlator = other.scanlator
}
companion object {
fun create(): SChapter {
return SChapterImpl()
}
// SY -->
operator fun invoke(
name: String,
url: String,
date_upload: Long = 0,
chapter_number: Float = -1F,
scanlator: String? = null,
): SChapter {
return create().apply {
this.name = name
this.url = url
this.date_upload = date_upload
this.chapter_number = chapter_number
this.scanlator = scanlator
}
}
// SY <--
}
}
@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.source.model
import data.Chapters
fun SChapter.copyFrom(other: Chapters) {
name = other.name
url = other.url
date_upload = other.date_upload
chapter_number = other.chapter_number
scanlator = other.scanlator
}
@@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.source.model
class SChapterImpl : SChapter {
override lateinit var url: String
override lateinit var name: String
override var date_upload: Long = 0
override var chapter_number: Float = -1f
override var scanlator: String? = null
}
@@ -1,206 +0,0 @@
package eu.kanade.tachiyomi.source.model
import data.Mangas
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.download.DownloadManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.Serializable
interface SManga : Serializable {
var url: String
var title: String
var artist: String?
var author: String?
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var initialized: Boolean
fun getGenres(): List<String>? {
if (genre.isNullOrBlank()) return null
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
}
// SY -->
val originalTitle: String
get() = (this as? MangaImpl)?.ogTitle ?: title
val originalAuthor: String?
get() = (this as? MangaImpl)?.ogAuthor ?: author
val originalArtist: String?
get() = (this as? MangaImpl)?.ogArtist ?: artist
val originalDescription: String?
get() = (this as? MangaImpl)?.ogDesc ?: description
val originalGenre: String?
get() = (this as? MangaImpl)?.ogGenre ?: genre
val originalStatus: Int
get() = (this as? MangaImpl)?.ogStatus ?: status
// SY <--
fun copyFrom(other: SManga) {
// EXH -->
if (other.title.isNotBlank() && originalTitle != other.title) {
val oldTitle = originalTitle
title = other.originalTitle
val source = (this as? Manga)?.source
if (source != null) {
Injekt.get<DownloadManager>().renameMangaDir(oldTitle, other.originalTitle, source)
}
}
// EXH <--
if (other.author != null) {
author = /* SY --> */ other.originalAuthor // SY <--
}
if (other.artist != null) {
artist = /* SY --> */ other.originalArtist // SY <--
}
if (other.description != null) {
description = /* SY --> */ other.originalDescription // SY <--
}
if (other.genre != null) {
genre = /* SY --> */ other.originalGenre // SY <--
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.status
if (!initialized) {
initialized = other.initialized
}
}
fun copyFrom(other: Mangas) {
// EXH -->
if (other.title.isNotBlank() && originalTitle != other.title) {
val oldTitle = originalTitle
title = other.title
val source = (this as? Manga)?.source
if (source != null) {
Injekt.get<DownloadManager>().renameMangaDir(oldTitle, other.title, source)
}
}
// EXH <--
if (other.author != null) {
author = other.author
}
if (other.artist != null) {
artist = other.artist
}
if (other.description != null) {
description = other.description
}
if (other.genre != null) {
genre = other.genre.joinToString(separator = ", ")
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.status.toInt()
if (!initialized) {
initialized = other.initialized
}
}
fun copy() = create().also {
it.url = url
// SY -->
it.title = originalTitle
it.artist = originalArtist
it.author = originalAuthor
it.description = originalDescription
it.genre = originalGenre
it.status = originalStatus
// SY <--
it.thumbnail_url = thumbnail_url
it.initialized = initialized
}
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
const val COMPLETED = 2
const val LICENSED = 3
const val PUBLISHING_FINISHED = 4
const val CANCELLED = 5
const val ON_HIATUS = 6
fun create(): SManga {
return SMangaImpl()
}
// SY -->
operator fun invoke(
url: String,
title: String,
artist: String? = null,
author: String? = null,
description: String? = null,
genre: String? = null,
status: Int = 0,
thumbnail_url: String? = null,
initialized: Boolean = false,
): SManga {
return create().also {
it.url = url
it.title = title
it.artist = artist
it.author = author
it.description = description
it.genre = genre
it.status = status
it.thumbnail_url = thumbnail_url
it.initialized = initialized
}
}
// SY <--
}
}
// SY -->
fun SManga.copy(
url: String = this.url,
title: String = this.originalTitle,
artist: String? = this.originalArtist,
author: String? = this.originalAuthor,
description: String? = this.originalDescription,
genre: String? = this.originalGenre,
status: Int = this.status,
thumbnail_url: String? = this.thumbnail_url,
initialized: Boolean = this.initialized,
) = SManga.create().also {
it.url = url
it.title = title
it.artist = artist
it.author = author
it.description = description
it.genre = genre
it.status = status
it.thumbnail_url = thumbnail_url
it.initialized = initialized
}
// SY <--
@@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.source.model
import data.Mangas
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
fun SManga.copyFrom(other: Mangas) {
// EXH -->
if (other.title.isNotBlank() && originalTitle != other.title) {
val oldTitle = originalTitle
title = other.title
val source = (this as? Manga)?.source
if (source != null) {
Injekt.get<DownloadManager>().renameMangaDir(oldTitle, other.title, source)
}
}
// EXH <--
if (other.author != null) {
author = other.author
}
if (other.artist != null) {
artist = other.artist
}
if (other.description != null) {
description = other.description
}
if (other.genre != null) {
genre = other.genre.joinToString(separator = ", ")
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.status.toInt()
if (!initialized) {
initialized = other.initialized
}
}
@@ -1,24 +0,0 @@
package eu.kanade.tachiyomi.source.model
class SMangaImpl : SManga {
override lateinit var url: String
// SY -->
override var title: String = ""
// SY <--
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var initialized: Boolean = false
}
@@ -1,9 +0,0 @@
package eu.kanade.tachiyomi.source.online
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController
interface BrowseSourceFilterHeader : CatalogueSource {
fun getFilterHeader(controller: BaseController<*>, onClick: () -> Unit): RecyclerView.Adapter<*>
}
@@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import exh.md.utils.FollowStatus
import exh.metadata.metadata.base.RaisedSearchMetadata
interface FollowsSource : CatalogueSource {
suspend fun fetchFollows(page: Int): MangasPage
/**
* Returns a list of all Follows retrieved by Coroutines
*
* @param SManga all smanga found for user
*/
suspend fun fetchAllFollows(): List<Pair<SManga, RaisedSearchMetadata>>
/**
* updates the follow status for a manga
*/
suspend fun updateFollowStatus(mangaID: String, followStatus: FollowStatus): Boolean
/**
* Get a MdList Track of the manga
*/
suspend fun fetchTrackingInfo(url: String): Track
}
@@ -1,424 +0,0 @@
package eu.kanade.tachiyomi.source.online
import android.app.Application
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.AndroidCookieJar
import eu.kanade.tachiyomi.network.CACHE_CONTROL_NO_STORE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import exh.log.maybeInjectEHLogger
import exh.patch.injectPatches
import exh.source.DelegatedHttpSource
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
abstract class HttpSource : CatalogueSource {
/**
* Network service.
*/
// SY -->
protected val network: NetworkHelper by lazy {
val network = Injekt.get<NetworkHelper>()
object : NetworkHelper(Injekt.get<Application>()) {
override val client: OkHttpClient
get() = delegate?.networkHttpClient ?: network.client
.newBuilder()
.injectPatches { id }
.maybeInjectEHLogger()
.build()
override val cloudflareClient: OkHttpClient
get() = delegate?.networkCloudflareClient ?: network.cloudflareClient
.newBuilder()
.injectPatches { id }
.maybeInjectEHLogger()
.build()
override val cookieManager: AndroidCookieJar
get() = network.cookieManager
}
}
// SY <--
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
abstract val baseUrl: String
/**
* Version id used to generate the source id. If the site completely changes and urls are
* incompatible, you may increase this value and it'll be considered as a new source.
*/
open val versionId = 1
/**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id by lazy {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
/**
* Headers used for requests.
*/
/* SY --> */
open /* SY <-- */ val headers: Headers by lazy { headersBuilder().build() }
/**
* Default network client for doing requests.
*/
open val client: OkHttpClient
// SY -->
get() = delegate?.baseHttpClient ?: network.client
// SY <--
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", network.defaultUserAgent)
}
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.uppercase()})"
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
*/
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
.asObservableSuccess()
.map { response ->
popularMangaParse(response)
}
}
/**
* Returns the request for the popular manga given the page.
*
* @param page the page number to retrieve.
*/
protected abstract fun popularMangaRequest(page: Int): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
protected abstract fun popularMangaParse(response: Response): MangasPage
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return Observable.defer {
try {
client.newCall(searchMangaRequest(page, query, filters)).asObservableSuccess()
} catch (e: NoClassDefFoundError) {
// RxJava doesn't handle Errors, which tends to happen during global searches
// if an old extension using non-existent classes is still around
throw RuntimeException(e)
}
}
.map { response ->
searchMangaParse(response)
}
}
/**
* Returns the request for the search manga given the page.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
protected abstract fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
protected abstract fun searchMangaParse(response: Response): MangasPage
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess()
.map { response ->
latestUpdatesParse(response)
}
}
/**
* Returns the request for latest manga given the page.
*
* @param page the page number to retrieve.
*/
/* SY --> protected <-- SY */
abstract fun latestUpdatesRequest(page: Int): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
/* SY --> protected <-- SY */
abstract fun latestUpdatesParse(response: Response): MangasPage
/**
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to be updated.
*/
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
/**
* Returns the request for the details of a manga. Override only if it's needed to change the
* url, send different headers or request method like POST.
*
* @param manga the manga to be updated.
*/
open fun mangaDetailsRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
protected abstract fun mangaDetailsParse(response: Response): SManga
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned
*
* @param manga the manga to look for chapters.
*/
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return if (manga.status != SManga.LICENSED) {
client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
chapterListParse(response)
}
} else {
Observable.error(Exception("Licensed - No chapters to show"))
}
}
/**
* Returns the request for updating the chapter list. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param manga the manga to look for chapters.
*/
protected open fun chapterListRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
protected abstract fun chapterListParse(response: Response): List<SChapter>
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
pageListParse(response)
}
}
/**
* Returns the request for getting the page list. Override only if it's needed to override the
* url, send different headers or request method like POST.
*
* @param chapter the chapter whose page list has to be fetched.
*/
protected open fun pageListRequest(chapter: SChapter): Request {
return GET(baseUrl + chapter.url, headers)
}
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
protected abstract fun pageListParse(response: Response): List<Page>
/**
* Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception.
*
* @param page the page whose source image has to be fetched.
*/
open fun fetchImageUrl(page: Page): Observable<String> {
return client.newCall(imageUrlRequest(page))
.asObservableSuccess()
.map { imageUrlParse(it) }
}
/**
* Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
protected open fun imageUrlRequest(page: Page): Request {
return GET(page.url, headers)
}
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
protected abstract fun imageUrlParse(response: Response): String
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
/* SY --> */
open /* SY <-- */ fun fetchImage(page: Page): Observable<Response> {
val request = imageRequest(page).newBuilder()
// images will be cached or saved manually, so don't take up network cache
.cacheControl(CACHE_CONTROL_NO_STORE)
.build()
return client.newCallWithProgress(request, page)
.asObservableSuccess()
}
/**
* Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
protected open fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers)
}
/**
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the chapter.
*/
fun SChapter.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the manga.
*/
fun SManga.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Returns the url of the given string without the scheme and domain.
*
* @param orig the full url.
*/
private fun getUrlWithoutDomain(orig: String): String {
return try {
val uri = URI(orig.replace(" ", "%20"))
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
orig
}
}
/**
* Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga].
*
* @param chapter the chapter to be added.
* @param manga the manga of the chapter.
*/
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
// EXH -->
private var delegate: DelegatedHttpSource? = null
get() = if (Injekt.get<PreferencesHelper>().delegateSources().get()) {
field
} else {
null
}
fun bindDelegate(delegate: DelegatedHttpSource) {
this.delegate = delegate
}
// EXH <--
}
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages)
.filter { !it.imageUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
}
fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() }
.concatMap { getImageUrl(it) }
}
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
interface LoginSource : Source {
val requiresLogin: Boolean
val twoFactorAuth: AuthSupport
fun isLogged(): Boolean
fun getUsername(): String
fun getPassword(): String
suspend fun login(username: String, password: String, twoFactorCode: String?): Boolean
suspend fun logout(): Boolean
enum class AuthSupport {
NOT_SUPPORTED,
SUPPORTED,
REQUIRED,
}
}
@@ -1,118 +0,0 @@
package eu.kanade.tachiyomi.source.online
import androidx.compose.runtime.Composable
import eu.kanade.domain.manga.interactor.GetFlatMetadataById
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.InsertFlatMetadata
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.lang.awaitSingle
import eu.kanade.tachiyomi.util.lang.runAsObservable
import exh.metadata.metadata.base.RaisedSearchMetadata
import rx.Completable
import rx.Single
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.reflect.KClass
/**
* LEWD!
*/
interface MetadataSource<M : RaisedSearchMetadata, I> : CatalogueSource {
val getManga: GetManga get() = Injekt.get()
val insertFlatMetadata: InsertFlatMetadata get() = Injekt.get()
val getFlatMetadataById: GetFlatMetadataById get() = Injekt.get()
/**
* The class of the metadata used by this source
*/
val metaClass: KClass<M>
/**
* Parse the supplied input into the supplied metadata object
*/
suspend fun parseIntoMetadata(metadata: M, input: I)
/**
* Use reflection to create a new instance of metadata
*/
private fun newMetaInstance() = metaClass.constructors.find {
it.parameters.isEmpty()
}?.call()
?: error("Could not find no-args constructor for meta class: ${metaClass.qualifiedName}!")
/**
* Parses metadata from the input and then copies it into the manga
*
* Will also save the metadata to the DB if possible
*/
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Use the MangaInfo variant")
fun parseToMangaCompletable(manga: SManga, input: I): Completable = runAsObservable {
parseToManga(manga, input)
}.toCompletable()
suspend fun parseToManga(manga: SManga, input: I): SManga {
val mangaId = manga.id()
val metadata = if (mangaId != null) {
val flatMetadata = getFlatMetadataById.await(mangaId)
flatMetadata?.raise(metaClass) ?: newMetaInstance()
} else {
newMetaInstance()
}
parseIntoMetadata(metadata, input)
if (mangaId != null) {
metadata.mangaId = mangaId
insertFlatMetadata.await(metadata)
}
return metadata.createMangaInfo(manga)
}
/**
* Try to first get the metadata from the DB. If the metadata is not in the DB, calls the input
* producer and parses the metadata from the input
*
* If the metadata needs to be parsed from the input producer, the resulting parsed metadata will
* also be saved to the DB.
*/
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("use fetchOrLoadMetadata made for MangaInfo")
fun getOrLoadMetadata(mangaId: Long?, inputProducer: () -> Single<I>): Single<M> =
runAsObservable {
fetchOrLoadMetadata(mangaId) { inputProducer().toObservable().awaitSingle() }
}.toSingle()
/**
* Try to first get the metadata from the DB. If the metadata is not in the DB, calls the input
* producer and parses the metadata from the input
*
* If the metadata needs to be parsed from the input producer, the resulting parsed metadata will
* also be saved to the DB.
*/
suspend fun fetchOrLoadMetadata(mangaId: Long?, inputProducer: suspend () -> I): M {
val meta = if (mangaId != null) {
val flatMetadata = getFlatMetadataById.await(mangaId)
flatMetadata?.raise(metaClass)
} else {
null
}
return meta ?: inputProducer().let { input ->
val newMeta = newMetaInstance()
parseIntoMetadata(newMeta, input)
if (mangaId != null) {
newMeta.mangaId = mangaId
insertFlatMetadata.await(newMeta)
}
newMeta
}
}
@Composable
fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit)
suspend fun SManga.id() = getManga.await(url, id)?.id
}
@@ -1,5 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
interface NamespaceSource : Source
@@ -1,200 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
/**
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
abstract class ParsedHttpSource : HttpSource() {
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
protected abstract fun popularMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [popularMangaSelector].
*/
protected abstract fun popularMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun popularMangaNextPageSelector(): String?
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
protected abstract fun searchMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [searchMangaSelector].
*/
protected abstract fun searchMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun searchMangaNextPageSelector(): String?
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
protected abstract fun latestUpdatesSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [latestUpdatesSelector].
*/
protected abstract fun latestUpdatesFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun latestUpdatesNextPageSelector(): String?
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
override fun mangaDetailsParse(response: Response): SManga {
return mangaDetailsParse(response.asJsoup())
}
/**
* Returns the details of the manga from the given [document].
*
* @param document the parsed document.
*/
protected abstract fun mangaDetailsParse(document: Document): SManga
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select(chapterListSelector()).map { chapterFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
*/
protected abstract fun chapterListSelector(): String
/**
* Returns a chapter from the given element.
*
* @param element an element obtained from [chapterListSelector].
*/
protected abstract fun chapterFromElement(element: Element): SChapter
/**
* Parses the response from the site and returns the page list.
*
* @param response the response from the site.
*/
override fun pageListParse(response: Response): List<Page> {
return pageListParse(response.asJsoup())
}
/**
* Returns a page list from the given document.
*
* @param document the parsed document.
*/
protected abstract fun pageListParse(document: Document): List<Page>
/**
* Parse the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
override fun imageUrlParse(response: Response): String {
return imageUrlParse(response.asJsoup())
}
/**
* Returns the absolute url to the source image from the document.
*
* @param document the parsed document.
*/
protected abstract fun imageUrlParse(document: Document): String
}
@@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
interface RandomMangaSource : Source {
suspend fun fetchRandomMangaUrl(): String
}
@@ -1,53 +0,0 @@
package eu.kanade.tachiyomi.source.online
import android.net.Uri
import eu.kanade.tachiyomi.source.Source
import java.net.URI
import java.net.URISyntaxException
interface UrlImportableSource : Source {
val matchingHosts: List<String>
fun matchesUri(uri: Uri): Boolean {
return uri.host.orEmpty().lowercase() in matchingHosts
}
fun mapUrlToChapterUrl(uri: Uri): String? = null
suspend fun mapChapterUrlToMangaUrl(uri: Uri): String? = null
// This method is allowed to block for IO if necessary
suspend fun mapUrlToMangaUrl(uri: Uri): String?
fun cleanMangaUrl(url: String): String {
return try {
val uri = URI(url)
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
url
}
}
fun cleanChapterUrl(url: String): String {
return try {
val uri = URI(url)
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
url
}
}
}
@@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
@@ -28,7 +27,6 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.lang.withIOContext
@@ -51,7 +49,6 @@ import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUA
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.toGenreString
import exh.metadata.metadata.base.RaisedTag
import exh.ui.login.EhLoginActivity
import exh.ui.metadata.adapters.EHentaiDescription
import exh.util.UriFilter
import exh.util.UriGroup
import exh.util.asObservableWithAsyncStacktrace
@@ -1127,11 +1124,6 @@ class EHentai(
return "${uri.scheme}://${uri.host}/g/${obj["gid"]!!.jsonPrimitive.int}/${obj["token"]!!.jsonPrimitive.content}/"
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
EHentaiDescription(state, openMetadataViewer, search)
}
override suspend fun getPagePreviewList(
manga: SManga,
page: Int,
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.source.online.all
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.compose.runtime.Composable
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
@@ -11,13 +10,11 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.HitomiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.HitomiDescription
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import java.text.SimpleDateFormat
@@ -136,11 +133,6 @@ class Hitomi(delegate: HttpSource, val context: Context) :
return "https://hitomi.la/manga/${uri.pathSegments[1].substringBefore('.')}.html"
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
HitomiDescription(state, openMetadataViewer)
}
companion object {
const val otherId = 2703068117101782422L
private val DATE_FORMAT by lazy {
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.source.online.all
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.compose.runtime.Composable
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
@@ -14,7 +13,6 @@ import eu.kanade.tachiyomi.source.model.MetadataMangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.BrowseSourceFilterHeader
import eu.kanade.tachiyomi.source.online.FollowsSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.LoginSource
@@ -22,10 +20,7 @@ import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.RandomMangaSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.lang.runAsObservable
import exh.md.MangaDexFabHeaderAdapter
import exh.md.dto.MangaDto
import exh.md.dto.StatisticsMangaDto
import exh.md.handlers.ApiMangaParser
@@ -49,7 +44,6 @@ import exh.md.utils.MdLang
import exh.md.utils.MdUtil
import exh.metadata.metadata.MangaDexSearchMetadata
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.MangaDexDescription
import okhttp3.OkHttpClient
import okhttp3.Response
import rx.Observable
@@ -64,7 +58,6 @@ class MangaDex(delegate: HttpSource, val context: Context) :
UrlImportableSource,
FollowsSource,
LoginSource,
BrowseSourceFilterHeader,
RandomMangaSource,
NamespaceSource {
override val lang: String = delegate.lang
@@ -217,11 +210,6 @@ class MangaDex(delegate: HttpSource, val context: Context) :
// MetadataSource methods
override val metaClass: KClass<MangaDexSearchMetadata> = MangaDexSearchMetadata::class
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
MangaDexDescription(state, openMetadataViewer)
}
override suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Triple<MangaDto, List<String>, StatisticsMangaDto>) {
apiMangaParser.parseIntoMetadata(metadata, input.first, input.second, input.third)
}
@@ -272,11 +260,11 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return followsHandler.fetchAllFollows()
}
override suspend fun updateFollowStatus(mangaID: String, followStatus: FollowStatus): Boolean {
suspend fun updateFollowStatus(mangaID: String, followStatus: FollowStatus): Boolean {
return followsHandler.updateFollowStatus(mangaID, followStatus)
}
override suspend fun fetchTrackingInfo(url: String): Track {
suspend fun fetchTrackingInfo(url: String): Track {
return followsHandler.fetchTrackingInfo(url)
}
@@ -293,11 +281,6 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return mangaHandler.getTrackingInfo(track)
}
// BrowseSourceFilterHeader method
override fun getFilterHeader(controller: BaseController<*>, onClick: () -> Unit): MangaDexFabHeaderAdapter {
return MangaDexFabHeaderAdapter(controller, this, onClick)
}
// RandomMangaSource method
override suspend fun fetchRandomMangaUrl(): String {
return mangaHandler.fetchRandomMangaId()
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.source.online.all
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.compose.runtime.Composable
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.newCallWithProgress
@@ -16,12 +15,10 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import exh.metadata.metadata.NHentaiSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.NHentaiDescription
import exh.util.trimOrNull
import exh.util.urlImportFetchSearchManga
import kotlinx.serialization.SerialName
@@ -175,11 +172,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
return "$baseUrl/g/${uri.pathSegments[1]}/"
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
NHentaiDescription(state, openMetadataViewer)
}
override suspend fun getPagePreviewList(manga: SManga, page: Int): PagePreviewPage {
val metadata = fetchOrLoadMetadata(manga.id()) {
client.newCall(mangaDetailsRequest(manga)).await()
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source.online.all
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.core.net.toUri
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.FilterList
@@ -10,13 +9,11 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.PervEdenSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.PervEdenDescription
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@@ -132,9 +129,4 @@ class PervEden(delegate: HttpSource, val context: Context) :
}
return newUri.toString()
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
PervEdenDescription(state, openMetadataViewer)
}
}
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source.online.english
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.core.net.toUri
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.FilterList
@@ -11,12 +10,10 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.EightMusesSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.EightMusesDescription
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@@ -96,9 +93,4 @@ class EightMuses(delegate: HttpSource, val context: Context) :
}
return "/comics/album/${path.joinToString("/")}"
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
EightMusesDescription(state, openMetadataViewer)
}
}
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source.online.english
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
@@ -10,12 +9,10 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.HBrowseSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.HBrowseDescription
import exh.util.urlImportFetchSearchManga
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@@ -84,9 +81,4 @@ class HBrowse(delegate: HttpSource, val context: Context) :
override suspend fun mapUrlToMangaUrl(uri: Uri): String? {
return uri.pathSegments.firstOrNull()?.let { "/$it/c00001/" }
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
HBrowseDescription(state, openMetadataViewer)
}
}
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source.online.english
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.core.net.toUri
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.FilterList
@@ -12,13 +11,11 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.PururinSearchMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.PururinDescription
import exh.util.dropBlank
import exh.util.trimAll
import exh.util.urlImportFetchSearchManga
@@ -116,9 +113,4 @@ class Pururin(delegate: HttpSource, val context: Context) :
override suspend fun mapUrlToMangaUrl(uri: Uri): String {
return "${PururinSearchMetadata.BASE_URL}/gallery/${uri.pathSegments.getOrNull(1)}/${uri.lastPathSegment}"
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
PururinDescription(state, openMetadataViewer)
}
}
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source.online.english
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
@@ -11,14 +10,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.ui.manga.MangaScreenState
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.TsuminoSearchMetadata
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.TsuminoDescription
import exh.util.dropBlank
import exh.util.trimAll
import exh.util.urlImportFetchSearchManga
@@ -141,9 +138,4 @@ class Tsumino(delegate: HttpSource, val context: Context) :
val RATING_USERS_REGEX = "\\(([0-9].*) users".toRegex()
val RATING_FAVORITES_REGEX = "/ ([0-9].*) favs".toRegex()
}
@Composable
override fun DescriptionComposable(state: MangaScreenState.Success, openMetadataViewer: () -> Unit, search: (String) -> Unit) {
TsuminoDescription(state, openMetadataViewer)
}
}
@@ -13,10 +13,11 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.databinding.SourceFilterSheetBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.BrowseSourceFilterHeader
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.widget.SimpleNavigationView
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
import exh.md.MangaDexFabHeaderAdapter
import exh.savedsearches.EXHSavedSearch
import exh.source.getMainSource
@@ -117,8 +118,12 @@ class SourceFilterSheet(
recycler.adapter = ConcatAdapter(
listOfNotNull(
controller?.let {
source?.getMainSource<BrowseSourceFilterHeader>()
?.getFilterHeader(it) { dismissSheet?.invoke() }
source?.getMainSource<MangaDex>()
?.let {
MangaDexFabHeaderAdapter(controller, it) {
dismissSheet?.invoke()
}
}
},
savedSearchesAdapter,
adapter,
@@ -46,7 +46,7 @@ open class AutoComplete(val filter: Filter.AutoComplete) : AbstractFlexibleItem<
// select from auto complete
holder.autoComplete.setOnItemClickListener { adapterView, _, chipPosition, _ ->
val name = adapterView.getItemAtPosition(chipPosition) as String
if (name !in if (filter.excludePrefix != null && name.startsWith(filter.excludePrefix)) filter.skipAutoFillTags.map { filter.excludePrefix + it } else filter.skipAutoFillTags) {
if (name !in if (filter.excludePrefix != null && name.startsWith(filter.excludePrefix!!)) filter.skipAutoFillTags.map { filter.excludePrefix + it } else filter.skipAutoFillTags) {
holder.autoComplete.text = null
addTag(name, holder)
}
@@ -54,7 +54,7 @@ open class AutoComplete(val filter: Filter.AutoComplete) : AbstractFlexibleItem<
// done keyboard button is pressed
holder.autoComplete.setOnEditorActionListener { textView, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE && textView.text.toString() !in if (filter.excludePrefix != null && textView.text.toString().startsWith(filter.excludePrefix)) filter.skipAutoFillTags.map { filter.excludePrefix + it } else filter.skipAutoFillTags) {
if (actionId == EditorInfo.IME_ACTION_DONE && textView.text.toString() !in if (filter.excludePrefix != null && textView.text.toString().startsWith(filter.excludePrefix!!)) filter.skipAutoFillTags.map { filter.excludePrefix + it } else filter.skipAutoFillTags) {
textView.text = null
addTag(textView.text.toString(), holder)
return@setOnEditorActionListener true
@@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
import eu.kanade.tachiyomi.util.system.isTablet
import eu.kanade.tachiyomi.widget.preference.ThemesPreference
import java.util.Date
@@ -1,26 +0,0 @@
package eu.kanade.tachiyomi.util
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
fun Element.selectText(css: String, defaultValue: String? = null): String? {
return select(css).first()?.text() ?: defaultValue
}
fun Element.selectInt(css: String, defaultValue: Int = 0): Int {
return select(css).first()?.text()?.toInt() ?: defaultValue
}
fun Element.attrOrText(css: String): String {
return if (css != "text") attr(css) else text()
}
/**
* Returns a Jsoup document for this response.
* @param html the body of the response. Use only if the body was read before calling this method.
*/
fun Response.asJsoup(html: String? = null): Document {
return Jsoup.parse(html ?: body.string(), request.url.toString())
}
@@ -1,57 +0,0 @@
package eu.kanade.tachiyomi.util.lang
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
*
* **Possible replacements**
* - suspend function
* - custom scope like view or presenter scope
*/
@DelicateCoroutinesApi
fun launchUI(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block)
/**
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
*
* **Possible replacements**
* - suspend function
* - custom scope like view or presenter scope
*/
@DelicateCoroutinesApi
fun launchIO(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block)
/**
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
*
* **Possible replacements**
* - suspend function
* - custom scope like view or presenter scope
*/
@DelicateCoroutinesApi
fun launchNow(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block)
fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.Main, block = block)
fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.IO, block = block)
fun CoroutineScope.launchNonCancellableIO(block: suspend CoroutineScope.() -> Unit): Job =
launchIO { withContext(NonCancellable, block) }
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)
@@ -1,87 +0,0 @@
package eu.kanade.tachiyomi.util.lang
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import rx.Emitter
import rx.Observable
import rx.Subscriber
import rx.Subscription
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/*
* Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
*/
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
cont.unsubscribeOnCancellation(
subscribe(
object : Subscriber<T>() {
override fun onStart() {
request(1)
}
override fun onNext(t: T) {
cont.resume(t)
}
override fun onCompleted() {
if (cont.isActive) {
cont.resumeWithException(
IllegalStateException(
"Should have invoked onNext",
),
)
}
}
override fun onError(e: Throwable) {
/*
* Rx1 observable throws NoSuchElementException if cancellation happened before
* element emission. To mitigate this we try to atomically resume continuation with exception:
* if resume failed, then we know that continuation successfully cancelled itself
*/
val token = cont.tryResumeWithException(e)
if (token != null) {
cont.completeResume(token)
}
}
},
),
)
}
internal fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
invokeOnCancellation { sub.unsubscribe() }
fun <T> runAsObservable(
backpressureMode: Emitter.BackpressureMode = Emitter.BackpressureMode.NONE,
block: suspend () -> T,
): Observable<T> {
return Observable.create(
{ emitter ->
val job = GlobalScope.launch(Dispatchers.Unconfined, start = CoroutineStart.ATOMIC) {
try {
emitter.onNext(block())
emitter.onCompleted()
} catch (e: Throwable) {
// Ignore `CancellationException` as error, since it indicates "normal cancellation"
if (e !is CancellationException) {
emitter.onError(e)
} else {
emitter.onCompleted()
}
}
}
emitter.setCancellation { job.cancel() }
},
backpressureMode,
)
}
@@ -24,10 +24,8 @@ import android.util.TypedValue
import android.view.Display
import android.view.View
import android.view.WindowManager
import android.widget.Toast
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
@@ -52,29 +50,6 @@ import kotlin.math.roundToInt
private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720
/**
* Display a toast in this context.
*
* @param resource the text resource.
* @param duration the duration of the toast. Defaults to short.
*/
fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
return toast(getString(resource), duration, block)
}
/**
* Display a toast in this context.
*
* @param text the text to display.
* @param duration the duration of the toast. Defaults to short.
*/
fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
return Toast.makeText(applicationContext, text.orEmpty(), duration).also {
block(it)
it.show()
}
}
/**
* Copies a string to clipboard
*
@@ -1,51 +0,0 @@
package eu.kanade.tachiyomi.util.system
import android.annotation.SuppressLint
import android.os.Build
import com.google.android.material.color.DynamicColors
import logcat.LogPriority
object DeviceUtil {
val isMiui by lazy {
getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false
}
@SuppressLint("PrivateApi")
fun isMiuiOptimizationDisabled(): Boolean {
val sysProp = getSystemProperty("persist.sys.miui_optimization")
if (sysProp == "0" || sysProp == "false") {
return true
}
return try {
Class.forName("android.miui.AppOpsUtils")
.getDeclaredMethod("isXOptMode")
.invoke(null) as Boolean
} catch (e: Exception) {
false
}
}
val isSamsung by lazy {
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
}
val isDynamicColorAvailable by lazy {
DynamicColors.isDynamicColorAvailable() || (isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
}
val invalidDefaultBrowsers = listOf("android", "com.huawei.android.internal.app")
@SuppressLint("PrivateApi")
private fun getSystemProperty(key: String?): String? {
return try {
Class.forName("android.os.SystemProperties")
.getDeclaredMethod("get", String::class.java)
.invoke(null, key) as String
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Unable to use SystemProperties.get()" }
null
}
}
}
@@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.util.system
import android.os.Build
import com.google.android.material.color.DynamicColors
val DeviceUtil.isDynamicColorAvailable by lazy {
DynamicColors.isDynamicColorAvailable() || (DeviceUtil.isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
}
@@ -1,18 +0,0 @@
package eu.kanade.tachiyomi.util.system
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
inline fun Any.logcat(
priority: LogPriority = LogPriority.DEBUG,
throwable: Throwable? = null,
message: () -> String = { "" },
) = logcat(priority = priority) {
var msg = message()
if (throwable != null) {
if (msg.isNotBlank()) msg += "\n"
msg += throwable.asLog()
}
msg
}
@@ -1,94 +0,0 @@
package eu.kanade.tachiyomi.util.system
import android.annotation.TargetApi
import android.os.Build
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
@Suppress("OverridingDeprecatedMember")
abstract class WebViewClientCompat : WebViewClient() {
open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
return false
}
open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
return null
}
open fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean,
) {
}
@TargetApi(Build.VERSION_CODES.N)
final override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
return shouldOverrideUrlCompat(view, request.url.toString())
}
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrlCompat(view, url)
}
final override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest,
): WebResourceResponse? {
return shouldInterceptRequestCompat(view, request.url.toString())
}
final override fun shouldInterceptRequest(
view: WebView,
url: String,
): WebResourceResponse? {
return shouldInterceptRequestCompat(view, url)
}
final override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
onReceivedErrorCompat(
view,
error.errorCode,
error.description?.toString(),
request.url.toString(),
request.isForMainFrame,
)
}
final override fun onReceivedError(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
) {
onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url)
}
final override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
error: WebResourceResponse,
) {
onReceivedErrorCompat(
view,
error.statusCode,
error.reasonPhrase,
request.url
.toString(),
request.isForMainFrame,
)
}
}
@@ -1,67 +0,0 @@
package eu.kanade.tachiyomi.util.system
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
import logcat.LogPriority
object WebViewUtil {
const val SPOOF_PACKAGE_NAME = "org.chromium.chrome"
const val MINIMUM_WEBVIEW_VERSION = 100
fun supportsWebView(context: Context): Boolean {
try {
// May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView
// is not installed
CookieManager.getInstance()
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
return false
}
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW)
}
}
fun WebView.isOutdated(): Boolean {
return getWebViewMajorVersion() < WebViewUtil.MINIMUM_WEBVIEW_VERSION
}
@SuppressLint("SetJavaScriptEnabled")
fun WebView.setDefaultSettings() {
with(settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
useWideViewPort = true
loadWithOverviewMode = true
cacheMode = WebSettings.LOAD_DEFAULT
}
}
private fun WebView.getWebViewMajorVersion(): Int {
val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString())
return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) {
uaRegexMatch.groupValues[1].toInt()
} else {
0
}
}
// Based on https://stackoverflow.com/a/29218966
private fun WebView.getDefaultUserAgentString(): String {
val originalUA: String = settings.userAgentString
// Next call to getUserAgentString() will get us the default
settings.userAgentString = null
val defaultUserAgentString = settings.userAgentString
// Revert to original UA string
settings.userAgentString = originalUA
return defaultUserAgentString
}