diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 49fd0ac1b7..02c4aa5e00 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -6,7 +6,6 @@
-
-
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 5214881eac..e91724d544 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -173,13 +173,18 @@ dependencies {
implementation libs.material
implementation libs.androidx.lifecycle.common.java8
implementation libs.androidx.webkit
+ implementation libs.injekt.core
implementation libs.androidx.work.runtime
implementation libs.guava
+ implementation libs.quickjs.kt
// Foldable/Window layout
implementation libs.androidx.window
+ implementation libs.rxjava
+ implementation libs.rxandroid
+
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2a4d82633b..7651f0d9c6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -24,6 +24,7 @@
+
+
= listOf(
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/avif",
+ "image/heif",
+ "image/jxl",
+ )
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt
new file mode 100644
index 0000000000..ffc091a20f
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt
@@ -0,0 +1,33 @@
+package eu.kanade.tachiyomi.network
+
+import android.content.Context
+import com.dokar.quickjs.QuickJs
+import kotlinx.coroutines.Dispatchers
+
+/**
+ * Util for evaluating JavaScript in sources.
+ *
+ * Uses QuickJS (with Rhino fallback) to execute JavaScript code.
+ * This provides compatibility with Mihon extensions that use JavaScriptEngine.
+ *
+ * @since extensions-lib 1.4
+ */
+class JavaScriptEngine(private val context: Context) {
+
+ /**
+ * Evaluate arbitrary JavaScript code and get the result as a primitive type
+ * (e.g., String, Int).
+ *
+ * @param script JavaScript to execute.
+ * @return Result of JavaScript code as a primitive type.
+ */
+ @Suppress("UNCHECKED_CAST")
+ suspend fun evaluate(script: String): T {
+ return QuickJs.create(jobDispatcher = Dispatchers.Default).use { qjs ->
+ qjs.maxStackSize = 1L shl 20 // 1MB
+ qjs.memoryLimit = 64L shl 20 // 64MB soft limit
+ val result = qjs.evaluate(script)
+ result as T
+ }
+ }
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt
new file mode 100644
index 0000000000..df75fe8279
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt
@@ -0,0 +1,29 @@
+package eu.kanade.tachiyomi.network
+
+import okhttp3.OkHttpClient
+
+/**
+ * Mihon-compatible NetworkHelper interface.
+ * Provides access to OkHttpClient for extensions.
+ *
+ * This will be implemented by app to bridge with its existing network stack.
+ */
+abstract class NetworkHelper {
+
+ /**
+ * The default OkHttpClient with CloudFlare bypassing.
+ */
+ abstract val client: OkHttpClient
+
+ /**
+ * @deprecated Since extension-lib 1.5
+ */
+ @Deprecated("The regular client handles Cloudflare by default")
+ open val cloudflareClient: OkHttpClient
+ get() = client
+
+ /**
+ * Returns the default user agent string.
+ */
+ abstract fun defaultUserAgentProvider(): String
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt
new file mode 100644
index 0000000000..ac78d37ca7
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt
@@ -0,0 +1,131 @@
+package eu.kanade.tachiyomi.network
+
+import kotlinx.coroutines.suspendCancellableCoroutine
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import rx.Producer
+import rx.Subscription
+import java.io.IOException
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.coroutines.resumeWithException
+
+/**
+ * OkHttp extension functions for Mihon compatibility.
+ */
+
+val jsonMime = "application/json; charset=utf-8".toMediaType()
+
+fun Call.asObservable(): Observable {
+ return Observable.unsafeCreate { subscriber ->
+ val call = clone()
+
+ val requestArbiter = object : Producer, Subscription {
+ val boolean = AtomicBoolean(false)
+ override fun request(n: Long) {
+ if (n == 0L || !boolean.compareAndSet(false, true)) return
+
+ try {
+ val response = call.execute()
+ if (!subscriber.isUnsubscribed) {
+ subscriber.onNext(response)
+ subscriber.onCompleted()
+ }
+ } catch (e: Exception) {
+ if (!subscriber.isUnsubscribed) {
+ subscriber.onError(e)
+ }
+ }
+ }
+
+ override fun unsubscribe() {
+ call.cancel()
+ }
+
+ override fun isUnsubscribed(): Boolean {
+ return call.isCanceled()
+ }
+ }
+
+ subscriber.add(requestArbiter)
+ subscriber.setProducer(requestArbiter)
+ }
+}
+
+fun Call.asObservableSuccess(): Observable {
+ return asObservable().doOnNext { response ->
+ if (!response.isSuccessful) {
+ response.close()
+ throw HttpException(response.code)
+ }
+ }
+}
+
+suspend fun Call.await(): Response {
+ val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
+ return suspendCancellableCoroutine { continuation ->
+ val callback = object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ continuation.resume(response) {
+ response.body.close()
+ }
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ if (continuation.isCancelled) return
+ val exception = IOException(e.message, e).apply { stackTrace = callStack }
+ continuation.resumeWithException(exception)
+ }
+ }
+
+ enqueue(callback)
+
+ continuation.invokeOnCancellation {
+ try {
+ cancel()
+ } catch (ex: Throwable) {
+ // Ignore cancel exception
+ }
+ }
+ }
+}
+
+/**
+ * @since extensions-lib 1.5
+ */
+suspend fun Call.awaitSuccess(): Response {
+ val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
+ val response = await()
+ if (!response.isSuccessful) {
+ response.close()
+ throw HttpException(response.code).apply { stackTrace = callStack }
+ }
+ return response
+}
+
+fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
+ val progressClient = newBuilder()
+ .cache(null)
+ .addNetworkInterceptor { chain ->
+ val originalResponse = chain.proceed(chain.request())
+ originalResponse.newBuilder()
+ .body(ProgressResponseBody(originalResponse.body, listener))
+ .build()
+ }
+ .build()
+
+ return progressClient.newCall(request)
+}
+
+/**
+ * Exception that handles HTTP codes considered not successful by OkHttp.
+ * Use it to have a standardized error message in the app across the extensions.
+ *
+ * @since extensions-lib 1.5
+ * @param code [Int] the HTTP status code
+ */
+class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressListener.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressListener.kt
new file mode 100644
index 0000000000..7395ab69eb
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressListener.kt
@@ -0,0 +1,8 @@
+package eu.kanade.tachiyomi.network
+
+/**
+ * Progress listener interface for tracking download progress.
+ */
+interface ProgressListener {
+ fun update(bytesRead: Long, contentLength: Long, done: Boolean)
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt
new file mode 100644
index 0000000000..483002e2cf
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt
@@ -0,0 +1,51 @@
+package eu.kanade.tachiyomi.network
+
+import okhttp3.MediaType
+import okhttp3.ResponseBody
+import okio.Buffer
+import okio.BufferedSource
+import okio.ForwardingSource
+import okio.Source
+import okio.buffer
+
+/**
+ * ResponseBody wrapper that reports download progress.
+ */
+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
+
+ override fun read(sink: Buffer, byteCount: Long): Long {
+ val bytesRead = super.read(sink, byteCount)
+ totalBytesRead += if (bytesRead != -1L) bytesRead else 0
+ progressListener.update(
+ totalBytesRead,
+ responseBody.contentLength(),
+ bytesRead == -1L
+ )
+ return bytesRead
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt
new file mode 100644
index 0000000000..71b1f259be
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt
@@ -0,0 +1,125 @@
+@file:Suppress("FunctionName")
+
+package eu.kanade.tachiyomi.network
+
+import okhttp3.CacheControl
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.Response
+import java.util.concurrent.TimeUnit.MINUTES
+
+/**
+ * HTTP request helper functions for Mihon compatibility.
+ */
+
+private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
+private val DEFAULT_HEADERS = Headers.Builder().build()
+private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
+
+fun GET(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Request {
+ return GET(url.toHttpUrl(), headers, cache)
+}
+
+/**
+ * @since extensions-lib 1.4
+ */
+fun GET(
+ url: HttpUrl,
+ headers: Headers = DEFAULT_HEADERS,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Request {
+ return Request.Builder()
+ .url(url)
+ .headers(headers)
+ .cacheControl(cache)
+ .build()
+}
+
+fun POST(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ body: RequestBody = DEFAULT_BODY,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Request {
+ return Request.Builder()
+ .url(url)
+ .post(body)
+ .headers(headers)
+ .cacheControl(cache)
+ .build()
+}
+
+fun PUT(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ body: RequestBody = DEFAULT_BODY,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Request {
+ return Request.Builder()
+ .url(url)
+ .put(body)
+ .headers(headers)
+ .cacheControl(cache)
+ .build()
+}
+
+fun DELETE(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ body: RequestBody = DEFAULT_BODY,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Request {
+ return Request.Builder()
+ .url(url)
+ .delete(body)
+ .headers(headers)
+ .cacheControl(cache)
+ .build()
+}
+
+// ---- OkHttpClient suspend extension functions (Aniyomi extensions-lib compat) ----
+// These build the request AND execute it, returning a Response.
+
+suspend fun OkHttpClient.get(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Response {
+ return newCall(GET(url, headers, cache)).await()
+}
+
+suspend fun OkHttpClient.post(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ body: RequestBody = DEFAULT_BODY,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Response {
+ return newCall(POST(url, headers, body, cache)).await()
+}
+
+suspend fun OkHttpClient.put(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ body: RequestBody = DEFAULT_BODY,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Response {
+ return newCall(PUT(url, headers, body, cache)).await()
+}
+
+suspend fun OkHttpClient.delete(
+ url: String,
+ headers: Headers = DEFAULT_HEADERS,
+ body: RequestBody = DEFAULT_BODY,
+ cache: CacheControl = DEFAULT_CACHE_CONTROL,
+): Response {
+ return newCall(DELETE(url, headers, body, cache)).await()
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt
new file mode 100644
index 0000000000..1f69882d26
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt
@@ -0,0 +1,14 @@
+package eu.kanade.tachiyomi.network.interceptor
+
+import okhttp3.Interceptor
+import okhttp3.Response
+
+/**
+ * A stubbed CloudflareInterceptor for Mihon extension compatibility.
+ * Modern Mihon handles Cloudflare via the main client, so this is mostly a passthrough.
+ */
+class CloudflareInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ return chain.proceed(chain.request())
+ }
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/RateLimitInterceptor.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/RateLimitInterceptor.kt
new file mode 100644
index 0000000000..d962810467
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/RateLimitInterceptor.kt
@@ -0,0 +1,41 @@
+package eu.kanade.tachiyomi.network.interceptor
+
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import java.util.concurrent.TimeUnit
+
+/**
+ * A stubbed RateLimitInterceptor for Mihon extension compatibility.
+ * In a real implementation, this would handle actual rate limiting.
+ */
+fun OkHttpClient.Builder.rateLimit(
+ permits: Int,
+ period: Long = 1,
+ unit: TimeUnit = TimeUnit.SECONDS,
+): OkHttpClient.Builder {
+ return addInterceptor(RateLimitInterceptor(permits, period, unit))
+}
+
+/**
+ * Overload for extensions using milliseconds.
+ */
+fun OkHttpClient.Builder.rateLimitHost(
+ permits: Int,
+ period: Long = 1,
+ unit: TimeUnit = TimeUnit.SECONDS,
+): OkHttpClient.Builder {
+ return addInterceptor(RateLimitInterceptor(permits, period, unit))
+}
+
+class RateLimitInterceptor(
+ private val permits: Int,
+ private val period: Long,
+ private val unit: TimeUnit,
+) : Interceptor {
+
+ // Minimal implementation: just pass through or a simple delay
+ override fun intercept(chain: Interceptor.Chain): Response {
+ return chain.proceed(chain.request())
+ }
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/SpecificHostRateLimitInterceptor.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/SpecificHostRateLimitInterceptor.kt
new file mode 100644
index 0000000000..8dffc6ee01
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/SpecificHostRateLimitInterceptor.kt
@@ -0,0 +1,110 @@
+package eu.kanade.tachiyomi.network.interceptor
+
+import okhttp3.HttpUrl
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+/**
+ * Rate limit interceptor for specific hosts.
+ *
+ * This is a compatibility shim for Mihon extensions that use SpecificHostRateLimitInterceptor.
+ */
+class SpecificHostRateLimitInterceptor(
+ private val host: HttpUrl,
+ private val permits: Int = 1,
+ private val period: Long = 1,
+ private val unit: TimeUnit = TimeUnit.SECONDS,
+) : Interceptor {
+
+ private val requestQueue = ArrayList(permits)
+ private val rateLimitMillis = unit.toMillis(period)
+
+ @Synchronized
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+
+ // Only apply rate limiting to requests matching the host
+ if (!request.url.host.equals(host.host, ignoreCase = true)) {
+ return chain.proceed(request)
+ }
+
+ // Clean up old requests
+ val now = System.currentTimeMillis()
+ requestQueue.removeAll { it < now - rateLimitMillis }
+
+ // Wait if necessary
+ if (requestQueue.size >= permits) {
+ val oldestRequest = requestQueue.minOrNull() ?: now
+ val waitTime = rateLimitMillis - (now - oldestRequest)
+ if (waitTime > 0) {
+ try {
+ Thread.sleep(waitTime)
+ } catch (e: InterruptedException) {
+ throw IOException("Rate limit wait interrupted", e)
+ }
+ }
+ // Remove oldest request
+ requestQueue.removeAll { it <= oldestRequest }
+ }
+
+ // Add current request
+ requestQueue.add(System.currentTimeMillis())
+
+ return chain.proceed(request)
+ }
+}
+
+/**
+ * Extension function to add specific host rate limiting to OkHttpClient.
+ */
+fun OkHttpClient.Builder.rateLimit(
+ host: HttpUrl,
+ permits: Int = 1,
+ period: Long = 1,
+ unit: TimeUnit = TimeUnit.SECONDS,
+): OkHttpClient.Builder {
+ return addInterceptor(SpecificHostRateLimitInterceptor(host, permits, period, unit))
+}
+
+/**
+ * Extension function to add specific host rate limiting by hostname.
+ */
+fun OkHttpClient.Builder.rateLimit(
+ hostname: String,
+ permits: Int = 1,
+ period: Long = 1,
+ unit: TimeUnit = TimeUnit.SECONDS,
+): OkHttpClient.Builder {
+ val url = HttpUrl.Builder()
+ .scheme("https")
+ .host(hostname)
+ .build()
+ return rateLimit(url, permits, period, unit)
+}
+
+/**
+ * Alias for rateLimit(HttpUrl, ...) - used by some extensions.
+ */
+fun OkHttpClient.Builder.rateLimitHost(
+ url: HttpUrl,
+ permits: Int = 1,
+ period: Long = 1,
+ unit: TimeUnit = TimeUnit.SECONDS,
+): OkHttpClient.Builder {
+ return rateLimit(url, permits, period, unit)
+}
+
+/**
+ * Alias for rateLimit(String, ...) - used by some extensions.
+ */
+fun OkHttpClient.Builder.rateLimitHost(
+ hostname: String,
+ permits: Int = 1,
+ period: Long = 1,
+ unit: TimeUnit = TimeUnit.SECONDS,
+): OkHttpClient.Builder {
+ return rateLimit(hostname, permits, period, unit)
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt
new file mode 100644
index 0000000000..69fb71b650
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt
@@ -0,0 +1,83 @@
+package eu.kanade.tachiyomi.source
+
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import rx.Observable
+
+/**
+ * Mihon-compatible CatalogueSource interface.
+ * A source that supports browsing and searching.
+ */
+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
+
+ /**
+ * Get a page with a list of manga.
+ *
+ * @since extensions-lib 1.5
+ * @param page the page number to retrieve.
+ */
+ @Suppress("DEPRECATION")
+ suspend fun getPopularManga(page: Int): MangasPage {
+ return fetchPopularManga(page).toBlocking().first()
+ }
+
+ /**
+ * Get a page with a list of manga.
+ *
+ * @since extensions-lib 1.5
+ * @param page the page number to retrieve.
+ * @param query the search query.
+ * @param filters the list of filters to apply.
+ */
+ @Suppress("DEPRECATION")
+ suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
+ return fetchSearchManga(page, query, filters).toBlocking().first()
+ }
+
+ /**
+ * Get a page with a list of latest manga updates.
+ *
+ * @since extensions-lib 1.5
+ * @param page the page number to retrieve.
+ */
+ @Suppress("DEPRECATION")
+ suspend fun getLatestUpdates(page: Int): MangasPage {
+ return fetchLatestUpdates(page).toBlocking().first()
+ }
+
+ /**
+ * Returns the list of filters for the source.
+ */
+ fun getFilterList(): FilterList
+
+ @Deprecated(
+ "Use the non-RxJava API instead",
+ ReplaceWith("getPopularManga"),
+ )
+ fun fetchPopularManga(page: Int): Observable =
+ throw IllegalStateException("Not used")
+
+ @Deprecated(
+ "Use the non-RxJava API instead",
+ ReplaceWith("getSearchManga"),
+ )
+ fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable =
+ throw IllegalStateException("Not used")
+
+ @Deprecated(
+ "Use the non-RxJava API instead",
+ ReplaceWith("getLatestUpdates"),
+ )
+ fun fetchLatestUpdates(page: Int): Observable =
+ throw IllegalStateException("Not used")
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/ConfigurableSource.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/ConfigurableSource.kt
new file mode 100644
index 0000000000..cea29c21c0
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/ConfigurableSource.kt
@@ -0,0 +1,33 @@
+package eu.kanade.tachiyomi.source
+
+import android.app.Application
+import android.content.Context
+import android.content.SharedPreferences
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+/**
+ * Mihon-compatible ConfigurableSource interface.
+ * Sources implementing this can provide user-configurable settings.
+ */
+interface ConfigurableSource : Source {
+
+ /**
+ * Gets instance of [SharedPreferences] scoped to the specific source.
+ *
+ * @since extensions-lib 1.5
+ */
+ fun getSourcePreferences(): SharedPreferences =
+ Injekt.get().getSharedPreferences(preferenceKey(), Context.MODE_PRIVATE)
+
+ fun setupPreferenceScreen(screen: PreferenceScreen)
+}
+
+fun ConfigurableSource.preferenceKey(): String = "source_$id"
+
+// TODO: use getSourcePreferences once all extensions are on ext-lib 1.5
+fun ConfigurableSource.sourcePreferences(): SharedPreferences =
+ Injekt.get().getSharedPreferences(preferenceKey(), Context.MODE_PRIVATE)
+
+fun sourcePreferences(key: String): SharedPreferences =
+ Injekt.get().getSharedPreferences(key, Context.MODE_PRIVATE)
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt
new file mode 100644
index 0000000000..982f1a9ef0
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt
@@ -0,0 +1,8 @@
+package eu.kanade.tachiyomi.source
+
+import androidx.preference.PreferenceScreen as AndroidPreferenceScreen
+
+/**
+ * Mihon-compatible PreferenceScreen type alias.
+ */
+typealias PreferenceScreen = AndroidPreferenceScreen
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt
new file mode 100644
index 0000000000..3721e5505c
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt
@@ -0,0 +1,84 @@
+package eu.kanade.tachiyomi.source
+
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import rx.Observable
+
+/**
+ * A basic interface for creating a source. It could be an online source, a local source, etc.
+ * Ported from Mihon source-api for extension compatibility.
+ */
+interface Source {
+
+ /**
+ * ID for the source. Must be unique.
+ */
+ val id: Long
+
+ /**
+ * Name of the source.
+ */
+ val name: String
+
+ val lang: String
+ get() = ""
+
+ /**
+ * Get the updated details for a manga.
+ *
+ * @since extensions-lib 1.5
+ * @param manga the manga to update.
+ * @return the updated manga.
+ */
+ @Suppress("DEPRECATION")
+ suspend fun getMangaDetails(manga: SManga): SManga {
+ return fetchMangaDetails(manga).toBlocking().first()
+ }
+
+ /**
+ * Get all the available chapters for a manga.
+ *
+ * @since extensions-lib 1.5
+ * @param manga the manga to update.
+ * @return the chapters for the manga.
+ */
+ @Suppress("DEPRECATION")
+ suspend fun getChapterList(manga: SManga): List {
+ return fetchChapterList(manga).toBlocking().first()
+ }
+
+ /**
+ * Get the list of pages a chapter has. Pages should be returned
+ * in the expected order; the index is ignored.
+ *
+ * @since extensions-lib 1.5
+ * @param chapter the chapter.
+ * @return the pages for the chapter.
+ */
+ @Suppress("DEPRECATION")
+ suspend fun getPageList(chapter: SChapter): List {
+ return fetchPageList(chapter).toBlocking().first()
+ }
+
+ @Deprecated(
+ "Use the non-RxJava API instead",
+ ReplaceWith("getMangaDetails"),
+ )
+ fun fetchMangaDetails(manga: SManga): Observable =
+ throw IllegalStateException("Not used")
+
+ @Deprecated(
+ "Use the non-RxJava API instead",
+ ReplaceWith("getChapterList"),
+ )
+ fun fetchChapterList(manga: SManga): Observable> =
+ throw IllegalStateException("Not used")
+
+ @Deprecated(
+ "Use the non-RxJava API instead",
+ ReplaceWith("getPageList"),
+ )
+ fun fetchPageList(chapter: SChapter): Observable> =
+ throw IllegalStateException("Not used")
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/SourceFactory.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/SourceFactory.kt
new file mode 100644
index 0000000000..d326c437a5
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/SourceFactory.kt
@@ -0,0 +1,12 @@
+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
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/UnmeteredSource.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/UnmeteredSource.kt
new file mode 100644
index 0000000000..b84ec2b463
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/UnmeteredSource.kt
@@ -0,0 +1,8 @@
+package eu.kanade.tachiyomi.source
+
+/**
+ * A source that explicitly states it doesn't require traffic considerations.
+ *
+ * Usually used for self-hosted sources that don't have rate limits.
+ */
+interface UnmeteredSource
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Filter.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Filter.kt
new file mode 100644
index 0000000000..79856e1624
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Filter.kt
@@ -0,0 +1,63 @@
+package eu.kanade.tachiyomi.source.model
+
+/**
+ * Mihon-compatible Filter classes.
+ * Ported from Mihon source-api for extension compatibility.
+ */
+sealed class Filter(val name: String, var state: T) {
+ open class Header(name: String) : Filter(name, 0)
+ open class Separator(name: String = "") : Filter(name, 0)
+ abstract class Select(name: String, val values: Array, state: Int = 0) : Filter(
+ name,
+ state,
+ )
+ abstract class Text(name: String, state: String = "") : Filter(name, state)
+ abstract class CheckBox(name: String, state: Boolean = false) : Filter(name, state)
+ abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter(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(name: String, state: List) : Filter>(name, state)
+
+ abstract class Sort(name: String, val values: Array, state: Selection? = null) :
+ Filter(name, state) {
+ data class Selection(val index: Int, val ascending: Boolean)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Filter<*>) return false
+
+ return name == other.name && state == other.state
+ }
+
+ override fun hashCode(): Int {
+ var result = name.hashCode()
+ result = 31 * result + (state?.hashCode() ?: 0)
+ return result
+ }
+}
+
+/**
+ * Mihon-compatible FilterList class.
+ */
+data class FilterList(val list: List>) : List> 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()
+ }
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt
new file mode 100644
index 0000000000..9f594e3c59
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt
@@ -0,0 +1,6 @@
+package eu.kanade.tachiyomi.source.model
+
+/**
+ * Mihon-compatible MangasPage data class.
+ */
+data class MangasPage(val mangas: List, val hasNextPage: Boolean)
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt
new file mode 100644
index 0000000000..078abf84e1
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt
@@ -0,0 +1,52 @@
+package eu.kanade.tachiyomi.source.model
+
+import android.net.Uri
+import eu.kanade.tachiyomi.network.ProgressListener
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+
+/**
+ * Mihon-compatible Page class.
+ * Ported from Mihon source-api for extension compatibility.
+ *
+ * Includes [uri] and [ProgressListener] for binary compatibility with extensions.
+ */
+@Serializable
+open class Page @JvmOverloads constructor(
+ var index: Int,
+ var url: String = "",
+ var imageUrl: String? = null,
+ @Transient var uri: Uri? = null,
+) : ProgressListener {
+
+ val number: Int
+ get() = index + 1
+
+ @Transient
+ var status: State = State.Queue
+
+ @Transient
+ var progress: Int = 0
+
+ override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
+ progress = if (contentLength > 0) {
+ (100 * bytesRead / contentLength).toInt()
+ } else {
+ -1
+ }
+ }
+
+ fun copy(
+ index: Int = this.index,
+ url: String = this.url,
+ imageUrl: String? = this.imageUrl,
+ ): Page = Page(index, url, imageUrl)
+
+ sealed interface State {
+ data object Queue : State
+ data object LoadPage : State
+ data object DownloadImage : State
+ data object Ready : State
+ data class Error(val error: Throwable) : State
+ }
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt
new file mode 100644
index 0000000000..d172ad5a69
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt
@@ -0,0 +1,52 @@
+@file:Suppress("PropertyName")
+
+package eu.kanade.tachiyomi.source.model
+
+import java.io.Serializable
+
+/**
+ * Mihon-compatible SChapter interface.
+ * Ported from Mihon source-api for extension compatibility.
+ */
+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
+ }
+
+ companion object {
+ fun create(): SChapter {
+ return SChapterImpl()
+ }
+ }
+}
+
+/**
+ * Default implementation of SChapter.
+ */
+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
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt
new file mode 100644
index 0000000000..ecd594c2da
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt
@@ -0,0 +1,90 @@
+@file:Suppress("PropertyName")
+
+package eu.kanade.tachiyomi.source.model
+
+import java.io.Serializable
+
+/**
+ * Mihon-compatible SManga interface.
+ * Ported from Mihon source-api for extension compatibility.
+ */
+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 update_strategy: UpdateStrategy
+
+ var initialized: Boolean
+
+ fun getGenres(): List? {
+ if (genre.isNullOrBlank()) return null
+ return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
+ }
+
+ fun copy() = create().also {
+ it.url = url
+ it.title = title
+ it.artist = artist
+ it.author = author
+ it.description = description
+ it.genre = genre
+ it.status = status
+ it.thumbnail_url = thumbnail_url
+ it.update_strategy = update_strategy
+ 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()
+ }
+ }
+}
+
+/**
+ * Default implementation of SManga.
+ */
+class SMangaImpl : SManga {
+
+ override lateinit var url: String
+
+ override lateinit var title: String
+
+ override var artist: String? = null
+
+ override var author: String? = null
+
+ override var description: String? = null
+
+ override var genre: String? = null
+
+ override var status: Int = 0
+
+ override var thumbnail_url: String? = null
+
+ override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
+
+ override var initialized: Boolean = false
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt
new file mode 100644
index 0000000000..d76a7dd008
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt
@@ -0,0 +1,23 @@
+package eu.kanade.tachiyomi.source.model
+
+/**
+ * Define the update strategy for a single [SManga].
+ * The strategy used will only take effect on the library update.
+ *
+ * @since extensions-lib 1.4
+ */
+@Suppress("UNUSED")
+enum class UpdateStrategy {
+ /**
+ * Series marked as always update will be included in the library
+ * update if they aren't excluded by additional restrictions.
+ */
+ ALWAYS_UPDATE,
+
+ /**
+ * Series marked as only fetch once will be automatically skipped
+ * during library updates. Useful for cases where the series is previously
+ * known to be finished and have only a single chapter, for example.
+ */
+ ONLY_FETCH_ONCE,
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt
new file mode 100644
index 0000000000..4a602a71eb
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt
@@ -0,0 +1,290 @@
+package eu.kanade.tachiyomi.source.online
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.NetworkHelper
+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 io.github.landwarderer.futon.mihon.model.contentSource
+import io.github.landwarderer.futon.mihon.parsers.model.ContentSource
+import okhttp3.Headers
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.net.URI
+import java.net.URISyntaxException
+import java.security.MessageDigest
+
+/**
+ * A simple implementation for sources from a website.
+ * Ported from Mihon source-api for extension compatibility.
+ */
+@Suppress("unused")
+abstract class HttpSource : CatalogueSource {
+
+ /**
+ * Network service.
+ */
+ protected val network: NetworkHelper by injectLazy()
+
+ /**
+ * Base url of the website without the trailing slash, like: http://mysite.com
+ */
+ abstract val baseUrl: String
+
+ /**
+ * Version id used to generate the source id. If the site completely changes and urls are
+ * incompatible, you may increase this value and it'll be considered as a new source.
+ */
+ open val versionId = 1
+
+ /**
+ * ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
+ * of the MD5 of the string `"${name.lowercase()}/$lang/$versionId"`.
+ */
+ override val id by lazy { generateId(name, lang, versionId) }
+
+ /**
+ * Headers used for requests.
+ */
+ val headers: Headers by lazy { headersBuilder().build() }
+
+ /**
+ * Default network client for doing requests.
+ */
+ open val client: OkHttpClient
+ get() = network.client
+
+ /**
+ * Generates a unique ID for the source.
+ */
+ @Suppress("MemberVisibilityCanBePrivate")
+ protected fun generateId(name: String, lang: String, versionId: Int): Long {
+ val key = "${name.lowercase()}/$lang/$versionId"
+ val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
+ return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
+ }
+
+ /**
+ * Headers builder for requests. Implementations can override this method for custom headers.
+ */
+ protected open fun headersBuilder() = Headers.Builder().apply {
+ add("User-Agent", network.defaultUserAgentProvider())
+ }
+
+ /**
+ * Visible name of the source.
+ */
+ override fun toString() = "$name (${lang.uppercase()})"
+
+ // ======== Popular manga ========
+
+ @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
+ override fun fetchPopularManga(page: Int): Observable {
+ return Observable.fromCallable {
+ val response = client.newCall(tagRequest(popularMangaRequest(page))).execute()
+ popularMangaParse(response)
+ }
+ }
+
+ protected abstract fun popularMangaRequest(page: Int): Request
+
+ protected abstract fun popularMangaParse(response: Response): MangasPage
+
+ // ======== Search manga ========
+
+ @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
+ override fun fetchSearchManga(
+ page: Int,
+ query: String,
+ filters: FilterList,
+ ): Observable {
+ return Observable.defer {
+ try {
+ Observable.fromCallable {
+ val response = client.newCall(tagRequest(searchMangaRequest(page, query, filters))).execute()
+ searchMangaParse(response)
+ }
+ } catch (e: NoClassDefFoundError) {
+ throw RuntimeException(e)
+ }
+ }
+ }
+
+ protected abstract fun searchMangaRequest(
+ page: Int,
+ query: String,
+ filters: FilterList,
+ ): Request
+
+ protected abstract fun searchMangaParse(response: Response): MangasPage
+
+ // ======== Latest updates ========
+
+ @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates"))
+ override fun fetchLatestUpdates(page: Int): Observable {
+ return Observable.fromCallable {
+ val response = client.newCall(tagRequest(latestUpdatesRequest(page))).execute()
+ latestUpdatesParse(response)
+ }
+ }
+
+ protected abstract fun latestUpdatesRequest(page: Int): Request
+
+ protected abstract fun latestUpdatesParse(response: Response): MangasPage
+
+ // ======== Content details ========
+
+ @Suppress("DEPRECATION")
+ override suspend fun getMangaDetails(manga: SManga): SManga {
+ return fetchMangaDetails(manga).toBlocking().first()
+ }
+
+ @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return Observable.fromCallable {
+ val response = client.newCall(tagRequest(mangaDetailsRequest(manga))).execute()
+ mangaDetailsParse(response).apply { initialized = true }
+ }
+ }
+
+ open fun mangaDetailsRequest(manga: SManga): Request {
+ return GET(baseUrl + manga.url, headers)
+ }
+
+ protected abstract fun mangaDetailsParse(response: Response): SManga
+
+ // ======== Chapter list ========
+
+ @Suppress("DEPRECATION")
+ override suspend fun getChapterList(manga: SManga): List {
+ return fetchChapterList(manga).toBlocking().first()
+ }
+
+ @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
+ override fun fetchChapterList(manga: SManga): Observable> {
+ return Observable.fromCallable {
+ val response = client.newCall(tagRequest(chapterListRequest(manga))).execute()
+ chapterListParse(response)
+ }
+ }
+
+ protected open fun chapterListRequest(manga: SManga): Request {
+ return GET(baseUrl + manga.url, headers)
+ }
+
+ protected abstract fun chapterListParse(response: Response): List
+
+ // ======== Page list ========
+
+ @Suppress("DEPRECATION")
+ override suspend fun getPageList(chapter: SChapter): List {
+ return fetchPageList(chapter).toBlocking().first()
+ }
+
+ @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ return Observable.fromCallable {
+ val response = client.newCall(tagRequest(pageListRequest(chapter))).execute()
+ pageListParse(response)
+ }
+ }
+
+ protected open fun pageListRequest(chapter: SChapter): Request {
+ return GET(baseUrl + chapter.url, headers)
+ }
+
+ protected abstract fun pageListParse(response: Response): List
+
+ // ======== Image URL ========
+
+ open suspend fun getImageUrl(page: Page): String {
+ return fetchImageUrl(page).toBlocking().first()
+ }
+
+ @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
+ open fun fetchImageUrl(page: Page): Observable {
+ return Observable.fromCallable {
+ val response = client.newCall(tagRequest(imageUrlRequest(page))).execute()
+ imageUrlParse(response)
+ }
+ }
+
+ protected open fun imageUrlRequest(page: Page): Request {
+ return GET(page.url, headers)
+ }
+
+ protected abstract fun imageUrlParse(response: Response): String
+
+ private fun tagRequest(request: Request): Request {
+ if (request.tag(ContentSource::class.java) != null) {
+ return request
+ }
+ return request.newBuilder()
+ .tag(
+ ContentSource::class.java,
+ contentSource("MIHON_$id"),
+ )
+ .build()
+ }
+
+ // ======== Image request ========
+
+ open suspend fun getImage(page: Page): Response {
+ return client.newCall(imageRequest(page)).execute()
+ }
+
+ open fun imageRequest(page: Page): Request {
+ return GET(page.imageUrl!!, headers)
+ }
+
+ /**
+ * Public helper to get headers for a page.
+ */
+ fun getPageHeaders(page: Page): Headers {
+ return imageRequest(page).headers
+ }
+
+ // ======== URL helpers ========
+
+ fun SChapter.setUrlWithoutDomain(url: String) {
+ this.url = getUrlWithoutDomain(url)
+ }
+
+ fun SManga.setUrlWithoutDomain(url: String) {
+ this.url = getUrlWithoutDomain(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
+ }
+ }
+
+ open fun getMangaUrl(manga: SManga): String {
+ return mangaDetailsRequest(manga).url.toString()
+ }
+
+ open fun getChapterUrl(chapter: SChapter): String {
+ return pageListRequest(chapter).url.toString()
+ }
+
+ open fun prepareNewChapter(chapter: SChapter, manga: SManga) {}
+
+ override fun getFilterList() = FilterList()
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt
new file mode 100644
index 0000000000..36c7f92278
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt
@@ -0,0 +1,126 @@
+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.
+ * Ported from Mihon source-api for extension compatibility.
+ */
+@Suppress("unused")
+abstract class ParsedHttpSource : HttpSource() {
+
+ /**
+ * Parses the response from the site and returns a [MangasPage] object.
+ */
+ 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)
+ }
+
+ protected abstract fun popularMangaSelector(): String
+
+ protected abstract fun popularMangaFromElement(element: Element): SManga
+
+ protected abstract fun popularMangaNextPageSelector(): String?
+
+ /**
+ * Parses the response from the site and returns a [MangasPage] object.
+ */
+ 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)
+ }
+
+ protected abstract fun searchMangaSelector(): String
+
+ protected abstract fun searchMangaFromElement(element: Element): SManga
+
+ protected abstract fun searchMangaNextPageSelector(): String?
+
+ /**
+ * Parses the response from the site and returns a [MangasPage] object.
+ */
+ 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)
+ }
+
+ protected abstract fun latestUpdatesSelector(): String
+
+ protected abstract fun latestUpdatesFromElement(element: Element): SManga
+
+ protected abstract fun latestUpdatesNextPageSelector(): String?
+
+ /**
+ * Parses the response from the site and returns the details of a manga.
+ */
+ override fun mangaDetailsParse(response: Response): SManga {
+ return mangaDetailsParse(response.asJsoup())
+ }
+
+ protected abstract fun mangaDetailsParse(document: Document): SManga
+
+ /**
+ * Parses the response from the site and returns a list of chapters.
+ */
+ override fun chapterListParse(response: Response): List {
+ val document = response.asJsoup()
+ return document.select(chapterListSelector()).map { chapterFromElement(it) }
+ }
+
+ protected abstract fun chapterListSelector(): String
+
+ protected abstract fun chapterFromElement(element: Element): SChapter
+
+ /**
+ * Parses the response from the site and returns the page list.
+ */
+ override fun pageListParse(response: Response): List {
+ return pageListParse(response.asJsoup())
+ }
+
+ protected abstract fun pageListParse(document: Document): List
+
+ /**
+ * Parse the response from the site and returns the absolute url to the source image.
+ */
+ override fun imageUrlParse(response: Response): String {
+ return imageUrlParse(response.asJsoup())
+ }
+
+ protected abstract fun imageUrlParse(document: Document): String
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/util/JsoupExtensions.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/util/JsoupExtensions.kt
new file mode 100644
index 0000000000..f978024ec1
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/util/JsoupExtensions.kt
@@ -0,0 +1,30 @@
+package eu.kanade.tachiyomi.util
+
+import okhttp3.Response
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+
+/**
+ * Jsoup extension functions for Mihon compatibility.
+ */
+
+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())
+}
diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/util/RxExtensions.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/util/RxExtensions.kt
new file mode 100644
index 0000000000..5c5c7f5b08
--- /dev/null
+++ b/app/src/main/kotlin/eu/kanade/tachiyomi/util/RxExtensions.kt
@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.util
+
+import kotlinx.coroutines.suspendCancellableCoroutine
+import rx.Observable
+import rx.Subscription
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+/**
+ * Awaits a single value from the [Observable] and returns it.
+ */
+suspend fun Observable.awaitSingle(): T = suspendCancellableCoroutine { continuation ->
+ val subscription: Subscription = first().subscribe(
+ { continuation.resume(it) },
+ { continuation.resumeWithException(it) }
+ )
+
+ continuation.invokeOnCancellation {
+ subscription.unsubscribe()
+ }
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/backups/domain/AppBackupAgent.kt b/app/src/main/kotlin/io/github/landwarderer/futon/backups/domain/AppBackupAgent.kt
index 7bfaf6bf82..711748a58e 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/backups/domain/AppBackupAgent.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/backups/domain/AppBackupAgent.kt
@@ -8,21 +8,26 @@ import android.content.Context
import android.os.ParcelFileDescriptor
import androidx.annotation.VisibleForTesting
import com.google.common.io.ByteStreams
-import kotlinx.coroutines.runBlocking
import io.github.landwarderer.futon.backups.data.BackupRepository
import io.github.landwarderer.futon.core.db.MangaDatabase
import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.explore.data.MangaSourcesRepository
import io.github.landwarderer.futon.filter.data.SavedFiltersRepository
+import io.github.landwarderer.futon.mihon.MihonExtensionManager
import io.github.landwarderer.futon.reader.data.TapGridSettings
+import kotlinx.coroutines.runBlocking
import java.io.File
import java.io.FileDescriptor
import java.io.FileInputStream
import java.util.EnumSet
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
+import javax.inject.Inject
+import javax.inject.Provider
class AppBackupAgent : BackupAgent() {
+ @Inject
+ lateinit var mihonExtensionManager: Provider
override fun onBackup(
oldState: ParcelFileDescriptor?,
@@ -38,6 +43,7 @@ class AppBackupAgent : BackupAgent() {
override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data)
+
val file = createBackupFile(
this,
BackupRepository(
@@ -48,6 +54,7 @@ class AppBackupAgent : BackupAgent() {
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
+ mihonExtensionManager = mihonExtensionManager.get(),
),
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
@@ -81,6 +88,7 @@ class AppBackupAgent : BackupAgent() {
context = applicationContext,
db = MangaDatabase(context = applicationContext),
settings = AppSettings(applicationContext),
+ mihonExtensionManager = mihonExtensionManager.get(),
),
savedFiltersRepository = SavedFiltersRepository(
context = applicationContext,
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/BaseApp.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/BaseApp.kt
index cd2f35a50b..733972881c 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/BaseApp.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/BaseApp.kt
@@ -9,24 +9,21 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.launch
-import okhttp3.internal.platform.PlatformRegistry
-
-import org.conscrypt.Conscrypt
import io.github.landwarderer.futon.BuildConfig
-import io.github.landwarderer.futon.R
import io.github.landwarderer.futon.core.db.MangaDatabase
import io.github.landwarderer.futon.core.os.AppValidator
-import io.github.landwarderer.futon.core.os.RomCompat
import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.core.util.ext.processLifecycleScope
import io.github.landwarderer.futon.local.data.LocalStorageChanges
import io.github.landwarderer.futon.local.data.index.LocalMangaIndex
import io.github.landwarderer.futon.local.domain.model.LocalManga
-import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
+import io.github.landwarderer.futon.mihon.MihonExtensionManager
import io.github.landwarderer.futon.settings.work.WorkScheduleManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import okhttp3.internal.platform.PlatformRegistry
+import org.conscrypt.Conscrypt
import java.security.Security
import javax.inject.Inject
import javax.inject.Provider
@@ -34,6 +31,9 @@ import javax.inject.Provider
@HiltAndroidApp
open class BaseApp : Application(), Configuration.Provider {
+ @Inject
+ lateinit var mihonExtensionManager: MihonExtensionManager
+
@Inject
lateinit var databaseObserversProvider: Provider>
@@ -80,6 +80,7 @@ open class BaseApp : Application(), Configuration.Provider {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
setupActivityLifecycleCallbacks()
+ mihonExtensionManager.initialize()
processLifecycleScope.launch(Dispatchers.IO) {
setupDatabaseObservers()
localStorageChanges.collect(localMangaIndexProvider.get())
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/DatabasePrePopulateCallback.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/DatabasePrePopulateCallback.kt
index ebcc2425c4..df36329b46 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/DatabasePrePopulateCallback.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/DatabasePrePopulateCallback.kt
@@ -21,5 +21,21 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
0L,
)
)
+
+ val now = System.currentTimeMillis()
+ db.execSQL(
+ "INSERT INTO external_extension_repos (type, baseUrl, name, shortName, website, signingKeyFingerprint, createdAt, updatedAt, lastSuccessAt) VALUES (?,?,?,?,?,?,?,?,?)",
+ arrayOf(
+ "MIHON",
+ "https://raw.githubusercontent.com/keiyoushi/extensions/refs/heads/repo",
+ "Keiyoushi",
+ "Keiyoushi",
+ "https://keiyoushi.github.io/extensions",
+ "508c909405615d0234a41316b230230559f6b9a89c3f15c13b306b38c2306f50",
+ now,
+ now,
+ now,
+ )
+ )
}
}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt
index 1e0f504aa4..2bd0315d00 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt
@@ -6,19 +6,17 @@ import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
import io.github.landwarderer.futon.bookmarks.data.BookmarkEntity
import io.github.landwarderer.futon.bookmarks.data.BookmarksDao
import io.github.landwarderer.futon.core.db.dao.ChaptersDao
+import io.github.landwarderer.futon.core.db.dao.ExternalExtensionRepoDao
import io.github.landwarderer.futon.core.db.dao.MangaDao
import io.github.landwarderer.futon.core.db.dao.MangaSourcesDao
import io.github.landwarderer.futon.core.db.dao.PreferencesDao
import io.github.landwarderer.futon.core.db.dao.TagsDao
import io.github.landwarderer.futon.core.db.dao.TrackLogsDao
import io.github.landwarderer.futon.core.db.entity.ChapterEntity
+import io.github.landwarderer.futon.core.db.entity.ExternalExtensionRepoEntity
import io.github.landwarderer.futon.core.db.entity.MangaEntity
import io.github.landwarderer.futon.core.db.entity.MangaPrefsEntity
import io.github.landwarderer.futon.core.db.entity.MangaSourceEntity
@@ -69,6 +67,10 @@ import io.github.landwarderer.futon.suggestions.data.SuggestionEntity
import io.github.landwarderer.futon.tracker.data.TrackEntity
import io.github.landwarderer.futon.tracker.data.TrackLogEntity
import io.github.landwarderer.futon.tracker.data.TracksDao
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
const val DATABASE_VERSION = 27
@@ -77,7 +79,7 @@ const val DATABASE_VERSION = 27
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class,
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class,
- MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
+ MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class, ExternalExtensionRepoEntity::class,
],
version = DATABASE_VERSION,
)
@@ -112,6 +114,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
abstract fun getChaptersDao(): ChaptersDao
+
+ abstract fun getExternalExtensionRepoDao(): ExternalExtensionRepoDao
}
fun getDatabaseMigrations(context: Context): Array = arrayOf(
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ExternalExtensionRepoDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ExternalExtensionRepoDao.kt
new file mode 100644
index 0000000000..cd0622d992
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ExternalExtensionRepoDao.kt
@@ -0,0 +1,30 @@
+package io.github.landwarderer.futon.core.db.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Upsert
+import io.github.landwarderer.futon.core.db.entity.ExternalExtensionRepoEntity
+import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionType
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ExternalExtensionRepoDao {
+
+ @Query("SELECT * FROM external_extension_repos WHERE type = :type")
+ fun observeByType(type: ExternalExtensionType): Flow>
+
+ @Query("SELECT * FROM external_extension_repos WHERE type = :type")
+ suspend fun getByType(type: ExternalExtensionType): List
+
+ @Query("SELECT * FROM external_extension_repos WHERE type = :type AND baseUrl = :baseUrl LIMIT 1")
+ suspend fun get(type: ExternalExtensionType, baseUrl: String): ExternalExtensionRepoEntity?
+
+ @Query("SELECT * FROM external_extension_repos WHERE type = :type AND signingKeyFingerprint = :fingerprint LIMIT 1")
+ suspend fun getByFingerprint(type: ExternalExtensionType, fingerprint: String): ExternalExtensionRepoEntity?
+
+ @Upsert
+ suspend fun upsert(entity: ExternalExtensionRepoEntity)
+
+ @Query("DELETE FROM external_extension_repos WHERE type = :type AND baseUrl = :baseUrl")
+ suspend fun delete(type: ExternalExtensionType, baseUrl: String)
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/ExternalExtensionRepoEntity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/ExternalExtensionRepoEntity.kt
new file mode 100644
index 0000000000..882a522c22
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/ExternalExtensionRepoEntity.kt
@@ -0,0 +1,28 @@
+package io.github.landwarderer.futon.core.db.entity
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionType
+
+@Entity(
+ tableName = "external_extension_repos",
+ indices = [
+ Index(value = ["type", "baseUrl"], unique = true),
+ Index(value = ["type", "signingKeyFingerprint"], unique = true),
+ ]
+)
+data class ExternalExtensionRepoEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val type: ExternalExtensionType,
+ val baseUrl: String,
+ val name: String,
+ val shortName: String?,
+ val website: String,
+ val signingKeyFingerprint: String,
+ val createdAt: Long,
+ val updatedAt: Long,
+ val lastSuccessAt: Long,
+ val lastError: String?,
+ val version: String? = null,
+)
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/UnsupportedSourceException.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/UnsupportedSourceException.kt
index f6711ae4f5..a82b05b386 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/UnsupportedSourceException.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/UnsupportedSourceException.kt
@@ -1,8 +1,10 @@
package io.github.landwarderer.futon.core.exceptions
import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
class UnsupportedSourceException(
message: String?,
- val manga: Manga?,
+ val manga: Manga? = null,
+ val source: MangaSource? = null,
) : IllegalArgumentException(message)
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt
index 97ad54b86b..48a2e1b8ef 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt
@@ -14,6 +14,8 @@ import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource
import io.github.landwarderer.futon.core.util.ext.getDisplayName
import io.github.landwarderer.futon.core.util.ext.toLocale
import io.github.landwarderer.futon.core.util.ext.toLocaleOrNull
+import io.github.landwarderer.futon.mihon.model.MihonMangaSource
+import io.github.landwarderer.futon.mihon.parsers.model.ContentType as MihonContentType
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -42,17 +44,23 @@ fun MangaSource(name: String?): MangaSource {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
}
+ if (name.startsWith("mihon:") || name.startsWith("MIHON_")) {
+ return AnonymousMangaSource(name)
+ }
MangaParserSource.entries.forEach {
if (it.name == name) return it
}
return UnknownMangaSource
}
+private data class AnonymousMangaSource(override val name: String) : MangaSource
+
fun Collection.toMangaSources() = map(::MangaSource)
-fun MangaSource.isNsfw(): Boolean = when (this) {
- is MangaSourceInfo -> mangaSource.isNsfw()
- is MangaParserSource -> contentType == ContentType.HENTAI
+fun MangaSource.isNsfw(): Boolean = when (val source = unwrap()) {
+ is MangaSourceInfo -> source.mangaSource.isNsfw()
+ is MangaParserSource -> source.contentType == ContentType.HENTAI
+ is MihonMangaSource -> source.isNsfw
else -> false
}
@@ -90,6 +98,27 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra
is ExternalMangaSource -> context.getString(R.string.external_source)
+ is MihonMangaSource -> {
+ val contentType = when (source.contentType) {
+ MihonContentType.MANGA -> ContentType.MANGA
+ MihonContentType.MANHWA -> ContentType.MANHWA
+ MihonContentType.MANHUA -> ContentType.MANHUA
+ MihonContentType.HENTAI_MANGA, MihonContentType.HENTAI_NOVEL, MihonContentType.HENTAI_VIDEO -> ContentType.HENTAI
+ MihonContentType.COMICS -> ContentType.COMICS
+ MihonContentType.VIDEO -> ContentType.OTHER
+ MihonContentType.NOVEL -> ContentType.NOVEL
+ MihonContentType.ONE_SHOT -> ContentType.ONE_SHOT
+ MihonContentType.DOUJINSHI -> ContentType.DOUJINSHI
+ MihonContentType.IMAGE_SET -> ContentType.IMAGE_SET
+ MihonContentType.ARTIST_CG -> ContentType.ARTIST_CG
+ MihonContentType.GAME_CG -> ContentType.GAME_CG
+ MihonContentType.OTHER -> ContentType.OTHER
+ }
+ val type = context.getString(contentType.titleResId)
+ val locale = source.language.toLocaleOrNull()?.getDisplayName(context) ?: source.language
+ context.getString(R.string.source_summary_pattern, type, locale)
+ }
+
else -> null
}
@@ -98,9 +127,13 @@ fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()
LocalMangaSource -> context.getString(R.string.local_storage)
TestMangaSource -> context.getString(R.string.test_parser)
is ExternalMangaSource -> source.resolveName(context)
+ is MihonMangaSource -> source.displayName
else -> context.getString(R.string.unknown)
}
+val MangaSource.isBroken: Boolean
+ get() = (this as? MangaParserSource)?.isBroken == true
+
fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder {
val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this
icon.setTintList(textView.textColors)
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt
index 88367ddeed..6c1fbd8f5a 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt
@@ -77,15 +77,6 @@ import io.github.landwarderer.futon.local.ui.ImportDialogFragment
import io.github.landwarderer.futon.local.ui.info.LocalInfoDialog
import io.github.landwarderer.futon.main.ui.MainActivity
import io.github.landwarderer.futon.main.ui.welcome.WelcomeSheet
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaListFilter
-import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.parsers.util.ellipsize
-import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
-import org.koitharu.kotatsu.parsers.util.mapToArray
import io.github.landwarderer.futon.reader.ui.colorfilter.ColorFilterConfigActivity
import io.github.landwarderer.futon.reader.ui.config.ReaderConfigSheet
import io.github.landwarderer.futon.scrobbling.common.domain.model.ScrobblerService
@@ -100,6 +91,7 @@ import io.github.landwarderer.futon.settings.override.OverrideConfigActivity
import io.github.landwarderer.futon.settings.reader.ReaderTapGridConfigActivity
import io.github.landwarderer.futon.settings.sources.auth.SourceAuthActivity
import io.github.landwarderer.futon.settings.sources.catalog.SourcesCatalogActivity
+import io.github.landwarderer.futon.settings.sources.extension.ExtensionDownloaderActivity
import io.github.landwarderer.futon.settings.storage.MangaDirectorySelectDialog
import io.github.landwarderer.futon.settings.storage.directories.MangaDirectoriesActivity
import io.github.landwarderer.futon.settings.tracker.categories.TrackerCategoriesConfigSheet
@@ -107,6 +99,15 @@ import io.github.landwarderer.futon.stats.ui.StatsActivity
import io.github.landwarderer.futon.stats.ui.sheet.MangaStatsSheet
import io.github.landwarderer.futon.suggestions.ui.SuggestionsActivity
import io.github.landwarderer.futon.tracker.ui.updates.UpdatesActivity
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.util.ellipsize
+import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
+import org.koitharu.kotatsu.parsers.util.mapToArray
import java.io.File
import androidx.appcompat.R as appcompatR
@@ -207,6 +208,8 @@ class AppRouter private constructor(
fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java)
+ fun openExtensionDownloader() = startActivity(ExtensionDownloaderActivity::class.java)
+
fun openDownloads() = startActivity(DownloadsActivity::class.java)
fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java)
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt
index eb78d2b621..76cda52eae 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt
@@ -1,22 +1,21 @@
package io.github.landwarderer.futon.core.network
import dagger.Lazy
-import io.github.landwarderer.futon.BuildConfig
import io.github.landwarderer.futon.core.model.MangaSource
import io.github.landwarderer.futon.core.parser.MangaLoaderContextImpl
import io.github.landwarderer.futon.core.parser.MangaRepository
import io.github.landwarderer.futon.core.parser.ParserMangaRepository
import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug
-import org.koitharu.kotatsu.parsers.model.MangaParserSource
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.util.mergeWith
-import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Interceptor.Chain
import okhttp3.Request
import okhttp3.Response
import okio.IOException
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mergeWith
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.IDN
import javax.inject.Inject
import javax.inject.Singleton
@@ -34,10 +33,6 @@ class CommonHeadersInterceptor @Inject constructor(
val repository = if (source is MangaParserSource) {
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else {
- if (BuildConfig.DEBUG && source == null) {
- IllegalArgumentException("Request without source tag: ${request.url}")
- .printStackTrace()
- }
null
}
val headersBuilder = request.headers.newBuilder()
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/HttpClients.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/HttpClients.kt
index 59392ab7fa..c728307079 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/HttpClients.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/HttpClients.kt
@@ -9,3 +9,7 @@ annotation class BaseHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class MangaHttpClient
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class ContentHttpClient
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt
index 8bb75f04db..39c0643bac 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/NetworkModule.kt
@@ -18,6 +18,7 @@ import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.core.util.ext.assertNotInMainThread
import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug
import io.github.landwarderer.futon.local.data.LocalStorageManager
+import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.CookieJar
import okhttp3.OkHttpClient
@@ -98,5 +99,12 @@ interface NetworkModule {
addInterceptor(commonHeadersInterceptor)
}.build()
+ @Provides
+ @Singleton
+ fun provideJson(): Json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ }
+
}
}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/EmptyMangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/EmptyMangaRepository.kt
index 5cce9d3b1a..fbe223ec13 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/EmptyMangaRepository.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/EmptyMangaRepository.kt
@@ -23,19 +23,19 @@ open class EmptyMangaRepository(override val source: MangaSource) : MangaReposit
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
- override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = stub(null)
+ override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = stub()
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
- override suspend fun getPages(chapter: MangaChapter): List = stub(null)
+ override suspend fun getPages(chapter: MangaChapter): List = stub()
- override suspend fun getPageUrl(page: MangaPage): String = stub(null)
+ override suspend fun getPageUrl(page: MangaPage): String = stub()
- override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
+ override suspend fun getFilterOptions(): MangaListFilterOptions = stub()
override suspend fun getRelated(seed: Manga): List = stub(seed)
- private fun stub(manga: Manga?): Nothing {
- throw UnsupportedSourceException("This manga source is not supported", manga)
+ private fun stub(manga: Manga? = null): Nothing {
+ throw UnsupportedSourceException("This manga source is not supported: ${source.name}", manga, source)
}
}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt
index be2884495b..c05d4a114d 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt
@@ -12,6 +12,9 @@ import io.github.landwarderer.futon.core.model.UnknownMangaSource
import io.github.landwarderer.futon.core.parser.external.ExternalMangaRepository
import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource
import io.github.landwarderer.futon.local.data.LocalMangaRepository
+import io.github.landwarderer.futon.mihon.MihonExtensionManager
+import io.github.landwarderer.futon.mihon.MihonMangaRepository
+import io.github.landwarderer.futon.mihon.model.MihonMangaSource
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -60,6 +63,7 @@ interface MangaRepository {
private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache,
private val mirrorSwitcher: MirrorSwitcher,
+ private val mihonExtensionManager: MihonExtensionManager,
) {
private val cache = ArrayMap>()
@@ -106,7 +110,22 @@ interface MangaRepository {
EmptyMangaRepository(source)
}
- else -> null
+ is MihonMangaSource -> MihonMangaRepository(
+ source = source,
+ cache = contentCache,
+ )
+
+ else -> {
+ if (source.name.startsWith("mihon:") || source.name.startsWith("MIHON_")) {
+ mihonExtensionManager.getMihonMangaSourceByName(source.name)?.let {
+ return MihonMangaRepository(
+ source = it,
+ cache = contentCache,
+ )
+ }
+ }
+ null
+ }
}
}
}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt
index 5ee94eea35..1978c76d44 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/favicon/FaviconFetcher.kt
@@ -33,13 +33,14 @@ import io.github.landwarderer.futon.core.util.ext.toMimeTypeOrNull
import io.github.landwarderer.futon.local.data.FaviconCache
import io.github.landwarderer.futon.local.data.LocalMangaRepository
import io.github.landwarderer.futon.local.data.LocalStorageCache
-import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import io.github.landwarderer.futon.mihon.MihonMangaRepository
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okio.FileSystem
import okio.IOException
import okio.Path.Companion.toOkioPath
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
import coil3.Uri as CoilUri
@@ -65,6 +66,7 @@ class FaviconFetcher(
)
is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options)
+ is MihonMangaRepository -> fetchMihonIcon(repo)
else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}")
}
@@ -125,6 +127,25 @@ class FaviconFetcher(
)
}
+ private suspend fun fetchMihonIcon(repository: MihonMangaRepository): FetchResult {
+ val source = repository.source
+ val pm = options.context.packageManager
+ val icon = runInterruptible {
+ try {
+ pm.getApplicationIcon(source.pkgName)
+ } catch (e: Exception) {
+ e.printStackTraceDebug("FaviconFetcher::fetchMihonIcon")
+ // Fallback to generic icon if extension icon not found
+ pm.getApplicationIcon("com.android.packageinstaller")
+ }
+ }
+ return ImageFetchResult(
+ image = icon.nonAdaptive().asImage(),
+ isSampled = false,
+ dataSource = DataSource.DISK,
+ )
+ }
+
private suspend fun writeToCache(key: String, result: FetchResult): FetchResult = runCatchingCancellable {
when (result) {
is ImageFetchResult -> {
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt
index 4f7bd95e7a..2a365d52dc 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt
@@ -15,11 +15,6 @@ import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.onStart
import io.github.landwarderer.futon.R
import io.github.landwarderer.futon.core.model.ZoomMode
import io.github.landwarderer.futon.core.network.DoHProvider
@@ -32,12 +27,17 @@ import io.github.landwarderer.futon.core.util.ext.takeIfReadable
import io.github.landwarderer.futon.core.util.ext.toUriOrNull
import io.github.landwarderer.futon.explore.data.SourcesSortOrder
import io.github.landwarderer.futon.list.domain.ListSortOrder
+import io.github.landwarderer.futon.reader.domain.ReaderColorFilter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
-import io.github.landwarderer.futon.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
import java.util.EnumSet
@@ -584,6 +584,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_CRASH_ANALYTICS_ENABLED, false)
set(value) = prefs.edit { putBoolean(KEY_CRASH_ANALYTICS_ENABLED, value) }
+ var gitHubMirror: GitHubMirror
+ get() = prefs.getEnumValue(KEY_GITHUB_MIRROR, GitHubMirror.KEIYOUSHI)
+ set(value) = prefs.edit { putEnumValue(KEY_GITHUB_MIRROR, value) }
+
val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
@@ -797,6 +801,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
+ const val KEY_EXTENSION_DOWNLOADER = "extension_downloader"
const val KEY_CF_BRIGHTNESS = "cf_brightness"
const val KEY_CF_CONTRAST = "cf_contrast"
const val KEY_CF_INVERTED = "cf_inverted"
@@ -822,6 +827,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_DISCORD_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw"
const val KEY_DISCORD_TOKEN = "discord_token"
const val KEY_CRASH_ANALYTICS_ENABLED = "crash_analytics_enabled"
+ const val KEY_GITHUB_MIRROR = "github_mirror"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/GitHubMirror.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/GitHubMirror.kt
new file mode 100644
index 0000000000..bce06fbaff
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/GitHubMirror.kt
@@ -0,0 +1,8 @@
+package io.github.landwarderer.futon.core.prefs
+
+import androidx.annotation.Keep
+
+@Keep
+enum class GitHubMirror {
+ NATIVE, KKGITHUB, GHPROXY, GHPROXY_NET, KEIYOUSHI;
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt
index 88df97bf46..9d141ffe28 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt
@@ -6,16 +6,6 @@ import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import androidx.room.withTransaction
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.channels.trySendBlocking
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onStart
import io.github.landwarderer.futon.BuildConfig
import io.github.landwarderer.futon.core.LocalizedAppContext
import io.github.landwarderer.futon.core.db.MangaDatabase
@@ -23,12 +13,24 @@ import io.github.landwarderer.futon.core.db.dao.MangaSourcesDao
import io.github.landwarderer.futon.core.db.entity.MangaSourceEntity
import io.github.landwarderer.futon.core.model.MangaSourceInfo
import io.github.landwarderer.futon.core.model.getTitle
+import io.github.landwarderer.futon.core.model.isBroken
import io.github.landwarderer.futon.core.model.isNsfw
import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource
import io.github.landwarderer.futon.core.prefs.AppSettings
import io.github.landwarderer.futon.core.prefs.observeAsFlow
import io.github.landwarderer.futon.core.ui.util.ReversibleHandle
import io.github.landwarderer.futon.core.util.ext.flattenLatest
+import io.github.landwarderer.futon.mihon.MihonExtensionManager
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -43,9 +45,10 @@ import javax.inject.Singleton
@Singleton
class MangaSourcesRepository @Inject constructor(
- @LocalizedAppContext private val context: Context,
- private val db: MangaDatabase,
- private val settings: AppSettings,
+ @LocalizedAppContext private val context: Context,
+ private val db: MangaDatabase,
+ private val settings: AppSettings,
+ private val mihonExtensionManager: MihonExtensionManager,
) {
private val isNewSourcesAssimilated = AtomicBoolean(false)
@@ -106,7 +109,7 @@ class MangaSourcesRepository @Inject constructor(
query: String?,
locale: String?,
sortOrder: SourcesSortOrder?,
- ): List {
+ ): List {
assimilateNewSources()
val entities = dao.findAll().toMutableList()
if (isDisabledOnly && !settings.isAllSourcesEnabled) {
@@ -119,16 +122,55 @@ class MangaSourcesRepository @Inject constructor(
skipNsfwSources = settings.isNsfwContentDisabled,
sortOrder = sortOrder,
).run {
- mapNotNullTo(ArrayList(size)) { it.mangaSource as? MangaParserSource }
+ mapTo(ArrayList(size)) { it.mangaSource }
+ }
+
+ if (isDisabledOnly) {
+ val external = getExternalSources()
+ // For now, we assume external sources are always "enabled" in the sense of being present,
+ // but if they are not in the database, they are "new" to the app.
+ // Actually, let's just add them if they match the query.
+ sources.addAll(external)
}
+
if (locale != null) {
- sources.retainAll { it.locale == locale }
+ sources.retainAll {
+ when (it) {
+ is MangaParserSource -> it.locale == locale
+ is io.github.landwarderer.futon.mihon.parsers.model.ContentSource -> it.locale == locale
+ else -> true
+ }
+ }
}
if (excludeBroken) {
- sources.removeAll { it.isBroken }
+ sources.removeAll { (it as? MangaParserSource)?.isBroken == true }
}
if (types.isNotEmpty()) {
- sources.retainAll { it.contentType in types }
+ sources.retainAll {
+ when (it) {
+ is MangaParserSource -> it.contentType in types
+ is io.github.landwarderer.futon.mihon.model.MihonMangaSource -> {
+ val mihonType = it.contentType
+ types.any { kotatsuType ->
+ when (kotatsuType) {
+ org.koitharu.kotatsu.parsers.model.ContentType.MANGA -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANGA
+ org.koitharu.kotatsu.parsers.model.ContentType.HENTAI -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.HENTAI_MANGA
+ org.koitharu.kotatsu.parsers.model.ContentType.COMICS -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.COMICS
+ org.koitharu.kotatsu.parsers.model.ContentType.MANHWA -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANHWA
+ org.koitharu.kotatsu.parsers.model.ContentType.MANHUA -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANHUA
+ org.koitharu.kotatsu.parsers.model.ContentType.NOVEL -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.NOVEL
+ org.koitharu.kotatsu.parsers.model.ContentType.ONE_SHOT -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.ONE_SHOT
+ org.koitharu.kotatsu.parsers.model.ContentType.DOUJINSHI -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.DOUJINSHI
+ org.koitharu.kotatsu.parsers.model.ContentType.IMAGE_SET -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.IMAGE_SET
+ org.koitharu.kotatsu.parsers.model.ContentType.ARTIST_CG -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.ARTIST_CG
+ org.koitharu.kotatsu.parsers.model.ContentType.GAME_CG -> mihonType == io.github.landwarderer.futon.mihon.parsers.model.ContentType.GAME_CG
+ else -> false
+ }
+ }
+ }
+ else -> true
+ }
+ }
}
if (!query.isNullOrEmpty()) {
sources.retainAll {
@@ -305,7 +347,9 @@ class MangaSourcesRepository @Inject constructor(
private suspend fun getNewSources(): MutableSet {
val entities = dao.findAll()
- val result = EnumSet.copyOf(allMangaSources)
+ val result = HashSet()
+ result.addAll(MangaParserSource.entries)
+ result.addAll(mihonExtensionManager.getMihonMangaSources())
for (e in entities) {
result.remove(e.source.toMangaSourceOrNull() ?: continue)
}
@@ -324,8 +368,8 @@ class MangaSourcesRepository @Inject constructor(
}
}
- private fun observeExternalSources(): Flow> {
- return callbackFlow {
+ private fun observeExternalSources(): Flow> {
+ val packageChanges = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
trySendBlocking(intent)
@@ -347,19 +391,29 @@ class MangaSourcesRepository @Inject constructor(
awaitClose { context.unregisterReceiver(receiver) }
}.onStart {
emit(null)
- }.map {
+ }
+
+ return combine(
+ packageChanges,
+ mihonExtensionManager.installedExtensions,
+ mihonExtensionManager.failedExtensions,
+ ) { _, _, _ ->
getExternalSources()
}.distinctUntilChanged()
.conflate()
}
- fun getExternalSources(): List = context.packageManager.queryIntentContentProviders(
- Intent("app.futon.parser.PROVIDE_MANGA"), 0,
- ).map { resolveInfo ->
- ExternalMangaSource(
- packageName = resolveInfo.providerInfo.packageName,
- authority = resolveInfo.providerInfo.authority,
- )
+ fun getExternalSources(): List {
+ val external = context.packageManager.queryIntentContentProviders(
+ Intent("app.futon.parser.PROVIDE_MANGA"), 0,
+ ).map { resolveInfo ->
+ ExternalMangaSource(
+ packageName = resolveInfo.providerInfo.packageName,
+ authority = resolveInfo.providerInfo.authority,
+ )
+ }
+ val mihon = mihonExtensionManager.getMihonMangaSources()
+ return external + mihon
}
private fun List.toSources(
@@ -373,7 +427,10 @@ class MangaSourcesRepository @Inject constructor(
if (skipNsfwSources && source.isNsfw()) {
continue
}
- if (source in allMangaSources) {
+ if (source.isBroken) {
+ continue
+ }
+ if (source is MangaParserSource || source.name.startsWith("mihon:") || source.name.startsWith("MIHON_")) {
result.add(
MangaSourceInfo(
mangaSource = source,
@@ -401,5 +458,10 @@ class MangaSourcesRepository @Inject constructor(
isAllSourcesEnabled
}
- private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
+ private fun String.toMangaSourceOrNull(): MangaSource? {
+ if (startsWith("mihon:") || startsWith("MIHON_")) {
+ return mihonExtensionManager.getMihonMangaSourceByName(this) ?: io.github.landwarderer.futon.core.model.MangaSource(this)
+ }
+ return MangaParserSource.entries.find { it.name == this }
+ }
}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt
index dd9c5c1ba5..23aca97250 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt
@@ -126,13 +126,16 @@ fun exploreSourceGridItemAD(
bind {
val title = item.source.getTitle(context)
+ val summary = item.source.getSummary(context)
itemView.setTooltipCompat(
buildSpannedString {
bold {
append(title)
}
- appendLine()
- append(item.source.getSummary(context))
+ if (summary != null) {
+ appendLine()
+ append(summary)
+ }
},
)
binding.textViewTitle.text = title
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt
index db393808fc..ddd9f3d4a8 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt
@@ -34,4 +34,5 @@ enum class ListItemType {
NAV_ITEM,
CHAPTER_LIST,
CHAPTER_GRID,
+ EXTENSION,
}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt
index 1150520d84..eae67590dc 100644
--- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt
@@ -41,7 +41,7 @@ class TypedListSpacingDecoration(
ListItemType.MANGA_SCROBBLING,
ListItemType.MANGA_LIST,
-> outRect.set(0)
-
+ ListItemType.EXTENSION,
ListItemType.DOWNLOAD,
ListItemType.HINT_EMPTY,
ListItemType.MANGA_LIST_DETAILED,
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt
new file mode 100644
index 0000000000..584d3c7993
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt
@@ -0,0 +1,68 @@
+package io.github.landwarderer.futon.mihon
+
+import android.util.Log
+import dalvik.system.PathClassLoader
+
+/**
+ * A ClassLoader that loads classes from its own path before delegating to its parent.
+ *
+ * This is necessary for Mihon extensions because they may bundle different versions
+ * of libraries than App uses, and we need to isolate them.
+ */
+class ChildFirstPathClassLoader(
+ dexPath: String,
+ librarySearchPath: String?,
+ parent: ClassLoader,
+) : PathClassLoader(dexPath, librarySearchPath, parent) {
+
+ /**
+ * List of packages that should always be loaded from the parent ClassLoader.
+ * These are core Android/Kotlin classes and Mihon API classes that must be shared.
+ */
+ private val parentPackages = setOf(
+ "java.",
+ "javax.",
+ "kotlin.",
+ "kotlinx.",
+ "android.",
+ "androidx.",
+ "org.json.",
+ "org.jsoup.",
+ "okhttp3.",
+ "okio.",
+ "rx.",
+ "eu.kanade.tachiyomi.source.",
+ "eu.kanade.tachiyomi.network.",
+ "eu.kanade.tachiyomi.util.",
+ "uy.kohesive.injekt.",
+ "ireader.core.",
+ "io.ktor.",
+ "com.fleeksoft.",
+ )
+
+ override fun loadClass(name: String, resolve: Boolean): Class<*> {
+ // Check if we should delegate to parent immediately
+ if (parentPackages.any { name.startsWith(it) }) {
+ try {
+ return parent.loadClass(name)
+ } catch (e: ClassNotFoundException) {
+ // fall through to child loading
+ }
+ }
+
+ // Try to find the class in our own path first
+ return try {
+ findLoadedClass(name) ?: findClass(name)
+ } catch (e: ClassNotFoundException) {
+ // Fall back to parent ClassLoader
+ try {
+ parent.loadClass(name)
+ } catch (e2: ClassNotFoundException) {
+ if (name.contains("tachiyomi")) {
+ Log.w("ChildFirstLoader", "Class not found: $name")
+ }
+ throw e2
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/GetMihonSourcesUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/GetMihonSourcesUseCase.kt
new file mode 100644
index 0000000000..a0fc2e513e
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/GetMihonSourcesUseCase.kt
@@ -0,0 +1,101 @@
+package io.github.landwarderer.futon.mihon
+
+import io.github.landwarderer.futon.mihon.model.MihonMangaSource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Use case for getting Mihon sources to display in the UI.
+ */
+@Singleton
+class GetMihonSourcesUseCase @Inject constructor(
+ private val extensionManager: MihonExtensionManager,
+ private val settings: io.github.landwarderer.futon.core.prefs.AppSettings,
+) {
+
+ fun getSourcesFlow(): Flow> {
+ return extensionManager.installedExtensions.map { extensions ->
+ val allSources = extensions.flatMap { ext ->
+ ext.catalogueSources.map { catalogueSource ->
+ Triple(ext, catalogueSource, catalogueSource.name)
+ }
+ }
+
+ val nameCountMap = allSources.groupBy { it.third }.mapValues { it.value.size }
+
+ allSources.map { (ext, catalogueSource, baseName) ->
+ val needsLanguageSuffix = nameCountMap[baseName]?.let { it > 1 } ?: false
+
+ MihonSourceItem(
+ source = MihonMangaSource(
+ catalogueSource = catalogueSource,
+ pkgName = ext.pkgName,
+ isNsfw = ext.isNsfw,
+ ),
+ extensionName = ext.appName,
+ versionName = ext.versionName,
+ hasLanguageSuffix = needsLanguageSuffix,
+ )
+ }
+ }
+ }
+
+ fun getSourcesFlowFiltered(userLanguages: Set): Flow> {
+ return getSourcesFlow()
+ }
+
+ fun getSourcesByLanguage(): Map> {
+ return extensionManager.getSourcesByLanguage().mapValues { (_, sources) ->
+ sources.map { catalogueSource ->
+ val ext = extensionManager.installedExtensions.value.find {
+ it.sources.contains(catalogueSource)
+ }
+ MihonMangaSource(
+ catalogueSource = catalogueSource,
+ pkgName = ext?.pkgName ?: "",
+ isNsfw = ext?.isNsfw ?: false,
+ )
+ }
+ }
+ }
+
+ fun hasExtensions(): Boolean = extensionManager.hasExtensions()
+
+ fun isLoading(): Flow = extensionManager.isLoading
+}
+
+data class MihonSourceItem(
+ val source: MihonMangaSource,
+ val extensionName: String,
+ val versionName: String,
+ val hasLanguageSuffix: Boolean = false,
+) {
+ val displayName: String get() {
+ return if (hasLanguageSuffix) {
+ "${source.displayName} (${getLanguageDisplayName(language)})"
+ } else {
+ source.displayName
+ }
+ }
+
+ val language: String get() = source.language
+ val isNsfw: Boolean get() = source.isNsfw
+ val sourceId: Long get() = source.sourceId
+
+ companion object {
+ private fun getLanguageDisplayName(langCode: String): String {
+ return when (langCode.lowercase()) {
+ "zh" -> "䏿–‡"
+ "zh-hans" -> "ç®€ä½“ä¸æ–‡"
+ "zh-hant" -> "ç¹é«”䏿–‡"
+ "en" -> "English"
+ "ja" -> "日本語"
+ "ko" -> "한êµì–´"
+ "all" -> "Multi"
+ else -> langCode.uppercase()
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionLoader.kt
new file mode 100644
index 0000000000..7a16079b9e
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionLoader.kt
@@ -0,0 +1,343 @@
+package io.github.landwarderer.futon.mihon
+
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.util.Log
+import androidx.core.content.pm.PackageInfoCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceFactory
+import io.github.landwarderer.futon.mihon.compat.MihonInjektBridge
+import io.github.landwarderer.futon.mihon.extensions.runtime.ExternalExtensionLoaderSupport
+import io.github.landwarderer.futon.mihon.extensions.runtime.ExternalExtensionMetadataSupport
+import io.github.landwarderer.futon.mihon.extensions.runtime.ExternalExtensionSourceLoaderSupport
+import io.github.landwarderer.futon.mihon.model.MihonExtensionInfo
+import io.github.landwarderer.futon.mihon.model.MihonLoadResult
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Loader for Mihon extension APKs.
+ *
+ * Scans for installed Mihon extensions and loads their Source implementations.
+ */
+@Singleton
+class MihonExtensionLoader @Inject constructor(
+ @ApplicationContext private val applicationContext: Context,
+ private val injektBridge: dagger.Lazy,
+) {
+ companion object {
+ private const val TAG = "MihonExtensionLoader"
+
+ // Feature that marks an APK as a Mihon/Tachiyomi extension
+ private const val EXTENSION_FEATURE = "tachiyomi.extension"
+
+ // Metadata keys in AndroidManifest.xml
+ private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
+ private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
+ private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
+
+ // Supported library version range
+ const val LIB_VERSION_MIN = 1.2
+ const val LIB_VERSION_MAX = 1.9
+
+ }
+
+ /**
+ * Load all installed Mihon extensions.
+ *
+ * @param context Android context
+ * @return List of load results (success, error, or untrusted)
+ */
+ suspend fun loadExtensions(context: Context): List = withContext(Dispatchers.IO) {
+ try {
+ Log.d(TAG, "Starting Mihon extension loading...")
+ // Ensure Injekt is initialized before loading any extensions
+ injektBridge.get().initialize()
+
+ val pkgManager = context.packageManager
+
+ // Get all installed packages
+ val installedPkgs = ExternalExtensionLoaderSupport.getInstalledPackages(pkgManager)
+ Log.d(TAG, "Scanning ${installedPkgs.size} packages...")
+
+ // Filter to only extension packages
+ val extPkgs = installedPkgs.filter { pkg: PackageInfo ->
+ val pkgName = pkg.packageName
+
+ // First filter by name to avoid refreshing all apps
+ if (!ExternalExtensionLoaderSupport.looksLikeMihonPackage(pkgName)) {
+ return@filter false
+ }
+
+ Log.d(TAG, "Potential extension found: $pkgName. Refreshing info...")
+
+ // Refresh to ensure we have metadata and features
+ val completePkg = ExternalExtensionLoaderSupport.refreshPackageInfoIfNeeded(pkgManager, pkg)
+ val isExt = isPackageAnExtension(completePkg)
+
+ Log.d(TAG, "Package $pkgName: isExt=$isExt")
+ isExt
+ }
+
+ if (extPkgs.isEmpty()) {
+ Log.d(TAG, "No Mihon extensions found")
+ return@withContext emptyList()
+ }
+
+ Log.i(TAG, "Found ${extPkgs.size} Mihon extension(s) to load")
+
+ // Load extensions in parallel
+ extPkgs.map { pkgInfo: PackageInfo ->
+ async {
+ try {
+ // Re-fetch full info for loading to be safe
+ val completePkg = ExternalExtensionLoaderSupport.refreshPackageInfoIfNeeded(pkgManager, pkgInfo)
+ loadExtension(context, completePkg)
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to load extension ${pkgInfo.packageName}", e)
+ MihonLoadResult.Error(pkgInfo.packageName, "Exception: ${e.message}", e)
+ }
+ }
+ }.awaitAll()
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to load extensions", e)
+ emptyList()
+ }
+ }
+
+ /**
+ * Load a single Mihon extension by package name.
+ */
+ suspend fun loadExtension(context: Context, packageName: String): MihonLoadResult? = withContext(Dispatchers.IO) {
+ injektBridge.get().initialize()
+
+ val pkgManager = context.packageManager
+ val pkgInfo = ExternalExtensionLoaderSupport.getPackageInfoOrNull(pkgManager, packageName)
+ ?: return@withContext null
+
+ if (!isPackageAnExtension(pkgInfo)) {
+ return@withContext null
+ }
+
+ loadExtension(context, pkgInfo)
+ }
+
+ /**
+ * Get list of installed Mihon extensions (metadata only, without loading).
+ */
+ fun getInstalledExtensions(context: Context): List {
+ val pkgManager = context.packageManager
+ val installedPkgs = ExternalExtensionLoaderSupport.getInstalledPackages(pkgManager)
+
+ return installedPkgs
+ .filter { ExternalExtensionLoaderSupport.looksLikeMihonPackage(it.packageName) }
+ .map { ExternalExtensionLoaderSupport.refreshPackageInfoIfNeeded(pkgManager, it) }
+ .filter { isPackageAnExtension(it) }
+ .mapNotNull { extractExtensionInfo(it) }
+ }
+
+ private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
+ val pkgName = pkgInfo.packageName
+
+ // Method 1: Check for explicit feature declaration
+ val hasFeature = pkgInfo.reqFeatures?.any { it.name == EXTENSION_FEATURE } == true
+
+ // Method 2: Check for package naming convention
+ val hasPackageName = ExternalExtensionLoaderSupport.looksLikeMihonPackage(pkgName)
+
+ // Method 3: Check for metadata in application info
+ val hasMetaData = ExternalExtensionMetadataSupport.hasDeclaredSource(
+ metaData = pkgInfo.applicationInfo?.metaData,
+ sourceClassKey = METADATA_SOURCE_CLASS,
+ sourceFactoryKey = METADATA_SOURCE_FACTORY,
+ )
+
+ // A package is an extension if it has the feature OR (has the correct name prefix AND has metadata)
+ val isExtension = hasFeature || (hasPackageName && hasMetaData)
+
+ if (hasPackageName && !isExtension) {
+ Log.w(TAG, "Package $pkgName looks like an extension but lacks feature and metadata")
+ }
+
+ return isExtension
+ }
+
+ private fun extractExtensionInfo(pkgInfo: PackageInfo): MihonExtensionInfo? {
+ val completePkgInfo = pkgInfo
+ val pkgName = completePkgInfo.packageName
+ val appInfo = completePkgInfo.applicationInfo ?: run {
+ Log.w(TAG, "extractExtensionInfo($pkgName): skipped because applicationInfo is null")
+ return null
+ }
+ val metaData = ExternalExtensionMetadataSupport.getMetaDataOrNull(appInfo) ?: run {
+ Log.w(TAG, "extractExtensionInfo($pkgName): skipped because metaData is null")
+ return null
+ }
+
+ val versionName = completePkgInfo.versionName ?: run {
+ Log.w(TAG, "extractExtensionInfo($pkgName): skipped because versionName is null")
+ return null
+ }
+
+ // Extract library version - handles different version formats
+ val libVersion = try {
+ versionName.split('.').let { parts ->
+ if (parts.size >= 2) "${parts[0]}.${parts[1]}".toDouble()
+ else parts[0].toDouble()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "extractExtensionInfo($pkgName): Failed to parse libVersion from $versionName, defaulting to 1.4")
+ 1.4 // Default to 1.4 if parsing fails
+ }
+
+ val declaredSource = ExternalExtensionMetadataSupport.getDeclaredSourceMetadataOrNull(
+ metaData = metaData,
+ sourceClassKey = METADATA_SOURCE_CLASS,
+ sourceFactoryKey = METADATA_SOURCE_FACTORY,
+ nsfwKey = METADATA_NSFW,
+ ) ?: run {
+ Log.w(TAG, "extractExtensionInfo($pkgName): skipped because no declaredSource could be parsed. Keys present in manifest: ${metaData.keySet()?.joinToString()}")
+ return null
+ }
+
+ // Get app name safely
+ val appName = try {
+ ExternalExtensionLoaderSupport.getAppLabel(applicationContext, appInfo)
+ } catch (e: Exception) {
+ null
+ } ?: pkgInfo.packageName.substringAfterLast('.')
+
+ val lang = ExternalExtensionLoaderSupport.extractLanguage(completePkgInfo.packageName, "extension")
+
+ return MihonExtensionInfo(
+ pkgName = completePkgInfo.packageName,
+ appName = appName,
+ versionCode = PackageInfoCompat.getLongVersionCode(completePkgInfo),
+ versionName = versionName,
+ libVersion = libVersion,
+ lang = lang,
+ isNsfw = declaredSource.isNsfw,
+ sourceClassName = declaredSource.sourceClassName,
+ apkPath = appInfo.sourceDir ?: return null,
+ )
+ }
+
+ private fun loadExtension(context: Context, pkgInfo: PackageInfo): MihonLoadResult {
+ val pkgName = pkgInfo.packageName
+ val appInfo = pkgInfo.applicationInfo
+ ?: run {
+ Log.e(TAG, "loadExtension($pkgName) FAILED: No ApplicationInfo")
+ return MihonLoadResult.Error(pkgName, "No ApplicationInfo")
+ }
+
+ val versionName = pkgInfo.versionName
+ ?: run {
+ Log.e(TAG, "loadExtension($pkgName) FAILED: No version name")
+ return MihonLoadResult.Error(pkgName, "No version name")
+ }
+ val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
+
+ // Extract library version
+ val libVersion = try {
+ versionName.split('.').let { parts ->
+ if (parts.size >= 2) "${parts[0]}.${parts[1]}".toDouble()
+ else parts[0].toDouble()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "loadExtension($pkgName) FAILED: Invalid lib version format ($versionName)")
+ return MihonLoadResult.Error(pkgName, "Invalid lib version format: $versionName")
+ }
+
+ // Check library version compatibility (more relaxed check)
+ if (libVersion < LIB_VERSION_MIN) {
+ val err = "Extension lib version too old: $libVersion (min: $LIB_VERSION_MIN)"
+ Log.e(TAG, "loadExtension($pkgName) FAILED: $err")
+ return MihonLoadResult.Error(pkgName, err)
+ }
+
+ val metaData = ExternalExtensionMetadataSupport.getMetaDataOrNull(appInfo)
+ ?: run {
+ Log.e(TAG, "loadExtension($pkgName) FAILED: No meta-data in manifest")
+ return MihonLoadResult.Error(pkgName, "No meta-data in manifest")
+ }
+
+ // Get source class name(s)
+ val declaredSource = ExternalExtensionMetadataSupport.getDeclaredSourceMetadataOrNull(
+ metaData = metaData,
+ sourceClassKey = METADATA_SOURCE_CLASS,
+ sourceFactoryKey = METADATA_SOURCE_FACTORY,
+ nsfwKey = METADATA_NSFW,
+ ) ?: run {
+ Log.e(TAG, "loadExtension($pkgName) FAILED: No valid source class specified in manifest")
+ return MihonLoadResult.Error(pkgName, "No source class specified in manifest")
+ }
+
+ // Get app name and language
+ val appName = try { ExternalExtensionLoaderSupport.getAppLabel(context, appInfo) } catch (e: Exception) { null }
+ val lang = ExternalExtensionLoaderSupport.extractLanguage(pkgName, "extension")
+
+ Log.d(TAG, "Loading extension: $pkgName (lib $libVersion, $lang) - Name: $appName")
+
+ // Create ClassLoader for this extension
+ val classLoader = try {
+ Log.d(TAG, "Creating ClassLoader for $pkgName with sourceDir: ${appInfo.sourceDir}")
+ ChildFirstPathClassLoader(
+ appInfo.sourceDir,
+ appInfo.nativeLibraryDir,
+ context.classLoader
+ )
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to create ClassLoader for $pkgName", e)
+ return MihonLoadResult.Error(pkgName, "Failed to create ClassLoader", e)
+ }
+
+ // Load source classes
+ val sources = try {
+ loadSources(pkgName, declaredSource.sourceClassName, classLoader)
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to load sources from $pkgName", e)
+ return MihonLoadResult.Error(pkgName, "Failed to load sources: ${e.message}", e)
+ }
+
+ if (sources.isEmpty()) {
+ Log.e(TAG, "No sources loaded from $pkgName")
+ return MihonLoadResult.Error(pkgName, "No sources loaded from extension")
+ } else {
+ Log.i(TAG, "Successfully loaded ${sources.size} source(s) from $pkgName")
+ }
+
+ return MihonLoadResult.Success(
+ pkgName = pkgName,
+ appName = appName ?: "Unknown",
+ versionCode = versionCode,
+ versionName = versionName,
+ libVersion = libVersion,
+ lang = lang,
+ isNsfw = declaredSource.isNsfw,
+ sources = sources,
+ )
+ }
+
+ private fun loadSources(
+ pkgName: String,
+ sourceClassNames: String,
+ classLoader: ClassLoader,
+ ): List {
+ return ExternalExtensionSourceLoaderSupport.loadSources(
+ pkgName = pkgName,
+ sourceClassNames = sourceClassNames,
+ classLoader = classLoader,
+ asSource = { it as? Source },
+ createSourcesFromFactory = { (it as? SourceFactory)?.createSources() },
+ onUnknownInstance = { className ->
+ Log.w(TAG, "Unknown instance type in $pkgName: $className")
+ },
+ )
+ }
+
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionManager.kt
new file mode 100644
index 0000000000..983d7b2bba
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionManager.kt
@@ -0,0 +1,150 @@
+package io.github.landwarderer.futon.mihon
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.Source
+import io.github.landwarderer.futon.mihon.extensions.runtime.ExternalExtensionManagerFacade
+import io.github.landwarderer.futon.mihon.model.MihonLoadResult
+import io.github.landwarderer.futon.mihon.model.MihonMangaSource
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Manager for Mihon extensions.
+ *
+ * Handles loading, caching, and providing access to Mihon extension sources.
+ */
+@Singleton
+class MihonExtensionManager @Inject constructor(
+ @get:ApplicationContext private val context: Context,
+ private val loader: MihonExtensionLoader,
+) {
+ companion object {
+ private const val TAG = "MihonExtensionManager"
+ }
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private val facade = ExternalExtensionManagerFacade<
+ MihonLoadResult,
+ MihonLoadResult.Success,
+ MihonLoadResult.Error,
+ Source,
+ CatalogueSource,
+ MihonMangaSource,
+ >(
+ context = context,
+ scope = scope,
+ logTag = TAG,
+ ecosystem = "mihon",
+ sourceNamePrefix = "MIHON_",
+ loadResults = loader::loadExtensions,
+ successOf = { it as? MihonLoadResult.Success },
+ errorOf = { it as? MihonLoadResult.Error },
+ untrustedPackageNameOf = { (it as? MihonLoadResult.Untrusted)?.pkgName },
+ successSources = { it.sources },
+ successPackageName = { it.pkgName },
+ successIsNsfw = { it.isNsfw },
+ successCatalogueSources = { it.catalogueSources },
+ sourceId = { it.id },
+ asCatalogueSource = { it as? CatalogueSource },
+ catalogueSourceName = { it.name },
+ catalogueSourceLang = { it.lang },
+ buildWrappedSource = { catalogueSource, pkgName, isNsfw, hasLanguageSuffix ->
+ MihonMangaSource(
+ catalogueSource = catalogueSource,
+ pkgName = pkgName,
+ isNsfw = isNsfw,
+ hasLanguageSuffix = hasLanguageSuffix,
+ )
+ },
+ errorPackageName = { it.pkgName },
+ errorMessage = { it.message },
+ )
+
+ val installedExtensions: StateFlow> = facade.installedExtensions
+ val failedExtensions: StateFlow> = facade.failedExtensions
+ val isLoading: StateFlow = facade.isLoading
+
+ init {
+ initialize()
+ }
+
+ /**
+ * Initialize the extension manager and load all extensions.
+ */
+ fun initialize() {
+ facade.initialize()
+ }
+
+ /**
+ * Reload all extensions.
+ */
+ suspend fun loadExtensions() {
+ facade.loadExtensions()
+ }
+
+ /**
+ * Get all available CatalogueSource instances.
+ */
+ fun getCatalogueSources(): List {
+ return facade.getCatalogueSources()
+ }
+
+ /**
+ * Get all MihonMangaSource wrappers.
+ */
+ fun getMihonMangaSources(): List {
+ return facade.getWrappedSources()
+ }
+
+ /**
+ * Get a source by its ID.
+ */
+ fun getSourceById(sourceId: Long): Source? {
+ return facade.getSourceById(sourceId)
+ }
+
+ /**
+ * Get a CatalogueSource by its ID.
+ */
+ fun getCatalogueSourceById(sourceId: Long): CatalogueSource? {
+ return facade.getCatalogueSourceById(sourceId)
+ }
+
+ /**
+ * Get a MihonMangaSource wrapper by source ID.
+ */
+ fun getMihonMangaSourceById(sourceId: Long): MihonMangaSource? {
+ return facade.getWrappedSourceById(sourceId)
+ }
+
+ /**
+ * Get a MihonMangaSource by its name (format: "MIHON_{sourceId}").
+ */
+ fun getMihonMangaSourceByName(name: String): MihonMangaSource? {
+ return facade.getWrappedSourceByName(name)
+ }
+
+ /**
+ * Get sources grouped by language.
+ */
+ fun getSourcesByLanguage(): Map> {
+ return facade.getSourcesByLanguage()
+ }
+
+ /**
+ * Get the number of loaded sources.
+ */
+ fun getSourceCount(): Int = facade.getSourceCount()
+
+ /**
+ * Check if any Mihon extensions are loaded.
+ */
+ fun hasExtensions(): Boolean = facade.hasExtensions()
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonFilterMapper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonFilterMapper.kt
new file mode 100644
index 0000000000..f29fdf64bf
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonFilterMapper.kt
@@ -0,0 +1,212 @@
+
+package io.github.landwarderer.futon.mihon
+
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi
+import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter
+import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilterOptions
+import io.github.landwarderer.futon.mihon.parsers.model.ContentSource
+import io.github.landwarderer.futon.mihon.parsers.model.ContentTag
+import io.github.landwarderer.futon.mihon.parsers.model.ContentTagGroup
+import io.github.landwarderer.futon.mihon.parsers.util.mapToSet
+
+@OptIn(InternalParsersApi::class)
+object MihonFilterMapper {
+
+ private const val TAG = "MihonFilterMapper"
+ private const val PREFIX_TOP = "top:"
+ private const val PREFIX_SORT = "sort:"
+ private const val PREFIX_TEXT = "text:"
+
+ fun mapOptions(mihonFilters: FilterList, source: ContentSource): ContentListFilterOptions {
+ val tagGroups = mutableListOf()
+ var currentHeader = "General"
+
+ mihonFilters.forEachIndexed { index, filter ->
+ when (filter) {
+ is Filter.Header -> {
+ currentHeader = filter.name
+ }
+ is Filter.Separator -> { }
+ is Filter.Group<*> -> {
+ when (val state = filter.state) {
+ is List<*> -> {
+ val checkboxTags = mutableListOf()
+
+ state.forEach { subItem ->
+ if (subItem is Filter<*>) {
+ when (subItem) {
+ is Filter.Select<*> -> {
+ val selectTags = mapFilterToTags(subItem, filter.name, source)
+ if (selectTags.isNotEmpty()) {
+ val groupTitle = "${filter.name} - ${subItem.name}"
+ tagGroups.add(ContentTagGroup(groupTitle, selectTags.toSet()))
+ }
+ }
+ is Filter.Sort -> {
+ val sortTags = mapFilterToTags(subItem, filter.name, source)
+ if (sortTags.isNotEmpty()) {
+ val groupTitle = "${filter.name} - ${subItem.name}"
+ tagGroups.add(ContentTagGroup(groupTitle, sortTags.toSet()))
+ }
+ }
+ is Filter.Group<*> -> {
+ val nestedTags = mapFilterToTags(subItem, filter.name, source)
+ checkboxTags.addAll(nestedTags)
+ }
+ else -> {
+ val tags = mapFilterToTags(subItem, filter.name, source)
+ checkboxTags.addAll(tags)
+ }
+ }
+ }
+ }
+
+ if (checkboxTags.isNotEmpty()) {
+ tagGroups.add(ContentTagGroup(filter.name, checkboxTags.toSet()))
+ }
+ }
+ }
+ }
+ else -> {
+ val tags = mapFilterToTags(filter, null, source)
+ if (tags.isNotEmpty()) {
+ tagGroups.add(ContentTagGroup(currentHeader, tags.toSet()))
+ }
+ }
+ }
+ }
+
+ val mergedGroups = tagGroups.groupBy { it.title }.map { (title, groups) ->
+ val allTags = groups.flatMap { it.tags }.toSet()
+ ContentTagGroup(title, allTags)
+ }
+
+ return ContentListFilterOptions(
+ availableTags = mergedGroups.flatMap { it.tags }.toSet(),
+ tagGroups = mergedGroups
+ )
+ }
+
+ private fun mapFilterToTags(
+ filter: Filter<*>,
+ parentName: String?,
+ source: ContentSource,
+ ): List {
+ val prefix = if (parentName != null) "$parentName/" else PREFIX_TOP
+
+ return when (filter) {
+ is Filter.CheckBox -> {
+ listOf(ContentTag(filter.name, "$prefix${filter.name}", source))
+ }
+ is Filter.TriState -> {
+ listOf(ContentTag(filter.name, "$prefix${filter.name}", source))
+ }
+ is Filter.Select<*> -> {
+ filter.values.map { value ->
+ val title = value.toString()
+ ContentTag(title, "$prefix${filter.name}/$title", source)
+ }
+ }
+ is Filter.Sort -> {
+ filter.values.map { value ->
+ ContentTag(value, "$PREFIX_SORT$prefix${filter.name}/$value", source)
+ }
+ }
+ is Filter.Text -> {
+ listOf(ContentTag(
+ title = "📝 ${filter.name}",
+ key = "$PREFIX_TEXT$prefix${filter.name}",
+ source = source
+ ))
+ }
+ is Filter.Group<*> -> {
+ val nestedTags = mutableListOf()
+ (filter.state as? List<*>)?.forEach { subItem ->
+ if (subItem is Filter<*>) {
+ val nestedPrefix = if (parentName != null) "$parentName/${filter.name}" else filter.name
+ nestedTags.addAll(mapFilterToTags(subItem, nestedPrefix, source))
+ }
+ }
+ nestedTags
+ }
+ else -> emptyList()
+ }
+ }
+
+ fun updateMihonFilters(mihonFilters: FilterList, contentListFilter: ContentListFilter) {
+ val selectedTags = contentListFilter.tags.mapToSet { it.key }
+ val excludedTags = contentListFilter.tagsExclude.mapToSet { it.key }
+
+ mihonFilters.forEach { filter ->
+ when (filter) {
+ is Filter.Group<*> -> {
+ (filter.state as? List<*>)?.forEach { subItem ->
+ val sub = subItem as? Filter<*> ?: return@forEach
+ updateSingleFilter(sub, filter.name, selectedTags, excludedTags)
+ }
+ }
+ else -> {
+ updateSingleFilter(filter, null, selectedTags, excludedTags)
+ }
+ }
+ }
+ }
+
+ private fun updateSingleFilter(filter: Filter<*>, parentName: String?, selectedTags: Set, excludedTags: Set) {
+ val prefix = if (parentName != null) "$parentName/" else PREFIX_TOP
+ when (filter) {
+ is Filter.CheckBox -> {
+ val key = "$prefix${filter.name}"
+ filter.state = key in selectedTags
+ }
+ is Filter.TriState -> {
+ val key = "$prefix${filter.name}"
+ filter.state = when {
+ key in selectedTags -> Filter.TriState.STATE_INCLUDE
+ key in excludedTags -> Filter.TriState.STATE_EXCLUDE
+ else -> Filter.TriState.STATE_IGNORE
+ }
+ }
+ is Filter.Select<*> -> {
+ filter.values.forEachIndexed { index, value ->
+ val key = "$prefix${filter.name}/$value"
+ if (key in selectedTags) {
+ filter.state = index
+ }
+ }
+ }
+ is Filter.Sort -> {
+ filter.values.forEachIndexed { index, value ->
+ val key = "$PREFIX_SORT$prefix${filter.name}/$value"
+ if (key in selectedTags) {
+ filter.state = Filter.Sort.Selection(index, filter.state?.ascending ?: false)
+ }
+ }
+ }
+ is Filter.Text -> {
+ val baseKey = "$PREFIX_TEXT$prefix${filter.name}"
+ val matchingTag = selectedTags.find { it.startsWith(baseKey) }
+ if (matchingTag != null) {
+ val value = if (matchingTag.contains("=")) {
+ matchingTag.substringAfter("=")
+ } else {
+ ""
+ }
+ filter.state = value
+ }
+ }
+ is Filter.Group<*> -> {
+ (filter.state as? List<*>)?.forEach { subItem ->
+ if (subItem is Filter<*>) {
+ val nestedPrefix = if (parentName != null) "$parentName/${filter.name}" else filter.name
+ updateSingleFilter(subItem, nestedPrefix, selectedTags, excludedTags)
+ }
+ }
+ }
+ is Filter.Header, is Filter.Separator -> { }
+ else -> {}
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt
new file mode 100644
index 0000000000..1af8906431
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt
@@ -0,0 +1,357 @@
+package io.github.landwarderer.futon.mihon
+
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.online.HttpSource
+import io.github.landwarderer.futon.core.cache.MemoryContentCache
+import io.github.landwarderer.futon.core.exceptions.CloudFlareException
+import io.github.landwarderer.futon.core.exceptions.InteractiveActionRequiredException
+import io.github.landwarderer.futon.core.parser.CachingMangaRepository
+import io.github.landwarderer.futon.mihon.model.MihonMangaSource
+import io.github.landwarderer.futon.mihon.model.asContentPage
+import io.github.landwarderer.futon.mihon.model.getPublicContentUrl
+import io.github.landwarderer.futon.mihon.model.toContent
+import io.github.landwarderer.futon.mihon.model.toContentChapter
+import io.github.landwarderer.futon.mihon.model.toContentListFilter
+import io.github.landwarderer.futon.mihon.model.toDomainContent
+import io.github.landwarderer.futon.mihon.model.toManga
+import io.github.landwarderer.futon.mihon.model.toMangaListFilterOptions
+import io.github.landwarderer.futon.mihon.model.toMangaPage
+import io.github.landwarderer.futon.mihon.model.toMihonChapter
+import io.github.landwarderer.futon.mihon.model.toMihonManga
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
+import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.SortOrder as ContentSortOrder
+
+/**
+ * Repository that adapts a Mihon CatalogueSource to app's ContentRepository interface.
+ */
+class MihonMangaRepository(
+ override val source: MihonMangaSource,
+ cache: MemoryContentCache,
+) : CachingMangaRepository(cache) {
+
+ companion object {
+ private const val TAG = "MihonMangaRepository"
+
+ private fun extractChapterNumber(name: String): Float {
+ // Try Chinese format: 第X话
+ val chineseRegex = Regex("""第\s*(\d+(?:\.\d+)?)\s*话""")
+ chineseRegex.find(name)?.let {
+ return it.groupValues[1].toFloatOrNull() ?: -1f
+ }
+
+ // Try English format: Chapter X, Ch. X
+ val englishRegex = Regex("""(?:Chapter|Ch\.?)\s*(\d+(?:\.\d+)?)""", RegexOption.IGNORE_CASE)
+ englishRegex.find(name)?.let {
+ return it.groupValues[1].toFloatOrNull() ?: -1f
+ }
+
+ // Try pure number
+ val numberRegex = Regex("""(\d+(?:\.\d+)?)""")
+ numberRegex.find(name)?.let {
+ return it.groupValues[1].toFloatOrNull() ?: -1f
+ }
+
+ return -1f
+ }
+ }
+
+ private var lastOffset = -1
+ private var currentPage = 1
+
+ val mihonSource = source.catalogueSource
+
+ override val sortOrders: Set = buildSet {
+ add(ContentSortOrder.POPULARITY)
+ if (mihonSource.supportsLatest) {
+ add(ContentSortOrder.UPDATED)
+ }
+ }
+
+ override val filterCapabilities: MangaListFilterCapabilities
+ get() = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isMultipleTagsSupported = true,
+ isSearchWithFiltersSupported = true,
+ )
+
+ override var defaultSortOrder: ContentSortOrder = ContentSortOrder.POPULARITY
+
+ override suspend fun getList(
+ offset: Int,
+ order: ContentSortOrder?,
+ filter: MangaListFilter?,
+ ): List = withContext(Dispatchers.IO) {
+ if (offset == 0) {
+ currentPage = 1
+ } else if (offset > lastOffset) {
+ currentPage++
+ }
+ lastOffset = offset
+
+ val page = currentPage
+ val query = filter?.query
+
+ val hasFilters = filter?.let {
+ it.query?.isNotBlank() == true || it.tags.isNotEmpty() || it.tagsExclude.isNotEmpty()
+ } ?: false
+
+ val mangasPage = rethrowMihonWrappedExceptions {
+ when {
+ hasFilters -> {
+ mihonSource.getSearchManga(page, query ?: "", filter?.toMihonFilterList() ?: FilterList())
+ }
+ order == ContentSortOrder.UPDATED && mihonSource.supportsLatest -> {
+ mihonSource.getLatestUpdates(page)
+ }
+ else -> {
+ mihonSource.getPopularManga(page)
+ }
+ }
+ }
+
+ mangasPage.mangas.map { sContent ->
+ sContent.toDomainContent(
+ source = source,
+ publicUrl = (mihonSource as? HttpSource)?.getPublicContentUrl(sContent) ?: "",
+ ).also {
+ android.util.Log.d(TAG, "Mapped to Domain Content: ${it.title}")
+ }.toManga()
+ }
+ }
+
+ override suspend fun getDetailsImpl(manga: Manga): Manga = withContext(Dispatchers.IO) {
+ val content = manga.toContent(source)
+ val sContent = content.toMihonManga()
+
+ val details = try {
+ rethrowMihonWrappedExceptions {
+ mihonSource.getMangaDetails(sContent)
+ }
+ } catch (e: Exception) {
+ val ioException = when {
+ e is java.io.IOException -> e
+ e.cause is java.io.IOException -> e.cause as java.io.IOException
+ else -> null
+ }
+
+ if (ioException != null) {
+ kotlinx.coroutines.delay(500)
+ rethrowMihonWrappedExceptions {
+ mihonSource.getMangaDetails(sContent)
+ }
+ } else {
+ throw e
+ }
+ }
+
+ val rawChapters = try {
+ rethrowMihonWrappedExceptions {
+ mihonSource.getChapterList(sContent)
+ }
+ } catch (e: Exception) {
+ val ioException = when {
+ e is java.io.IOException -> e
+ e.cause is java.io.IOException -> e.cause as java.io.IOException
+ else -> null
+ }
+
+ if (ioException != null) {
+ kotlinx.coroutines.delay(500)
+ rethrowMihonWrappedExceptions {
+ mihonSource.getChapterList(sContent)
+ }
+ } else {
+ throw e
+ }
+ }
+
+ val chapters = rawChapters.asReversed()
+ .mapIndexed { index, sChapter ->
+ val chapterNumber = if (sChapter.chapter_number > 0) {
+ sChapter.chapter_number
+ } else {
+ (index + 1).toFloat()
+ }
+ sChapter.toContentChapter(source, chapterNumber)
+ }
+ .sortedBy { it.number }
+
+ // Copy missing fields from original manga to details
+ details.url = sContent.url
+
+ // Title fallback
+ val detailsTitle = try { details.title } catch (e: Exception) { "" }
+ if (detailsTitle.isBlank()) {
+ details.title = sContent.title
+ }
+
+ // Thumbnail fallback
+ val detailsThumb = try { details.thumbnail_url } catch (e: Exception) { null }
+ val searchThumb = try { sContent.thumbnail_url } catch (e: Exception) { null }
+
+ if (detailsThumb.isNullOrBlank() || detailsThumb == details.url || detailsThumb == sContent.url) {
+ if (!searchThumb.isNullOrBlank()) {
+ details.thumbnail_url = searchThumb
+ }
+ }
+
+ val publicUrl = (mihonSource as? HttpSource)?.getPublicContentUrl(details) ?: ""
+
+ details.toDomainContent(
+ source = source,
+ chapters = chapters,
+ publicUrl = publicUrl,
+ ).copy(id = manga.id).toManga()
+ }
+
+ override suspend fun getPagesImpl(chapter: MangaChapter): List = withContext(Dispatchers.IO) {
+ val contentChapter = chapter.toContentChapter(source)
+ val sChapter = contentChapter.toMihonChapter()
+ val pages = rethrowMihonWrappedExceptions {
+ mihonSource.getPageList(sChapter)
+ }
+
+ pages.mapIndexed { index, page ->
+ if (mihonSource !is HttpSource) {
+ return@mapIndexed page.asContentPage(source, sChapter).toMangaPage()
+ }
+
+ val headers = try {
+ if (!page.imageUrl.isNullOrBlank()) {
+ val h = mihonSource.getPageHeaders(page)
+ val map = mutableMapOf()
+ for (i in 0 until h.size) {
+ map[h.name(i)] = h.value(i)
+ }
+ map
+ } else {
+ emptyMap()
+ }
+ } catch (e: Exception) {
+ emptyMap()
+ }
+
+ page.asContentPage(source, sChapter, headers).let { contentPage ->
+ val updatedPage = if (page.imageUrl.isNullOrBlank() && page.url.isNotBlank()) {
+ contentPage.copy(
+ url = "mihon://resolve?page_url=${java.net.URLEncoder.encode(page.url, "UTF-8")}&index=$index"
+ )
+ } else if (!page.imageUrl.isNullOrBlank() && page.url.isNotBlank() && page.url != page.imageUrl) {
+ contentPage.copy(
+ url = "mihon://image?page_url=${java.net.URLEncoder.encode(page.url, "UTF-8")}&image_url=${java.net.URLEncoder.encode(page.imageUrl!!, "UTF-8")}&index=$index"
+ )
+ } else {
+ contentPage
+ }
+ updatedPage.toMangaPage()
+ }
+ }
+ }
+
+ override suspend fun getPageUrl(page: MangaPage): String = withContext(Dispatchers.IO) {
+ val url = page.url
+
+ if (url.startsWith("mihon://")) {
+ val uri = android.net.Uri.parse(url)
+ if (url.startsWith("mihon://image")) {
+ val imageUrl = uri.getQueryParameter("image_url")
+ if (!imageUrl.isNullOrBlank()) return@withContext imageUrl
+ } else if (url.startsWith("mihon://resolve")) {
+ val pageUrl = uri.getQueryParameter("page_url")
+ if (!pageUrl.isNullOrBlank()) {
+ val mihonPage = Page(0, pageUrl)
+ val httpSource = mihonSource as? HttpSource
+ if (httpSource != null) {
+ return@withContext rethrowMihonWrappedExceptions {
+ httpSource.getImageUrl(mihonPage)
+ }
+ }
+ return@withContext pageUrl
+ }
+ }
+ return@withContext url
+ } else {
+ url
+ }
+ }
+
+ override suspend fun getFilterOptions(): MangaListFilterOptions {
+ val mihonFilters = try {
+ mihonSource.getFilterList()
+ } catch (e: Exception) {
+ FilterList()
+ }
+
+ val options = MihonFilterMapper.mapOptions(mihonFilters, source)
+ return options.toMangaListFilterOptions()
+ }
+
+ private fun MangaListFilter.toMihonFilterList(): FilterList {
+ val mihonFilters = try {
+ mihonSource.getFilterList()
+ } catch (e: Exception) {
+ return FilterList()
+ }
+
+ MihonFilterMapper.updateMihonFilters(mihonFilters, this.toContentListFilter())
+ return mihonFilters
+ }
+
+ fun getRequestHeaders(): Map {
+ val httpSource = mihonSource as? HttpSource ?: return emptyMap()
+ val headers = httpSource.headers
+ val map = mutableMapOf()
+ for (i in 0 until headers.size) {
+ map[headers.name(i)] = headers.value(i)
+ }
+ return map
+ }
+
+ fun getImageClient(): okhttp3.OkHttpClient? {
+ return (mihonSource as? HttpSource)?.client
+ }
+
+ fun createPageRequest(pageUrl: String, page: MangaPage): okhttp3.Request {
+ if (pageUrl.isBlank()) return okhttp3.Request.Builder().url("http://localhost").build() // Dummy
+ val httpSource = mihonSource as? HttpSource ?: return okhttp3.Request.Builder().url(pageUrl).build()
+ val sPage = Page(index = page.id.toInt(), url = pageUrl, imageUrl = pageUrl) // Simplified toMihonPage
+ return httpSource.imageRequest(sPage)
+ }
+
+ fun createCoverRequest(imageUrl: String): okhttp3.Request {
+ val httpSource = mihonSource as? HttpSource ?: return okhttp3.Request.Builder().url(imageUrl).build()
+ return try {
+ val sPage = Page(0, imageUrl = imageUrl)
+ httpSource.imageRequest(sPage)
+ } catch (e: Throwable) {
+ okhttp3.Request.Builder().url(imageUrl).build()
+ }
+ }
+
+ private inline fun rethrowMihonWrappedExceptions(block: () -> T): T {
+ try {
+ return block()
+ } catch (e: RuntimeException) {
+ when (val cause = e.cause) {
+ is CloudFlareException -> throw cause
+ is InteractiveActionRequiredException -> throw cause
+ is java.io.IOException -> throw cause
+ else -> throw e
+ }
+ }
+ }
+
+ override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList()
+
+ suspend fun getFavicons(): org.koitharu.kotatsu.parsers.model.Favicons {
+ return org.koitharu.kotatsu.parsers.model.Favicons(emptyList(), "")
+ }
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonModule.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonModule.kt
new file mode 100644
index 0000000000..b0cad5ec2c
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonModule.kt
@@ -0,0 +1,61 @@
+package io.github.landwarderer.futon.mihon
+
+import android.content.Context
+import android.util.Log
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import io.github.landwarderer.futon.core.network.MangaHttpClient
+import io.github.landwarderer.futon.core.network.webview.WebViewExecutor
+import io.github.landwarderer.futon.mihon.compat.MihonInjektBridge
+import okhttp3.CookieJar
+import okhttp3.OkHttpClient
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object MihonModule {
+ @Provides
+ @Singleton
+ fun provideMihonInjektBridge(
+ @ApplicationContext context: Context,
+ @MangaHttpClient okHttpClient: OkHttpClient,
+ cookieJar: CookieJar,
+ webViewExecutor: WebViewExecutor,
+ ): MihonInjektBridge {
+ return try {
+ MihonInjektBridge(
+ context = context,
+ httpClient = okHttpClient,
+ cookieJar = cookieJar,
+ webViewExecutor = webViewExecutor,
+ )
+ } catch (e: Throwable) {
+ Log.e("MihonModule", "CRITICAL ERROR: Failed to create MihonInjektBridge!", e)
+ // Still need to return something or Dagger will fail.
+ // In case of fatal libs issue (NoClassDefFound), this might still crash later,
+ // but let's try to catch it here.
+ throw e
+ }
+ }
+
+ @Provides
+ @Singleton
+ fun provideMihonExtensionLoader(
+ @ApplicationContext context: Context,
+ injektBridge: dagger.Lazy,
+ ): MihonExtensionLoader {
+ return MihonExtensionLoader(context,injektBridge)
+ }
+
+ @Provides
+ @Singleton
+ fun provideMihonExtensionManager(
+ @ApplicationContext context: Context,
+ loader: MihonExtensionLoader,
+ ): MihonExtensionManager {
+ return MihonExtensionManager(context, loader)
+ }
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonInjektBridge.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonInjektBridge.kt
new file mode 100644
index 0000000000..b7ead80c2f
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonInjektBridge.kt
@@ -0,0 +1,84 @@
+package io.github.landwarderer.futon.mihon.compat
+
+import android.app.Application
+import android.content.Context
+import android.util.Log
+import eu.kanade.tachiyomi.network.NetworkHelper
+import io.github.landwarderer.futon.core.network.webview.WebViewExecutor
+import kotlinx.serialization.SerialFormat
+import kotlinx.serialization.StringFormat
+import kotlinx.serialization.json.Json
+import okhttp3.CookieJar
+import okhttp3.OkHttpClient
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.InjektModule
+import uy.kohesive.injekt.api.InjektRegistrar
+import uy.kohesive.injekt.api.addSingleton
+import uy.kohesive.injekt.api.addSingletonFactory
+import javax.inject.Singleton
+
+@Singleton
+class MihonInjektBridge(
+ private val context: Context,
+ private val httpClient: OkHttpClient,
+ private val cookieJar: CookieJar,
+ private val webViewExecutor: WebViewExecutor? = null,
+) {
+
+ private val application: Application
+ get() = context.applicationContext as Application
+
+ @Volatile
+ private var initialized = false
+
+ /**
+ * This must be called before loading any Mihon extensions.
+ *
+ * Thread-safe - can be called multiple times.
+ */
+ @Synchronized
+ fun initialize() {
+ if (initialized) return
+
+ try {
+ val networkHelper = MihonNetworkHelper(httpClient, cookieJar, webViewExecutor)
+ Log.d(
+ "MihonInjektBridge",
+ "Creating MihonNetworkHelper with webViewExecutorPresent=${webViewExecutor != null}",
+ )
+
+ Injekt.importModule(object : InjektModule {
+ override fun InjektRegistrar.registerInjectables() {
+ // Application and Context
+ addSingleton(application)
+ addSingletonFactory { context.applicationContext }
+
+ // Network components
+ addSingletonFactory { networkHelper }
+ addSingletonFactory { httpClient }
+ addSingletonFactory { cookieJar }
+
+ // JSON - explicitly type it to ensure Injekt matches correctly
+ val json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ }
+ addSingletonFactory { json }
+ addSingletonFactory { json }
+ addSingletonFactory { json }
+ }
+ })
+
+ initialized = true
+ Log.d("MIhonInjektBridge", "Injekt initialized with App dependencies")
+ } catch (e: Throwable) {
+ Log.e("MihonInjektBridge", "CRITICAL: Failed to initialize Injekt bridge", e)
+ // Do not rethrow, so the app can continue to function without Mihon
+ }
+ }
+
+ /**
+ * Check if Injekt has been initialized.
+ */
+ fun isInitialized(): Boolean = initialized
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt
new file mode 100644
index 0000000000..ed5be4207a
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt
@@ -0,0 +1,341 @@
+package io.github.landwarderer.futon.mihon.compat
+
+import android.util.Log
+import eu.kanade.tachiyomi.network.NetworkHelper
+import io.github.landwarderer.futon.core.exceptions.CloudFlareBlockedException
+import io.github.landwarderer.futon.core.exceptions.InteractiveActionRequiredException
+import io.github.landwarderer.futon.core.network.webview.WebViewExecutor
+import io.github.landwarderer.futon.mihon.model.toMangaSource
+import io.github.landwarderer.futon.mihon.parsers.model.ContentSource
+import io.github.landwarderer.futon.mihon.parsers.network.CloudFlareHelper
+import io.github.landwarderer.futon.mihon.parsers.network.UserAgents
+import okhttp3.CookieJar
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import java.io.IOException
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+
+/**
+ * Implementation of Mihon's NetworkHelper interface.
+ *
+ * Wraps App's existing OkHttpClient to provide Mihon extensions with
+ * access to the network stack, including CloudFlare bypassing and cookie management.
+ *
+ * Note: We create a new client without GZipInterceptor because Mihon extensions
+ * handle their own request encoding. App's GZipInterceptor incorrectly
+ * adds Content-Encoding: gzip header without actually compressing the body,
+ * which causes server-side decompression errors (e.g., Picacomic login fails with
+ * "incorrect header check").
+ */
+class MihonNetworkHelper(
+ baseClient: OkHttpClient,
+ val cookieJar: CookieJar,
+ private val webViewExecutor: WebViewExecutor? = null,
+) : NetworkHelper() {
+
+ /**
+ * The OkHttpClient for Mihon extensions.
+ * We rebuild without GZipInterceptor to prevent incorrect Content-Encoding headers.
+ */
+ override val client: OkHttpClient = run {
+ val builder = OkHttpClient.Builder()
+
+ // Copy configuration from base client
+ builder.connectTimeout(baseClient.connectTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
+ builder.readTimeout(baseClient.readTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
+ builder.writeTimeout(baseClient.writeTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
+ builder.cookieJar(baseClient.cookieJar)
+ builder.dns(baseClient.dns)
+ builder.cache(baseClient.cache)
+ builder.dispatcher(baseClient.dispatcher)
+ builder.connectionPool(baseClient.connectionPool)
+ builder.followRedirects(baseClient.followRedirects)
+ builder.followSslRedirects(baseClient.followSslRedirects)
+ builder.retryOnConnectionFailure(baseClient.retryOnConnectionFailure)
+
+ // Wrap exceptions thrown by subsequent interceptors (especially from extensions)
+ builder.addInterceptor { chain ->
+ try {
+ chain.proceed(chain.request())
+ } catch (e: Throwable) {
+ // OkHttp Dispatcher will crash the app if intercepted throws unchecked exception instead of IOException.
+ // Extensions (like Baozi) might throw plain Exceptions for errors like "Socket closed".
+ if (e is IOException) throw e
+ throw IOException(e.message, e)
+ }
+ }
+
+ // Copy interceptors but exclude GZipInterceptor
+ baseClient.interceptors.forEach { interceptor ->
+ if (interceptor.javaClass.simpleName != "GZipInterceptor") {
+ builder.addInterceptor(interceptor)
+ } else {
+ Log.d("MihonNetworkHelper", "Skipping GZipInterceptor for Mihon client")
+ }
+ }
+
+ // Copy network interceptors
+ baseClient.networkInterceptors.forEach { interceptor ->
+ builder.addNetworkInterceptor(interceptor)
+ }
+
+ // Add a Mihon-specific fallback detector.
+ // Some Mihon sources build their own clients from network.cloudflareClient, and in practice
+ // the copied base interceptor chain is not always enough to surface App's CF flow.
+ builder.addInterceptor { chain ->
+ val originalRequest = chain.request()
+ val request = enrichApiRequestHeadersIfNeeded(originalRequest)
+ val response = chain.proceed(request)
+ val challengeUrl = request.toChallengeUrl()
+ when (CloudFlareHelper.checkResponseForProtection(response)) {
+ CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing(
+ CloudFlareBlockedException(
+ url = challengeUrl,
+ source = request.tag(ContentSource::class.java) as MangaSource?,
+ ),
+ )
+
+ CloudFlareHelper.PROTECTION_CAPTCHA -> {
+ val host = request.url.host.lowercase()
+ val clearance = cookieJar.loadForRequest(request.url)
+ .firstOrNull { it.name == "cf_clearance" }
+ ?.value
+
+ tryFetchWithWebView(request)?.let { browserResponse ->
+ val browserProtection = CloudFlareHelper.checkResponseForProtection(browserResponse)
+ if (browserProtection == CloudFlareHelper.PROTECTION_NOT_DETECTED) {
+ Log.i(
+ "MihonNetwork",
+ "WebView fallback succeeded for host=$host, status=${browserResponse.code}",
+ )
+ response.close()
+ return@addInterceptor browserResponse
+ }
+ Log.w(
+ "MihonNetwork",
+ "WebView fallback still protected for host=$host, status=${browserResponse.code}",
+ )
+ browserResponse.close()
+ }
+
+ if (shouldSkipInteractiveAction(host, clearance)) {
+ Log.w(
+ "MihonNetwork",
+ "Skip interactive action for host=$host: repeated challenge with same cf_clearance",
+ )
+ response.closeThrowing(
+ CloudFlareBlockedException(
+ url = challengeUrl,
+ source = request.tag(ContentSource::class.java),
+ ),
+ )
+ } else {
+ val source = request.tag(ContentSource::class.java)
+ if (source == null) {
+ Log.w("MihonNetwork", "Missing ContentSource tag for host=$host")
+ response.closeThrowing(CloudFlareBlockedException(url = challengeUrl, source = null))
+ } else {
+ response.closeThrowing(
+ InteractiveActionRequiredException(
+ source = source.toMangaSource(),
+ url = challengeUrl,
+ ),
+ )
+ }
+ }
+ }
+
+ else -> response
+ }
+ }
+
+ // Add debug logging interceptor for Mihon extensions
+ builder.addInterceptor { chain ->
+ val request = chain.request()
+ val requestCookies = cookieJar.loadForRequest(request.url)
+ val cfClearanceCookie = requestCookies.firstOrNull { it.name == "cf_clearance" }?.value
+ val cookieNames = requestCookies.joinToString(",") { it.name }
+ Log.d(
+ "MihonNetwork",
+ "RequestMeta: host=${request.url.host}, ua=${request.header("User-Agent")}, referer=${request.header("Referer")}, origin=${request.header("Origin")}, hasCfClearance=${cfClearanceCookie != null}, cfClearance=${maskCookieValue(cfClearanceCookie)}, cookies=[$cookieNames]",
+ )
+ Log.d("MihonNetwork", "Request: ${request.method} ${request.url}")
+
+ val response = chain.proceed(request)
+
+ // Log response info
+ val responseCode = response.code
+ val contentType = response.header("Content-Type")
+ Log.d(
+ "MihonNetwork",
+ "Response: $responseCode, Content-Type: $contentType, cf-ray=${response.header("cf-ray")}, cf-mitigated=${response.header("cf-mitigated")}, server=${response.header("server")}, URL: ${request.url}",
+ )
+
+ // If response is not successful, log the first 200 chars of body for debugging
+ if (!response.isSuccessful) {
+ val source = response.body.source()
+ source.request(200)
+ val buffer = source.buffer.clone()
+ val preview = buffer.readUtf8(minOf(200, buffer.size))
+ Log.w("MihonNetwork", "Non-successful response ($responseCode) preview: $preview")
+ }
+
+ response
+ }
+
+ builder.build()
+ }
+
+ /**
+ * @deprecated Since extension-lib 1.5, CloudFlare is handled by the regular client.
+ */
+ @Deprecated("The regular client handles Cloudflare by default")
+ override val cloudflareClient: OkHttpClient = client
+
+ /**
+ * Returns the default user agent string.
+ */
+ override fun defaultUserAgentProvider(): String = UserAgents.CHROME_MOBILE
+
+ private fun Response.closeThrowing(error: Throwable): Nothing {
+ try {
+ close()
+ } catch (e: Exception) {
+ error.addSuppressed(e)
+ }
+ throw error
+ }
+
+ private fun Request.toChallengeUrl(): String {
+ val referer = header("Referer")?.toHttpUrlOrNull()
+ if (referer != null && referer.host == url.host) {
+ return referer.newBuilder()
+ .query(null)
+ .fragment(null)
+ .build()
+ .toString()
+ }
+ return url.newBuilder()
+ .encodedPath("/")
+ .query(null)
+ .fragment(null)
+ .build()
+ .toString()
+ }
+
+ private fun enrichApiRequestHeadersIfNeeded(request: Request): Request {
+ if (!request.url.encodedPath.startsWith("/api/")) return request
+ val cookies = cookieJar.loadForRequest(request.url)
+ val hasCfClearance = cookies.any { it.name == "cf_clearance" }
+ if (!hasCfClearance) return request
+ val origin = "${request.url.scheme}://${request.url.host}"
+ var modified = false
+ val builder = request.newBuilder()
+ if (request.header("Referer").isNullOrBlank()) {
+ builder.header("Referer", "$origin/")
+ modified = true
+ }
+ if (request.header("Origin").isNullOrBlank()) {
+ builder.header("Origin", origin)
+ modified = true
+ }
+ if (request.header("Accept").isNullOrBlank()) {
+ builder.header("Accept", "application/json, text/plain, */*")
+ modified = true
+ }
+ if (request.header("Accept-Language").isNullOrBlank()) {
+ builder.header("Accept-Language", "en-US,en;q=0.9")
+ modified = true
+ }
+ if (request.header("Sec-Fetch-Site").isNullOrBlank()) {
+ builder.header("Sec-Fetch-Site", "same-origin")
+ modified = true
+ }
+ if (request.header("Sec-Fetch-Mode").isNullOrBlank()) {
+ builder.header("Sec-Fetch-Mode", "cors")
+ modified = true
+ }
+ if (request.header("Sec-Fetch-Dest").isNullOrBlank()) {
+ builder.header("Sec-Fetch-Dest", "empty")
+ modified = true
+ }
+ if (request.header("X-Requested-With").isNullOrBlank()) {
+ builder.header("X-Requested-With", "XMLHttpRequest")
+ modified = true
+ }
+ if (request.header("X-XSRF-TOKEN").isNullOrBlank()) {
+ val xsrf = cookies.firstOrNull { it.name == "XSRF-TOKEN" }?.value
+ val decodedXsrf = xsrf?.let {
+ runCatching { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }.getOrDefault(it)
+ }
+ if (!decodedXsrf.isNullOrBlank()) {
+ builder.header("X-XSRF-TOKEN", decodedXsrf)
+ modified = true
+ }
+ }
+ return if (modified) builder.build() else request
+ }
+
+ private fun maskCookieValue(value: String?): String {
+ if (value.isNullOrEmpty()) return ""
+ return if (value.length <= 8) "***" else "${value.take(4)}...${value.takeLast(4)}"
+ }
+
+ private fun tryFetchWithWebView(request: Request): Response? {
+ if (request.method != "GET") {
+ Log.d("MihonNetwork", "WebView fallback skipped: non-GET ${request.method}")
+ return null
+ }
+ val executor = webViewExecutor
+ if (executor == null) {
+ Log.w("MihonNetwork", "WebView fallback skipped: WebViewExecutor is null")
+ return null
+ }
+ val cookies = cookieJar.loadForRequest(request.url)
+ val hasCfClearance = cookies.any { it.name == "cf_clearance" }
+ if (!hasCfClearance) {
+ Log.d("MihonNetwork", "WebView fallback skipped: no cf_clearance for host=${request.url.host}")
+ return null
+ }
+
+ Log.i("MihonNetwork", "WebView fallback is disabled due to missing implementation for WebViewExecutor.fetchWithBrowserContext")
+ return null
+ }
+
+ private fun shouldSkipInteractiveAction(host: String, clearance: String?): Boolean {
+ if (clearance.isNullOrBlank()) return false
+ val now = System.currentTimeMillis()
+ val last = recentChallengeAttempts[host]
+ if (last == null || now - last.timestampMs > INTERACTIVE_RETRY_WINDOW_MS || last.clearance != clearance) {
+ recentChallengeAttempts[host] = ChallengeAttempt(
+ clearance = clearance,
+ timestampMs = now,
+ count = 1,
+ )
+ return false
+ }
+ val nextCount = last.count + 1
+ recentChallengeAttempts[host] = last.copy(
+ timestampMs = now,
+ count = nextCount,
+ )
+ return nextCount >= 2
+ }
+
+ private data class ChallengeAttempt(
+ val clearance: String,
+ val timestampMs: Long,
+ val count: Int,
+ )
+
+ companion object {
+ private const val INTERACTIVE_RETRY_WINDOW_MS = 10 * 60 * 1000L
+ private val recentChallengeAttempts = ConcurrentHashMap()
+ }
+}
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/install/ExtensionInstallService.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/install/ExtensionInstallService.kt
new file mode 100644
index 0000000000..7ed129f796
--- /dev/null
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/install/ExtensionInstallService.kt
@@ -0,0 +1,146 @@
+package io.github.landwarderer.futon.mihon.extensions.install
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import androidx.core.content.edit
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.ProgressListener
+import eu.kanade.tachiyomi.network.awaitSuccess
+import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
+import io.github.landwarderer.futon.BuildConfig
+import io.github.landwarderer.futon.core.network.MangaHttpClient
+import io.github.landwarderer.futon.core.prefs.AppSettings
+import io.github.landwarderer.futon.core.prefs.GitHubMirror
+import io.github.landwarderer.futon.mihon.extensions.repo.RepoAvailableExtension
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.withContext
+import okhttp3.Call
+import okhttp3.OkHttpClient
+import java.io.File
+import java.io.IOException
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ExtensionInstallService @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @MangaHttpClient private val httpClient: OkHttpClient,
+ private val settings: AppSettings,
+) {
+
+ private fun applyMirror(url: String): String {
+ if (url.startsWith("https://raw.githubusercontent.com/")) {
+ return when (settings.gitHubMirror) {
+ GitHubMirror.NATIVE -> url
+ GitHubMirror.KKGITHUB -> url.replace("raw.githubusercontent.com", "raw.kkgithub.com")
+ GitHubMirror.GHPROXY -> "https://mirror.ghproxy.com/$url"
+ GitHubMirror.GHPROXY_NET -> "https://ghproxy.net/$url"
+ GitHubMirror.KEIYOUSHI -> url.replace("raw.githubusercontent.com", "raw.github.com")
+ }
+ }
+ return url
+ }
+
+ private val activeCalls = ConcurrentHashMap()
+ private val _downloadStates = MutableStateFlow