From 25b9df2f389a9039ca84708d929f47b5f567c359 Mon Sep 17 00:00:00 2001 From: LandWarderer2772 Date: Sun, 16 Aug 2026 20:48:24 +0530 Subject: [PATCH 1/2] fix: extension manager --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 20 +- .../kotlin/eu/kanade/tachiyomi/AppInfo.kt | 2 +- .../core/db/DatabasePrePopulateCallback.kt | 16 - .../futon/list/ui/adapter/ListItemType.kt | 2 + .../ui/adapter/TypedListSpacingDecoration.kt | 2 + .../futon/mihon/ChildFirstPathClassLoader.kt | 112 +++--- .../futon/mihon/MihonExtensionLoader.kt | 2 +- .../futon/mihon/compat/MihonInjektBridge.kt | 7 + .../install/ExtensionInstallService.kt | 17 +- .../extensions/repo/ExtensionRepoService.kt | 256 +++++++++++-- .../repo/ExternalExtensionRepoRepository.kt | 19 - .../ExternalExtensionSourceLoaderSupport.kt | 19 + .../settings/sources/SourceSettingsExt.kt | 19 +- .../sources/SourceSettingsFragment.kt | 6 +- .../sources/SourceSettingsViewModel.kt | 20 + .../extension/ExtensionDownloaderActivity.kt | 51 ++- .../extension/ExtensionDownloaderAdapter.kt | 62 +++ .../extension/ExtensionDownloaderViewModel.kt | 149 +++++++- .../kotlin/okhttp3/CompressionAlgorithms.kt | 21 + .../kotlin/okhttp3/CompressionInterceptor.kt | 15 + app/src/main/kotlin/okhttp3/brotli/Brotli.kt | 6 + app/src/main/kotlin/okhttp3/zstd/Zstd.kt | 6 + .../main/res/layout/item_extension_repo.xml | 69 ++++ .../res/layout/item_extension_repo_header.xml | 69 ++++ app/src/main/res/values/strings.xml | 15 +- .../futon/mihon/ExtensionRepoServiceTest.kt | 359 ++++++++++++++++++ 27 files changed, 1209 insertions(+), 134 deletions(-) create mode 100644 app/src/main/kotlin/okhttp3/CompressionAlgorithms.kt create mode 100644 app/src/main/kotlin/okhttp3/CompressionInterceptor.kt create mode 100644 app/src/main/kotlin/okhttp3/brotli/Brotli.kt create mode 100644 app/src/main/kotlin/okhttp3/zstd/Zstd.kt create mode 100644 app/src/main/res/layout/item_extension_repo.xml create mode 100644 app/src/main/res/layout/item_extension_repo_header.xml create mode 100644 app/src/test/kotlin/io/github/landwarderer/futon/mihon/ExtensionRepoServiceTest.kt diff --git a/app/build.gradle b/app/build.gradle index a5a83ad4f2..8f289ecd77 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -203,6 +203,7 @@ dependencies { implementation libs.okio implementation libs.kotlinx.serialization.json implementation libs.kotlinx.serialization.json.okio + implementation libs.kotlinx.serialization.protobuf implementation libs.adapterdelegates implementation libs.adapterdelegates.viewbinding @@ -245,6 +246,7 @@ dependencies { testImplementation libs.junit testImplementation libs.json testImplementation libs.kotlinx.coroutines.test + testImplementation 'org.mockito:mockito-core:5.12.0' androidTestImplementation libs.androidx.runner androidTestImplementation libs.androidx.rules diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9231b7bb4b..32ec773902 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ @@ -295,7 +295,21 @@ android:label="@string/sources_catalog" /> + android:exported="true" + android:label="@string/extensions_manager"> + + + + + + + + + + + + + outRect.set(0) ListItemType.HEADER, + ListItemType.EXTENSION_REPO_HEADER, + ListItemType.EXTENSION_REPO_ITEM, ListItemType.FEED, ListItemType.EXPLORE_SOURCE_LIST, ListItemType.MANGA_SCROBBLING, 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 index 584d3c7993..6aa56a7396 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt @@ -1,13 +1,16 @@ package io.github.landwarderer.futon.mihon -import android.util.Log import dalvik.system.PathClassLoader +import java.io.IOException +import java.io.InputStream +import java.net.URL +import java.util.Enumeration /** - * 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. + * A parent-last class loader that will try in order: + * - the system class loader + * - the child class loader + * - the parent class loader. */ class ChildFirstPathClassLoader( dexPath: String, @@ -15,54 +18,69 @@ class ChildFirstPathClassLoader( 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.", - ) + private val systemClassLoader: ClassLoader? = getSystemClassLoader() - override fun loadClass(name: String, resolve: Boolean): Class<*> { - // Check if we should delegate to parent immediately - if (parentPackages.any { name.startsWith(it) }) { + override fun loadClass(name: String?, resolve: Boolean): Class<*> { + var c = findLoadedClass(name) + + if (c == null && systemClassLoader != null) { try { - return parent.loadClass(name) - } catch (e: ClassNotFoundException) { - // fall through to child loading + c = systemClassLoader.loadClass(name) + } catch (_: ClassNotFoundException) {} + } + + if (c == null) { + c = try { + findClass(name) + } catch (_: ClassNotFoundException) { + super.loadClass(name, resolve) } } - // 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 + if (resolve) { + resolveClass(c) + } + + return c + } + + override fun getResource(name: String?): URL? { + return systemClassLoader?.getResource(name) + ?: findResource(name) + ?: super.getResource(name) + } + + override fun getResources(name: String?): Enumeration { + val systemUrls = systemClassLoader?.getResources(name) + val localUrls = findResources(name) + val parentUrls = parent?.getResources(name) + val urls = buildList { + while (systemUrls?.hasMoreElements() == true) { + add(systemUrls.nextElement()) } + + while (localUrls?.hasMoreElements() == true) { + add(localUrls.nextElement()) + } + + while (parentUrls?.hasMoreElements() == true) { + add(parentUrls.nextElement()) + } + } + + return object : Enumeration { + val iterator = urls.iterator() + + override fun hasMoreElements() = iterator.hasNext() + override fun nextElement() = iterator.next() + } + } + + override fun getResourceAsStream(name: String?): InputStream? { + return try { + getResource(name)?.openStream() + } catch (_: IOException) { + return null } } } 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 index 7a16079b9e..5f459e4933 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionLoader.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionLoader.kt @@ -43,7 +43,7 @@ class MihonExtensionLoader @Inject constructor( // Supported library version range const val LIB_VERSION_MIN = 1.2 - const val LIB_VERSION_MAX = 1.9 + const val LIB_VERSION_MAX = 2.5 } 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 index b7ead80c2f..cd15a065dd 100644 --- 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 @@ -2,7 +2,9 @@ package io.github.landwarderer.futon.mihon.compat import android.app.Application import android.content.Context +import android.content.SharedPreferences import android.util.Log +import androidx.preference.PreferenceManager import eu.kanade.tachiyomi.network.NetworkHelper import io.github.landwarderer.futon.core.network.webview.WebViewExecutor import kotlinx.serialization.SerialFormat @@ -58,6 +60,11 @@ class MihonInjektBridge( addSingletonFactory { httpClient } addSingletonFactory { cookieJar } + // SharedPreferences + addSingletonFactory { + PreferenceManager.getDefaultSharedPreferences(context) + } + // JSON - explicitly type it to ensure Injekt matches correctly val json = Json { ignoreUnknownKeys = true 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 index 7ed129f796..a12f03f20e 100644 --- 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 @@ -43,7 +43,16 @@ class ExtensionInstallService @Inject constructor( 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") + GitHubMirror.KEIYOUSHI -> url.replace("raw.githubusercontent.com", "raw.github.com") + } + } + if (url.startsWith("https://github.com/")) { + return when (settings.gitHubMirror) { + GitHubMirror.NATIVE -> url + GitHubMirror.KKGITHUB -> url.replace("github.com", "kkgithub.com") + GitHubMirror.GHPROXY -> "https://mirror.ghproxy.com/$url" + GitHubMirror.GHPROXY_NET -> "https://ghproxy.net/$url" + GitHubMirror.KEIYOUSHI -> url } } return url @@ -55,7 +64,11 @@ class ExtensionInstallService @Inject constructor( val downloadStates: StateFlow> = _downloadStates.asStateFlow() suspend fun createInstallIntent(extension: RepoAvailableExtension): Intent? = withContext(Dispatchers.IO) { - val apkUrl = applyMirror("${extension.repoUrl}/apk/${extension.apkName}") + val apkUrl = if (extension.apkName.startsWith("http://") || extension.apkName.startsWith("https://")) { + applyMirror(extension.apkName) + } else { + applyMirror("${extension.repoUrl}/apk/${extension.apkName}") + } val outputDir = File(context.cacheDir, "extension-installs").apply { mkdirs() } val outputFile = File(outputDir, "${extension.pkgName}-${extension.versionCode}.apk") val call = httpClient.newCachelessCallWithProgress(GET(apkUrl), ExtensionInstallProgressListener(extension.pkgName)) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionRepoService.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionRepoService.kt index 63d48c0384..655c9a2972 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionRepoService.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionRepoService.kt @@ -2,21 +2,28 @@ package io.github.landwarderer.futon.mihon.extensions.repo import android.util.Log import androidx.annotation.Keep -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.awaitSuccess -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.MihonExtensionLoader +import java.io.ByteArrayInputStream +import java.util.zip.GZIPInputStream import kotlinx.coroutines.withTimeout +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.json.Json +import kotlinx.serialization.protobuf.ProtoBuf +import kotlinx.serialization.protobuf.ProtoNumber import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.awaitSuccess +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.MihonExtensionLoader import javax.inject.Inject import javax.inject.Singleton +@OptIn(ExperimentalSerializationApi::class) @Singleton class ExtensionRepoService @Inject constructor( @MangaHttpClient private val httpClient: OkHttpClient, @@ -24,6 +31,11 @@ class ExtensionRepoService @Inject constructor( private val settings: AppSettings, ) { + private val protoBuf = ProtoBuf { + @Suppress("OPT_IN_USAGE") + encodeDefaults = true + } + private fun applyMirror(url: String): String { if (url.startsWith("https://raw.githubusercontent.com/")) { return when (settings.gitHubMirror) { @@ -40,6 +52,14 @@ class ExtensionRepoService @Inject constructor( else -> url } } + if (url.startsWith("https://github.com/")) { + return when (settings.gitHubMirror) { + GitHubMirror.KKGITHUB -> url.replace("github.com", "kkgithub.com") + GitHubMirror.GHPROXY -> "https://mirror.ghproxy.com/$url" + GitHubMirror.GHPROXY_NET -> "https://ghproxy.net/$url" + else -> url + } + } return url } @@ -82,7 +102,7 @@ class ExtensionRepoService @Inject constructor( name = repoName, shortName = repoShort, website = baseUrl, - signingKeyFingerprint = baseUrl.hashCode().toString(16), // Use baseUrl hash as pseudo-fingerprint for JAR/IReader + signingKeyFingerprint = baseUrl.hashCode().toString(16), createdAt = now, updatedAt = now, lastSuccessAt = now, @@ -121,10 +141,51 @@ class ExtensionRepoService @Inject constructor( } suspend fun fetchAvailableExtensions(repo: ExternalExtensionRepo): List { + val startedAt = System.currentTimeMillis() + Log.d(TAG, "fetchAvailableExtensions:start type=${repo.type} baseUrl=${repo.baseUrl}") + + if (repo.type == ExternalExtensionType.MIHON || repo.type == ExternalExtensionType.ANIYOMI) { + val pbResult = runCatching { + val pbUrl = applyMirror("${repo.baseUrl}/index.pb") + Log.d(TAG, "fetchAvailableExtensions:trying index.pb url=$pbUrl") + withTimeout(CATALOG_TIMEOUT_MS) { + val rawBytes = httpClient.newCall(GET(pbUrl)).awaitSuccess().use { response -> + response.body.bytes() + } + val decompressedBytes = if (rawBytes.size >= 2 && rawBytes[0] == 0x1f.toByte() && rawBytes[1] == 0x8b.toByte()) { + GZIPInputStream(ByteArrayInputStream(rawBytes)).use { it.readBytes() } + } else { + rawBytes + } + val storeDto = protoBuf.decodeFromByteArray(decompressedBytes) + val extensions = storeDto.extensionList?.extensions.orEmpty() + extensions.asSequence() + .filterNot { ext -> + ext.packageName == "eu.kanade.tachiyomi.extension.all.outdated" || + ext.packageName.endsWith(".outdated") || + ext.name.startsWith("Outdated App", ignoreCase = true) || + ext.name.contains("Update to Mihon", ignoreCase = true) + } + .mapNotNull { ext -> ext.toAvailableExtension(repo) } + .toList() + } + } + + if (pbResult.isSuccess && pbResult.getOrNull()?.isNotEmpty() == true) { + val list = pbResult.getOrThrow() + Log.d( + TAG, + "fetchAvailableExtensions:success (protobuf) type=${repo.type} baseUrl=${repo.baseUrl} count=${list.size} elapsedMs=${System.currentTimeMillis() - startedAt}", + ) + return list + } else if (pbResult.isFailure) { + Log.d(TAG, "fetchAvailableExtensions:protobuf fallback to json, reason=${pbResult.exceptionOrNull()?.message}") + } + } + val indexUrl = "${repo.baseUrl}/index.min.json" val requestUrl = applyMirror(indexUrl) - val startedAt = System.currentTimeMillis() - Log.d(TAG, "fetchAvailableExtensions:start type=${repo.type} url=$requestUrl") + Log.d(TAG, "fetchAvailableExtensions:trying json url=$requestUrl") return runCatching { withTimeout(CATALOG_TIMEOUT_MS) { val body = httpClient.newCall(GET(requestUrl)).awaitSuccess().use { response -> @@ -145,7 +206,7 @@ class ExtensionRepoService @Inject constructor( }.onSuccess { extensions -> Log.d( TAG, - "fetchAvailableExtensions:success type=${repo.type} baseUrl=${repo.baseUrl} count=${extensions.size} elapsedMs=${System.currentTimeMillis() - startedAt}", + "fetchAvailableExtensions:success (json) type=${repo.type} baseUrl=${repo.baseUrl} count=${extensions.size} elapsedMs=${System.currentTimeMillis() - startedAt}", ) }.onFailure { error -> Log.e( @@ -159,14 +220,35 @@ class ExtensionRepoService @Inject constructor( fun normalizeIndexUrl(input: String): String? { val processUrl = input.trim() - val url = processUrl.toHttpUrlOrNull() ?: return null - if (url.scheme != "https") { + var url = processUrl.toHttpUrlOrNull() ?: return null + if (url.scheme != "https" && url.scheme != "http") { return null } + + if (url.host == "github.com") { + val segments = url.pathSegments.filter { it.isNotEmpty() } + if (segments.size >= 3 && (segments[2] == "raw" || segments[2] == "tree" || segments[2] == "blob" || segments[2] == "refs")) { + val user = segments[0] + val repo = segments[1] + val rest = if (segments[2] == "refs" && segments.size >= 5 && segments[3] == "heads") { + segments.drop(4) + } else { + segments.drop(3) + } + val newPath = "/$user/$repo/" + rest.joinToString("/") + url = url.newBuilder() + .host("raw.githubusercontent.com") + .encodedPath(newPath) + .build() + } + } + val normalizedSegments = url.pathSegments .filter { it.isNotEmpty() } .toMutableList() - if (normalizedSegments.lastOrNull() != "index.min.json") { + if (normalizedSegments.lastOrNull() in listOf("index.min.json", "index.json", "index.pb", "repo.json")) { + normalizedSegments[normalizedSegments.lastIndex] = "index.min.json" + } else { normalizedSegments += "index.min.json" } val normalizedPath = "/" + normalizedSegments.joinToString("/") @@ -182,11 +264,81 @@ class ExtensionRepoService @Inject constructor( return indexUrl.removeSuffix("/index.min.json") } + private fun ExtensionDto.toAvailableExtension(repo: ExternalExtensionRepo): RepoAvailableExtension? { + val libVersion = runCatching { + val parts = extensionLib.split('.') + if (parts.size >= 2) { + "${parts[0]}.${parts[1]}".toDouble() + } else { + parts[0].toDouble() + } + }.getOrNull() ?: return null + + val supported = when (repo.type) { + ExternalExtensionType.MIHON -> libVersion in MihonExtensionLoader.LIB_VERSION_MIN..MihonExtensionLoader.LIB_VERSION_MAX + ExternalExtensionType.ANIYOMI -> libVersion in (1.2)..(2.5) + ExternalExtensionType.IREADER -> true + ExternalExtensionType.JAR -> true + } + + val displayName = when (repo.type) { + ExternalExtensionType.MIHON -> name.removePrefix("Tachiyomi: ") + ExternalExtensionType.ANIYOMI -> name.removePrefix("Aniyomi: ") + ExternalExtensionType.IREADER -> name.removePrefix("IReader: ") + ExternalExtensionType.JAR -> name + } + + val langs = sources.map { it.language }.filter { it.isNotEmpty() }.toSet() + val lang = if (langs.size == 1) langs.first() else "all" + + val icon = if (resources.iconUrl.startsWith("http://") || resources.iconUrl.startsWith("https://")) { + applyMirror(resources.iconUrl) + } else if (resources.iconUrl.isNotEmpty()) { + applyMirror("${repo.baseUrl}/${resources.iconUrl.trimStart('/')}") + } else { + applyMirror("${repo.baseUrl}/icon/${resources.apkUrl.substringAfterLast('/').replace(".apk", ".png")}") + } + + return RepoAvailableExtension( + type = repo.type, + name = displayName, + pkgName = packageName, + versionName = versionName, + versionCode = versionCode, + libVersion = libVersion, + lang = lang, + isNsfw = contentWarning >= 2, + sourceNames = sources.map { it.name }, + apkName = resources.apkUrl, + iconUrl = icon, + repoUrl = repo.baseUrl, + repoName = repo.displayName, + signatureHash = repo.signingKeyFingerprint, + isCompatible = supported, + ) + } + private fun ExtensionIndexDto.toAvailableExtension(repo: ExternalExtensionRepo): RepoAvailableExtension? { - val libVersion = runCatching { version.substringBeforeLast('.').toDouble() }.getOrNull() ?: if (repo.type == ExternalExtensionType.IREADER) 0.0 else return null + if (pkg == "eu.kanade.tachiyomi.extension.all.outdated" || + pkg.endsWith(".outdated") || + name.startsWith("Outdated App", ignoreCase = true) || + name.contains("Update to Mihon", ignoreCase = true) + ) { + return null + } + + val libVersion = runCatching { + val parts = version.split('.') + if (parts.size >= 2) { + "${parts[0]}.${parts[1]}".toDouble() + } else { + parts[0].toDouble() + } + }.getOrNull() ?: if (repo.type == ExternalExtensionType.IREADER) 0.0 else return null + val supported = when (repo.type) { ExternalExtensionType.MIHON -> libVersion in MihonExtensionLoader.LIB_VERSION_MIN..MihonExtensionLoader.LIB_VERSION_MAX - ExternalExtensionType.ANIYOMI -> libVersion in (1.2)..(1.9) + ExternalExtensionType.ANIYOMI -> libVersion in (1.2)..(2.5) ExternalExtensionType.IREADER -> true ExternalExtensionType.JAR -> true } @@ -217,7 +369,14 @@ class ExtensionRepoService @Inject constructor( } private fun IReaderExtensionIndexDto.toAvailableExtension(repo: ExternalExtensionRepo): RepoAvailableExtension { - val libVersion = runCatching { version.substringBeforeLast('.').toDouble() }.getOrNull() ?: 0.0 + val libVersion = runCatching { + val parts = version.split('.') + if (parts.size >= 2) { + "${parts[0]}.${parts[1]}".toDouble() + } else { + parts[0].toDouble() + } + }.getOrNull() ?: 0.0 val displayName = name.removePrefix("IReader: ") return RepoAvailableExtension( @@ -229,25 +388,21 @@ class ExtensionRepoService @Inject constructor( libVersion = libVersion, lang = lang, isNsfw = nsfw, - sourceNames = emptyList(), // IReader plugins don't declare subset sources natively + sourceNames = emptyList(), apkName = apk, iconUrl = applyMirror("${repo.baseUrl}/icon/${apk.replace(".apk", ".png")}"), repoUrl = repo.baseUrl, repoName = repo.displayName, - // IReader repos currently don't expose a verifiable APK signing fingerprint. - // `repo.signingKeyFingerprint` is a synthetic repo identifier for repo management, - // not the package certificate fingerprint, so using it for trust checks would - // always misclassify installed IReader extensions as untrusted. signatureHash = "", isCompatible = true, ) } - - @Keep @Serializable private data class RepoMetaWrapperDto( + @SerialName("index_v2") + val indexV2: String? = null, val meta: RepoMetaDto, ) @@ -293,6 +448,61 @@ class ExtensionRepoService @Inject constructor( val nsfw: Boolean = false, ) + @Keep + @Serializable + private data class NetworkExtensionStoreDto( + @ProtoNumber(1) val name: String = "", + @ProtoNumber(2) val badgeLabel: String = "", + @ProtoNumber(3) val signingKey: String = "", + @ProtoNumber(4) val contact: ContactDto? = null, + @ProtoNumber(101) val extensionList: ExtensionListDto? = null, + @ProtoNumber(102) val extensionListUrl: String? = null, + ) + + @Keep + @Serializable + private data class ContactDto( + @ProtoNumber(1) val website: String = "", + @ProtoNumber(2) val discord: String? = null, + ) + + @Keep + @Serializable + private data class ExtensionListDto( + @ProtoNumber(1) val extensions: List = emptyList(), + ) + + @Keep + @Serializable + private data class ExtensionDto( + @ProtoNumber(1) val name: String = "", + @ProtoNumber(2) val packageName: String = "", + @ProtoNumber(3) val resources: ResourcesDto = ResourcesDto(), + @ProtoNumber(4) val extensionLib: String = "", + @ProtoNumber(5) val versionCode: Long = 0L, + @ProtoNumber(6) val versionName: String = "", + @ProtoNumber(7) val contentWarning: Int = 0, + @ProtoNumber(8) val sources: List = emptyList(), + ) + + @Keep + @Serializable + private data class ResourcesDto( + @ProtoNumber(1) val apkUrl: String = "", + @ProtoNumber(2) val iconUrl: String = "", + ) + + @Keep + @Serializable + private data class SourceDto( + @ProtoNumber(1) val id: Long = 0L, + @ProtoNumber(2) val name: String = "", + @ProtoNumber(3) val language: String = "", + @ProtoNumber(4) val homeUrl: String = "", + @ProtoNumber(5) val mirrorUrls: List = emptyList(), + @ProtoNumber(7) val message: String? = null, + ) + private companion object { const val TAG = "ExtensionRepo" const val REPO_DETAILS_TIMEOUT_MS = 15_000L diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepoRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepoRepository.kt index 903a6bee31..0fb9232078 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepoRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepoRepository.kt @@ -131,25 +131,6 @@ class ExternalExtensionRepoRepository @Inject constructor( Log.d(TAG, "getCatalogExtensions:start type=$type") val repos = getByType(type) Log.d(TAG, "getCatalogExtensions:db_repos count=${repos.size}") - if (type == ExternalExtensionType.MIHON && repos.none { it.baseUrl.contains("keiyoushi") }) { - Log.d(TAG, "getCatalogExtensions:keiyoushi_missing auto_adding") - val now = System.currentTimeMillis() - val keiyoushi = ExternalExtensionRepo( - type = ExternalExtensionType.MIHON, - baseUrl = "https://raw.githubusercontent.com/keiyoushi/extensions/refs/heads/repo", - name = "Keiyoushi", - shortName = "Keiyoushi", - website = "https://keiyoushi.github.io/extensions", - signingKeyFingerprint = "508c909405615d0234a41316b230230559f6b9a89c3f15c13b306b38c2306f50", - createdAt = now, - updatedAt = now, - lastSuccessAt = now, - lastError = null, - ) - val result = confirmAddRepo(keiyoushi) - Log.d(TAG, "getCatalogExtensions:keiyoushi_added result=$result") - return@coroutineScope getCatalogExtensions(type) - } val results = repos .map { repo -> async { service.fetchAvailableExtensions(repo) } } .awaitAll() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionSourceLoaderSupport.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionSourceLoaderSupport.kt index 3e33c21ba8..e70a2717fc 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionSourceLoaderSupport.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionSourceLoaderSupport.kt @@ -73,6 +73,25 @@ object ExternalExtensionSourceLoaderSupport { return@flatMap instance.createSources() as List } + // Reflective createSources fallback for extension factories + try { + val method = instance.javaClass.getMethod("createSources") + val result = method.invoke(instance) + if (result is List<*>) { + @Suppress("UNCHECKED_CAST") + val sources = result.mapNotNull { item -> + item?.let { asSource(it) ?: (it as? SourceT) } + } + if (sources.isNotEmpty()) { + return@flatMap sources + } + } + } catch (_: NoSuchMethodException) { + // not a factory + } catch (e: Throwable) { + Log.w("MihonExtensionLoader", "Failed to reflectively invoke createSources on $fullClassName", e) + } + onUnknownInstance(instance.javaClass.name) emptyList() } catch (e: Throwable) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt index 81d6207913..7c28a4cd8c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt @@ -6,25 +6,38 @@ import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.source.ConfigurableSource import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.parser.EmptyMangaRepository import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.core.parser.ParserMangaRepository -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.network.UserAgents -import org.koitharu.kotatsu.parsers.util.mapToArray +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.mihon.MihonMangaRepository import io.github.landwarderer.futon.settings.utils.AutoCompleteTextViewPreference import io.github.landwarderer.futon.settings.utils.EditTextBindListener import io.github.landwarderer.futon.settings.utils.EditTextDefaultSummaryProvider import io.github.landwarderer.futon.settings.utils.validation.DomainValidator import io.github.landwarderer.futon.settings.utils.validation.HeaderValidator +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.network.UserAgents +import org.koitharu.kotatsu.parsers.util.mapToArray fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: MangaRepository) = when (repository) { is ParserMangaRepository -> addPreferencesFromParserRepository(repository) + is MihonMangaRepository -> addPreferencesFromMihonRepository(repository) is EmptyMangaRepository -> addPreferencesFromEmptyRepository() else -> Unit } +private fun PreferenceFragmentCompat.addPreferencesFromMihonRepository(repository: MihonMangaRepository) { + val configurableSource = repository.mihonSource as? ConfigurableSource ?: return + runCatching { + configurableSource.setupPreferenceScreen(preferenceScreen) + }.onFailure { + it.printStackTraceDebug() + } +} + private fun PreferenceFragmentCompat.addPreferencesFromParserRepository(repository: ParserMangaRepository) { addPreferencesFromResource(R.xml.pref_source_parser) val configKeys = repository.getConfigKeys() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt index aa118f9154..f1d9708b1a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt @@ -24,6 +24,7 @@ import io.github.landwarderer.futon.core.ui.util.ReversibleActionObserver import io.github.landwarderer.futon.core.util.ext.observe import io.github.landwarderer.futon.core.util.ext.observeEvent import io.github.landwarderer.futon.core.util.ext.withArgs +import io.github.landwarderer.futon.mihon.MihonMangaRepository import org.koitharu.kotatsu.parsers.model.MangaSource import java.io.File @@ -41,7 +42,10 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.sharedPreferencesName = viewModel.source.name.replace(File.separatorChar, '$') + preferenceManager.sharedPreferencesName = when (val repo = viewModel.repository) { + is MihonMangaRepository -> "source_${repo.source.sourceId}" + else -> viewModel.source.name.replace(File.separatorChar, '$') + } addPreferencesFromResource(R.xml.pref_source) addPreferencesFromRepository(viewModel.repository) val isValidSource = viewModel.repository !is EmptyMangaRepository diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsViewModel.kt index 2fca378b89..403bf0bd84 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsViewModel.kt @@ -1,8 +1,11 @@ package io.github.landwarderer.futon.settings.sources +import android.app.Application +import android.content.Context import android.content.SharedPreferences import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel +import eu.kanade.tachiyomi.source.online.HttpSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +23,7 @@ import io.github.landwarderer.futon.core.ui.util.ReversibleAction import io.github.landwarderer.futon.core.util.ext.MutableEventFlow import io.github.landwarderer.futon.core.util.ext.call import io.github.landwarderer.futon.explore.data.MangaSourcesRepository +import io.github.landwarderer.futon.mihon.MihonMangaRepository import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import javax.inject.Inject @@ -28,6 +32,7 @@ import javax.inject.Inject class SourceSettingsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, + private val app: Application, private val cookieJar: MutableCookieJar, private val mangaSourcesRepository: MangaSourcesRepository, ) : BaseViewModel(), SharedPreferences.OnSharedPreferenceChangeListener { @@ -41,6 +46,7 @@ class SourceSettingsViewModel @Inject constructor( val browserUrl = MutableStateFlow(null) val isEnabled = mangaSourcesRepository.observeIsEnabled(source) private var usernameLoadJob: Job? = null + private var mihonPrefs: SharedPreferences? = null init { when (repository) { @@ -49,6 +55,13 @@ class SourceSettingsViewModel @Inject constructor( repository.getConfig().subscribe(this) loadUsername(repository.getAuthProvider()) } + is MihonMangaRepository -> { + val httpSource = repository.mihonSource as? HttpSource + browserUrl.value = httpSource?.baseUrl + val prefs = app.getSharedPreferences("source_${repository.source.sourceId}", Context.MODE_PRIVATE) + mihonPrefs = prefs + prefs.registerOnSharedPreferenceChangeListener(this) + } } } @@ -57,6 +70,9 @@ class SourceSettingsViewModel @Inject constructor( is ParserMangaRepository -> { repository.getConfig().unsubscribe(this) } + is MihonMangaRepository -> { + mihonPrefs?.unregisterOnSharedPreferenceChangeListener(this) + } } super.onCleared() } @@ -72,6 +88,10 @@ class SourceSettingsViewModel @Inject constructor( browserUrl.value = "https://${repository.domain}" } } + if (repository is MihonMangaRepository) { + val httpSource = repository.mihonSource as? HttpSource + browserUrl.value = httpSource?.baseUrl + } } fun onResume() { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderActivity.kt index 0455beab92..8547eda39f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderActivity.kt @@ -1,21 +1,26 @@ package io.github.landwarderer.futon.settings.sources.extension +import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.View import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.ui.BaseActivity import io.github.landwarderer.futon.core.util.ext.observe import io.github.landwarderer.futon.core.util.ext.observeEvent import io.github.landwarderer.futon.databinding.ActivityExtensionDownloaderBinding +import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionRepo @AndroidEntryPoint class ExtensionDownloaderActivity : BaseActivity() { @@ -30,9 +35,11 @@ class ExtensionDownloaderActivity : BaseActivity viewModel.addRepo(url) }, + onDeleteRepoClick = { repo -> showDeleteRepoDialog(repo) }, onInstallClick = { viewModel.installExtension(it.available) }, onCancelClick = { viewModel.cancelDownload(it.available.pkgName) }, - onUninstallClick = { viewModel.uninstallExtension(it.available.pkgName) } + onUninstallClick = { viewModel.uninstallExtension(it.available.pkgName) }, ) viewBinding.recyclerView.adapter = adapter @@ -46,10 +53,50 @@ class ExtensionDownloaderActivity : BaseActivity + when (event) { + is RepoEvent.Success -> { + Snackbar.make(viewBinding.root, R.string.extension_repo_add_success, Snackbar.LENGTH_SHORT).show() + } + is RepoEvent.Error -> { + Snackbar.make(viewBinding.root, event.message, Snackbar.LENGTH_LONG).show() + } + } + } + addMenuProvider(ExtensionManagerMenuProvider()) + handleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent?) { + val data = intent?.data ?: return + val repoUrl = data.getQueryParameter("url") + ?: data.getQueryParameter("repo") + ?: data.getQueryParameter("index") + ?: return + + if (repoUrl.isNotBlank()) { + viewModel.addRepo(repoUrl) + } + } + + private fun showDeleteRepoDialog(repo: ExternalExtensionRepo) { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.extension_repo_remove_confirm, repo.displayName)) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.deleteRepo(repo) + } + .setNegativeButton(android.R.string.cancel, null) + .show() } - override fun onApplyWindowInsets(v: android.view.View, insets: WindowInsetsCompat): WindowInsetsCompat { + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.updatePadding(bottom = systemBars.bottom) return insets diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderAdapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderAdapter.kt index 2b09ea5d0e..84d742da92 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderAdapter.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderAdapter.kt @@ -1,24 +1,86 @@ package io.github.landwarderer.futon.settings.sources.extension +import android.view.inputmethod.EditorInfo import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.ui.BaseListAdapter import io.github.landwarderer.futon.databinding.ItemExtensionBinding +import io.github.landwarderer.futon.databinding.ItemExtensionRepoBinding +import io.github.landwarderer.futon.databinding.ItemExtensionRepoHeaderBinding import io.github.landwarderer.futon.list.ui.adapter.ListItemType +import io.github.landwarderer.futon.list.ui.adapter.listHeaderAD import io.github.landwarderer.futon.list.ui.model.ListModel +import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionRepo class ExtensionDownloaderAdapter( + onAddRepoClick: (String) -> Unit, + onDeleteRepoClick: (ExternalExtensionRepo) -> Unit, onInstallClick: (ExtensionItem) -> Unit, onCancelClick: (ExtensionItem) -> Unit, onUninstallClick: (ExtensionItem) -> Unit, ) : BaseListAdapter() { init { + addDelegate(ListItemType.EXTENSION_REPO_HEADER, repoHeaderItemAD(onAddRepoClick)) + addDelegate(ListItemType.EXTENSION_REPO_ITEM, repoItemAD(onDeleteRepoClick)) + addDelegate(ListItemType.HEADER, listHeaderAD(null)) addDelegate(ListItemType.EXTENSION, extensionItemAD(onInstallClick, onCancelClick, onUninstallClick)) } } +private fun repoHeaderItemAD( + onAddClick: (String) -> Unit, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExtensionRepoHeaderBinding.inflate(layoutInflater, parent, false) } +) { + binding.buttonAdd.setOnClickListener { + val text = binding.editTextUrl.text?.toString().orEmpty() + if (text.isNotBlank()) { + onAddClick(text) + binding.editTextUrl.setText("") + } + } + + binding.editTextUrl.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + val text = binding.editTextUrl.text?.toString().orEmpty() + if (text.isNotBlank()) { + onAddClick(text) + binding.editTextUrl.setText("") + } + true + } else { + false + } + } + + bind { + binding.textViewEmpty.isVisible = !item.hasRepos + } +} + +private fun repoItemAD( + onDeleteClick: (ExternalExtensionRepo) -> Unit, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExtensionRepoBinding.inflate(layoutInflater, parent, false) } +) { + binding.buttonDelete.setOnClickListener { + onDeleteClick(item.repo) + } + + bind { + binding.textViewName.text = item.repo.displayName + binding.textViewUrl.text = item.repo.baseUrl + if (item.repo.lastError != null) { + binding.textViewError.text = item.repo.lastError + binding.textViewError.isVisible = true + } else { + binding.textViewError.isVisible = false + } + } +} + private fun extensionItemAD( onInstallClick: (ExtensionItem) -> Unit, onCancelClick: (ExtensionItem) -> Unit, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderViewModel.kt index 25b5bda7b1..3aa3699d62 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderViewModel.kt @@ -1,30 +1,36 @@ package io.github.landwarderer.futon.settings.sources.extension +import android.app.Application import android.util.Log import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.ui.BaseViewModel import io.github.landwarderer.futon.core.util.ext.MutableEventFlow import io.github.landwarderer.futon.core.util.ext.call +import io.github.landwarderer.futon.core.util.ext.getDisplayMessage +import io.github.landwarderer.futon.list.ui.model.ListHeader import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.mihon.MihonExtensionManager import io.github.landwarderer.futon.mihon.extensions.install.ExtensionInstallDownloadState import io.github.landwarderer.futon.mihon.extensions.install.ExtensionInstallService +import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionRepo import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionRepoRepository import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionType import io.github.landwarderer.futon.mihon.extensions.repo.RepoAvailableExtension import io.github.landwarderer.futon.mihon.model.MihonLoadResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ExtensionDownloaderViewModel @Inject constructor( + private val app: Application, private val repoRepository: ExternalExtensionRepoRepository, private val extensionManager: MihonExtensionManager, private val installService: ExtensionInstallService, @@ -37,6 +43,13 @@ class ExtensionDownloaderViewModel @Inject constructor( private val _intentAction = MutableEventFlow() val intentAction = _intentAction + private val _onRepoEvent = MutableEventFlow() + val onRepoEvent = _onRepoEvent + + val repos: StateFlow> = repoRepository + .observeByType(ExternalExtensionType.MIHON) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + init { viewModelScope.launch { Log.d("ExtensionDownloaderViewModel", "fetching extensions") @@ -45,29 +58,81 @@ class ExtensionDownloaderViewModel @Inject constructor( refresh() } - val state: StateFlow = combine( + private data class RepoCatalogQuery( + val repos: List, + val available: List, + val query: String, + ) + + private val repoCatalogQueryFlow = combine( + repos, catalogExtensions, + searchQuery, + ) { repoList, available, query -> + RepoCatalogQuery(repoList, available, query) + } + + val state: StateFlow = combine( + repoCatalogQueryFlow, extensionManager.installedExtensions, installService.downloadStates, refreshing, - searchQuery - ) { available, installed, downloads, isRefreshing, query -> + ) { (repoList, available, query), installed, downloads, isRefreshing -> val filteredExtensions = if (query.isNotEmpty()) { available.filter { it.name.contains(query, ignoreCase = true) } } else { available } - val items = filteredExtensions.map { extension -> + + val extensionItems = filteredExtensions.map { extension -> val installedExtension = installed.find { it.pkgName == extension.pkgName } ExtensionItem( available = extension, installed = installedExtension, - downloadState = downloads[extension.pkgName] + downloadState = downloads[extension.pkgName], ) } + + val installedItems = extensionItems + .filter { it.isInstalled } + .sortedWith( + compareByDescending { it.hasUpdate } + .thenBy { it.available.name.lowercase() } + ) + + val notInstalledItems = extensionItems + .filterNot { it.isInstalled } + .sortedWith( + compareBy { it.available.lang } + .thenBy { it.available.name.lowercase() } + ) + + val items = buildList { + if (query.isEmpty()) { + add(RepoHeaderItem(hasRepos = repoList.isNotEmpty())) + for (repo in repoList) { + add(RepoItem(repo)) + } + if (installedItems.isNotEmpty()) { + add(ListHeader(R.string.installed_extensions_header)) + addAll(installedItems) + } + if (notInstalledItems.isNotEmpty()) { + if (installedItems.isNotEmpty()) { + add(ListHeader(R.string.available_extensions_header)) + } + addAll(notInstalledItems) + } + } else { + // When searching, pin installed matches to top + addAll(installedItems) + addAll(notInstalledItems) + } + } + ExtensionDownloaderState( items = items, - isLoading = isRefreshing + isLoading = isRefreshing, ) }.stateIn(viewModelScope, SharingStarted.Lazily, ExtensionDownloaderState()) @@ -83,6 +148,45 @@ class ExtensionDownloaderViewModel @Inject constructor( } } + fun addRepo(url: String) { + val cleanUrl = url.trim() + if (cleanUrl.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + when (val result = repoRepository.addRepo(ExternalExtensionType.MIHON, cleanUrl)) { + is ExternalExtensionRepoRepository.AddRepoResult.Success -> { + _onRepoEvent.call(RepoEvent.Success(result.repo)) + refresh() + } + is ExternalExtensionRepoRepository.AddRepoResult.DuplicateFingerprint -> { + _onRepoEvent.call(RepoEvent.Error(app.getString(R.string.repo_already_exists))) + } + is ExternalExtensionRepoRepository.AddRepoResult.FetchFailed -> { + _onRepoEvent.call( + RepoEvent.Error( + app.getString( + R.string.failed_to_add_repo, + result.error.getDisplayMessage(app.resources), + ) + ) + ) + } + ExternalExtensionRepoRepository.AddRepoResult.InvalidUrl -> { + _onRepoEvent.call(RepoEvent.Error(app.getString(R.string.invalid_repo_url))) + } + ExternalExtensionRepoRepository.AddRepoResult.RepoAlreadyExists -> { + _onRepoEvent.call(RepoEvent.Error(app.getString(R.string.repo_already_exists))) + } + } + } + } + + fun deleteRepo(repo: ExternalExtensionRepo) { + viewModelScope.launch(Dispatchers.IO) { + repoRepository.delete(repo) + refresh() + } + } + fun installExtension(extension: RepoAvailableExtension) { viewModelScope.launch { val intent = installService.createInstallIntent(extension) @@ -106,11 +210,28 @@ class ExtensionDownloaderViewModel @Inject constructor( } } +sealed interface RepoEvent { + data class Success(val repo: ExternalExtensionRepo) : RepoEvent + data class Error(val message: String) : RepoEvent +} + data class ExtensionDownloaderState( - val items: List = emptyList(), + val items: List = emptyList(), val isLoading: Boolean = false, ) +data class RepoHeaderItem( + val hasRepos: Boolean, +) : ListModel { + override fun areItemsTheSame(other: ListModel): Boolean = other is RepoHeaderItem +} + +data class RepoItem( + val repo: ExternalExtensionRepo, +) : ListModel { + override fun areItemsTheSame(other: ListModel): Boolean = other is RepoItem && repo.baseUrl == other.repo.baseUrl +} + data class ExtensionItem( val available: RepoAvailableExtension, val installed: MihonLoadResult.Success?, diff --git a/app/src/main/kotlin/okhttp3/CompressionAlgorithms.kt b/app/src/main/kotlin/okhttp3/CompressionAlgorithms.kt new file mode 100644 index 0000000000..61523fc7a9 --- /dev/null +++ b/app/src/main/kotlin/okhttp3/CompressionAlgorithms.kt @@ -0,0 +1,21 @@ +package okhttp3 + +import okhttp3.brotli.Brotli +import okhttp3.zstd.Zstd + +/** + * Stub object providing compression algorithm identifiers for OkHttp CompressionInterceptor. + */ +object CompressionAlgorithms { + @JvmField + val brotli: Any = Brotli + + @JvmField + val zstd: Any = Zstd + + @JvmField + val gzip: Any = "gzip" + + @JvmField + val deflate: Any = "deflate" +} diff --git a/app/src/main/kotlin/okhttp3/CompressionInterceptor.kt b/app/src/main/kotlin/okhttp3/CompressionInterceptor.kt new file mode 100644 index 0000000000..177b91f3c7 --- /dev/null +++ b/app/src/main/kotlin/okhttp3/CompressionInterceptor.kt @@ -0,0 +1,15 @@ +package okhttp3 + +/** + * Stub class for OkHttp CompressionInterceptor to provide binary compatibility + * with Mihon extensions compiled against okhttp-brotli / okhttp-zstd / Keiyoushi v1.6+. + */ +class CompressionInterceptor( + val algorithms: List = emptyList(), +) : Interceptor { + constructor(vararg algorithms: Any) : this(algorithms.toList()) + + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed(chain.request()) + } +} diff --git a/app/src/main/kotlin/okhttp3/brotli/Brotli.kt b/app/src/main/kotlin/okhttp3/brotli/Brotli.kt new file mode 100644 index 0000000000..04c76953cb --- /dev/null +++ b/app/src/main/kotlin/okhttp3/brotli/Brotli.kt @@ -0,0 +1,6 @@ +package okhttp3.brotli + +/** + * Stub object for Brotli compression algorithm. + */ +object Brotli diff --git a/app/src/main/kotlin/okhttp3/zstd/Zstd.kt b/app/src/main/kotlin/okhttp3/zstd/Zstd.kt new file mode 100644 index 0000000000..d5f85a0ba2 --- /dev/null +++ b/app/src/main/kotlin/okhttp3/zstd/Zstd.kt @@ -0,0 +1,6 @@ +package okhttp3.zstd + +/** + * Stub object for Zstandard compression algorithm. + */ +object Zstd diff --git a/app/src/main/res/layout/item_extension_repo.xml b/app/src/main/res/layout/item_extension_repo.xml new file mode 100644 index 0000000000..915d04b517 --- /dev/null +++ b/app/src/main/res/layout/item_extension_repo.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_extension_repo_header.xml b/app/src/main/res/layout/item_extension_repo_header.xml new file mode 100644 index 0000000000..e46a3e531c --- /dev/null +++ b/app/src/main/res/layout/item_extension_repo_header.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f50829191..9f41dbfd31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,7 +125,7 @@ Clear updates feed Cleared Rotate screen - View Release + Update Downloading from GitHub will bypass security checks performed by IzzyOnDroid and Fdroid. Feed update will start soon Look for updates @@ -530,7 +530,18 @@ %1$s, %2$s Sources catalog Extensions manager - Manage, install and uninstall Tachiyomi extensions from keiyoushi repository + Manage, install and uninstall extensions from repositories + Repositories + Add + Repository URL + Repository added + Remove %s? + No repositories added. Add a repository URL above to browse extensions. + Installed + Available + Invalid repository URL + Repository already exists + Failed to add repository: %s Source enabled There are no sources available in this section, or all of it might have been already added.\nStay tuned No available manga sources found by your query diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/mihon/ExtensionRepoServiceTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/mihon/ExtensionRepoServiceTest.kt new file mode 100644 index 0000000000..f1a885455e --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/mihon/ExtensionRepoServiceTest.kt @@ -0,0 +1,359 @@ +package io.github.landwarderer.futon.mihon + +import android.util.Log +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.GitHubMirror +import io.github.landwarderer.futon.mihon.extensions.repo.ExtensionRepoService +import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionRepo +import io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionType + +class ExtensionRepoServiceTest { + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } + + private lateinit var appSettings: AppSettings + private lateinit var mockedLog: MockedStatic + + @Before + fun setUp() { + mockedLog = Mockito.mockStatic(Log::class.java) + appSettings = Mockito.mock(AppSettings::class.java) + Mockito.`when`(appSettings.gitHubMirror).thenReturn(GitHubMirror.NATIVE) + } + + @After + fun tearDown() { + mockedLog.close() + } + + @Test + fun testFetchAvailableExtensions_filtersOutdatedAndParsesLibVersions() = runTest { + val jsonCatalog = """ + [ + { + "name": "Outdated App", + "pkg": "eu.kanade.tachiyomi.extension.all.outdated", + "apk": "tachiyomi-outdated.apk", + "lang": "all", + "code": 1, + "version": "0.20.1", + "nsfw": 0 + }, + { + "name": "Tachiyomi: MangaFire", + "pkg": "eu.kanade.tachiyomi.extension.all.mangafire", + "apk": "tachiyomi-mangafire.apk", + "lang": "all", + "code": 12, + "version": "1.5", + "nsfw": 0 + }, + { + "name": "Tachiyomi: HentaiFox", + "pkg": "eu.kanade.tachiyomi.extension.all.hentaifox", + "apk": "tachiyomi-hentaifox.apk", + "lang": "all", + "code": 10, + "version": "1.4.12", + "nsfw": 1 + }, + { + "name": "Tachiyomi: Asura Scans", + "pkg": "eu.kanade.tachiyomi.extension.en.asurascans", + "apk": "tachiyomi-asurascans.apk", + "lang": "en", + "code": 20, + "version": "2.0.1", + "nsfw": 0 + }, + { + "name": "Tachiyomi: Old Extension", + "pkg": "eu.kanade.tachiyomi.extension.all.oldext", + "apk": "tachiyomi-oldext.apk", + "lang": "all", + "code": 5, + "version": "1.0.0", + "nsfw": 0 + }, + { + "name": "Tachiyomi: Future Extension", + "pkg": "eu.kanade.tachiyomi.extension.all.futureext", + "apk": "tachiyomi-futureext.apk", + "lang": "all", + "code": 30, + "version": "3.0.0", + "nsfw": 0 + } + ] + """.trimIndent() + + val mockClient = OkHttpClient.Builder() + .dispatcher(okhttp3.Dispatcher(com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService())) + .addInterceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(jsonCatalog.toResponseBody("application/json".toMediaType())) + .build() + } + .build() + + val service = ExtensionRepoService(mockClient, json, appSettings) + val repo = ExternalExtensionRepo( + type = ExternalExtensionType.MIHON, + baseUrl = "https://raw.githubusercontent.com/keiyoushi/extensions/repo", + name = "Keiyoushi", + shortName = "Keiyoushi", + website = "https://keiyoushi.github.io", + signingKeyFingerprint = "abcd1234efgh5678", + createdAt = 1000L, + updatedAt = 1000L, + lastSuccessAt = 1000L, + lastError = null, + ) + + val result = service.fetchAvailableExtensions(repo) + + // Verify outdated notice package is filtered out + assertNull(result.find { it.pkgName == "eu.kanade.tachiyomi.extension.all.outdated" }) + assertNull(result.find { it.name == "Outdated App" }) + + // 5 valid extensions remain + assertEquals(5, result.size) + + // Check 2-part version parsing ("1.5" -> libVersion 1.5) + val mangafire = result.first { it.pkgName == "eu.kanade.tachiyomi.extension.all.mangafire" } + assertEquals("MangaFire", mangafire.name) + assertEquals(1.5, mangafire.libVersion, 0.001) + assertTrue(mangafire.isCompatible) + + // Check 3-part version parsing ("1.4.12" -> libVersion 1.4) + val hentaifox = result.first { it.pkgName == "eu.kanade.tachiyomi.extension.all.hentaifox" } + assertEquals("HentaiFox", hentaifox.name) + assertEquals(1.4, hentaifox.libVersion, 0.001) + assertTrue(hentaifox.isNsfw) + assertTrue(hentaifox.isCompatible) + + // Check 2.0 version parsing ("2.0.1" -> libVersion 2.0) + val asurascans = result.first { it.pkgName == "eu.kanade.tachiyomi.extension.en.asurascans" } + assertEquals("Asura Scans", asurascans.name) + assertEquals(2.0, asurascans.libVersion, 0.001) + assertTrue(asurascans.isCompatible) + + // Check too old version (1.0 < LIB_VERSION_MIN 1.2) + val oldExt = result.first { it.pkgName == "eu.kanade.tachiyomi.extension.all.oldext" } + assertEquals(1.0, oldExt.libVersion, 0.001) + assertFalse(oldExt.isCompatible) + + // Check too new version (3.0 > LIB_VERSION_MAX 2.5) + val futureExt = result.first { it.pkgName == "eu.kanade.tachiyomi.extension.all.futureext" } + assertEquals(3.0, futureExt.libVersion, 0.001) + assertFalse(futureExt.isCompatible) + } + + @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + @Test + fun testFetchAvailableExtensions_protobufGzipSupport() = runTest { + // Construct a gzipped protobuf response using test data + val baos = java.io.ByteArrayOutputStream() + java.util.zip.GZIPOutputStream(baos).use { gzipOut -> + // Write a simple protobuf payload or invoke fetch + val sampleProto = kotlinx.serialization.protobuf.ProtoBuf.encodeToByteArray( + TestStore.serializer(), + ExtensionRepoServiceProtoTestHelper.sampleStore, + ) + gzipOut.write(sampleProto) + } + val gzippedBytes = baos.toByteArray() + + val mockClient = OkHttpClient.Builder() + .dispatcher(okhttp3.Dispatcher(com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService())) + .addInterceptor { chain -> + val requestUrl = chain.request().url.toString() + if (requestUrl.endsWith("/index.pb")) { + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(gzippedBytes.toResponseBody("application/octet-stream".toMediaType())) + .build() + } else { + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(404) + .message("Not Found") + .body(ByteArray(0).toResponseBody(null)) + .build() + } + } + .build() + + val service = ExtensionRepoService(mockClient, json, appSettings) + val repo = ExternalExtensionRepo( + type = ExternalExtensionType.MIHON, + baseUrl = "https://raw.githubusercontent.com/keiyoushi/extensions/repo", + name = "Keiyoushi", + shortName = "Keiyoushi", + website = "https://keiyoushi.github.io", + signingKeyFingerprint = "abcd1234efgh5678", + createdAt = 1000L, + updatedAt = 1000L, + lastSuccessAt = 1000L, + lastError = null, + ) + + val result = service.fetchAvailableExtensions(repo) + assertEquals(1, result.size) + val ext = result.first() + assertEquals("AHottie", ext.name) + assertEquals("eu.kanade.tachiyomi.extension.all.ahottie", ext.pkgName) + assertEquals(1.6, ext.libVersion, 0.001) + assertTrue(ext.isCompatible) + } + + @Test + fun testFetchAvailableExtensions_realKeiyoushiIndexPb() = runTest { + val pbFile = java.io.File("/tmp/index.pb") + if (!pbFile.exists()) return@runTest + val realBytes = pbFile.readBytes() + + val mockClient = OkHttpClient.Builder() + .dispatcher(okhttp3.Dispatcher(com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService())) + .addInterceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(realBytes.toResponseBody("application/octet-stream".toMediaType())) + .build() + } + .build() + + val service = ExtensionRepoService(mockClient, json, appSettings) + val repo = ExternalExtensionRepo( + type = ExternalExtensionType.MIHON, + baseUrl = "https://raw.githubusercontent.com/keiyoushi/extensions/repo", + name = "Keiyoushi", + shortName = "Keiyoushi", + website = "https://keiyoushi.github.io", + signingKeyFingerprint = "abcd1234efgh5678", + createdAt = 1000L, + updatedAt = 1000L, + lastSuccessAt = 1000L, + lastError = null, + ) + + val result = service.fetchAvailableExtensions(repo) + assertTrue("Real Keiyoushi repo should have > 1000 extensions, got ${result.size}", result.size > 1000) + } + + @Test + fun testNormalizeIndexUrl() { + val service = ExtensionRepoService(OkHttpClient(), json, appSettings) + + val normalized = service.normalizeIndexUrl("https://raw.githubusercontent.com/keiyoushi/extensions/repo") + assertEquals("https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json", normalized) + + val base = service.baseUrlFromIndexUrl(normalized!!) + assertEquals("https://raw.githubusercontent.com/keiyoushi/extensions/repo", base) + + // Test GitHub raw URL normalization + val ghRaw = service.normalizeIndexUrl("https://github.com/keiyoushi/extensions/raw/repo/index.pb") + assertEquals("https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json", ghRaw) + + val ghTree = service.normalizeIndexUrl("https://github.com/keiyoushi/extensions/tree/repo") + assertEquals("https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json", ghTree) + } +} + +@kotlinx.serialization.Serializable +private data class TestStore( + @kotlinx.serialization.protobuf.ProtoNumber(1) val name: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(2) val badgeLabel: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(3) val signingKey: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(101) val extensionList: TestExtensionList? = null, +) + +@kotlinx.serialization.Serializable +private data class TestExtensionList( + @kotlinx.serialization.protobuf.ProtoNumber(1) val extensions: List = emptyList(), +) + +@kotlinx.serialization.Serializable +private data class TestExtension( + @kotlinx.serialization.protobuf.ProtoNumber(1) val name: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(2) val packageName: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(3) val resources: TestResources = TestResources(), + @kotlinx.serialization.protobuf.ProtoNumber(4) val extensionLib: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(5) val versionCode: Long = 0L, + @kotlinx.serialization.protobuf.ProtoNumber(6) val versionName: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(7) val contentWarning: Int = 0, + @kotlinx.serialization.protobuf.ProtoNumber(8) val sources: List = emptyList(), +) + +@kotlinx.serialization.Serializable +private data class TestResources( + @kotlinx.serialization.protobuf.ProtoNumber(1) val apkUrl: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(2) val iconUrl: String = "", +) + +@kotlinx.serialization.Serializable +private data class TestSource( + @kotlinx.serialization.protobuf.ProtoNumber(1) val id: Long = 0L, + @kotlinx.serialization.protobuf.ProtoNumber(2) val name: String = "", + @kotlinx.serialization.protobuf.ProtoNumber(3) val language: String = "", +) + +private object ExtensionRepoServiceProtoTestHelper { + val sampleStore = TestStore( + name = "Keiyoushi", + badgeLabel = "KEI", + signingKey = "abcd1234efgh5678", + extensionList = TestExtensionList( + extensions = listOf( + TestExtension( + name = "Tachiyomi: AHottie", + packageName = "eu.kanade.tachiyomi.extension.all.ahottie", + resources = TestResources( + apkUrl = "https://github.com/keiyoushi/extensions/releases/download/a76c957-0/tachiyomi-all.ahottie-v1.6.4.apk", + iconUrl = "https://cdn.jsdelivr.net/gh/keiyoushi/extensions-source@main/src/all/ahottie/res/mipmap-xhdpi/ic_launcher.png", + ), + extensionLib = "1.6", + versionCode = 1, + versionName = "1.6.4", + contentWarning = 0, + sources = listOf( + TestSource(id = 1L, name = "AHottie", language = "all"), + ), + ) + ) + ) + ) +} + From e72d58578a4c1c68c181da88fed790e1b0e48804 Mon Sep 17 00:00:00 2001 From: LandWarderer2772 Date: Sun, 16 Aug 2026 20:48:53 +0530 Subject: [PATCH 2/2] chore: update version, parsers --- CHANGELOG.md | 14 ++++++++++++++ app/build.gradle | 4 ++-- gradle/libs.versions.toml | 3 ++- metadata/en-US/changelogs/90801.txt | 12 ++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 metadata/en-US/changelogs/90801.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index ca694121b4..9726a37e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented in this file. The format is based on "Keep a Changelog" and follows semantic versioning where possible. +## 9.8.1 +Date: 2026-08-16 + +### Highlights +- New mihon source settings feature +- Support for multiple extension repo +- Support for .pb files in extension manager + +### Fixes +- Fixed extension manager updates error + +### Maintenance +- Updated parsers and dependencies + ## 9.8 Date: 2026-07-18 diff --git a/app/build.gradle b/app/build.gradle index 8f289ecd77..e35e0807d6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,8 +29,8 @@ android { applicationId 'io.github.landwarderer.futon' minSdk = 23 targetSdk = 36 - versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 90800 - versionName project.hasProperty('versionName') ? project.property('versionName') : '9.8' + versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 90801 + versionName project.hasProperty('versionName') ? project.property('versionName') : '9.8.1' generatedDensities = [] testInstrumentationRunner 'io.github.landwarderer.futon.HiltTestRunner' ksp { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd913844fa..c3d147d631 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ material = "1.14.0-alpha05" moshi = "1.15.2" okhttp = "5.2.1" okio = "3.16.1" -parsers = "f287c414a6" +parsers = "204c9ee032" preference = "1.2.1" quickjsKt = "1.0.5" recyclerview = "1.4.0" @@ -111,6 +111,7 @@ kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "serialization" } kotlinx-serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" } +kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" } lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } diff --git a/metadata/en-US/changelogs/90801.txt b/metadata/en-US/changelogs/90801.txt new file mode 100644 index 0000000000..1457040cc4 --- /dev/null +++ b/metadata/en-US/changelogs/90801.txt @@ -0,0 +1,12 @@ +Date: 2026-08-16 + +Highlights +- New mihon source settings feature +- Support for multiple extension repo +- Support for .pb files in extension manager + +Fixes +- Fixed extension manager updates error + +Maintenance +- Updated parsers and dependencies