From 42fef654b17f19f65cd20bf9c21b6dbc3631778e Mon Sep 17 00:00:00 2001 From: Ash Date: Sat, 11 Apr 2026 13:04:35 -0300 Subject: [PATCH 01/13] feat: add mihon extension support --- .idea/gradle.xml | 3 +- .../futon/backups/domain/AppBackupAgent.kt | 5 +- .../exceptions/UnsupportedSourceException.kt | 4 +- .../futon/core/model/MangaSource.kt | 26 +++ .../futon/core/parser/EmptyMangaRepository.kt | 12 +- .../futon/core/parser/MangaRepository.kt | 10 + .../core/parser/mihon/MihonDataConverters.kt | 141 ++++++++++++ .../parser/mihon/MihonExtensionManager.kt | 44 ++++ .../core/parser/mihon/MihonMangaRepository.kt | 211 ++++++++++++++++++ .../mihon/loader/ChildFirstPathClassLoader.kt | 44 ++++ .../mihon/loader/MihonExtensionLoader.kt | 82 +++++++ .../core/parser/mihon/loader/MihonModule.kt | 32 +++ .../parser/mihon/model/MihonMangaSource.kt | 26 +++ .../explore/data/MangaSourcesRepository.kt | 72 +++--- .../sources/catalog/SourceCatalogItem.kt | 4 +- .../sources/catalog/SourceCatalogItemAD.kt | 1 + .../sources/manage/SourcesListProducer.kt | 24 +- gradle/gradle-daemon-jvm.properties | 12 + 18 files changed, 701 insertions(+), 52 deletions(-) create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonDataConverters.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt create mode 100644 gradle/gradle-daemon-jvm.properties 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/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..ec58354a9d 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,13 +8,14 @@ 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.parser.mihon.MihonExtensionManager 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.reader.data.TapGridSettings +import kotlinx.coroutines.runBlocking import java.io.File import java.io.FileDescriptor import java.io.FileInputStream @@ -48,6 +49,7 @@ class AppBackupAgent : BackupAgent() { context = applicationContext, db = MangaDatabase(context = applicationContext), settings = AppSettings(applicationContext), + mihonExtensionManager = MihonExtensionManager(applicationContext), ), savedFiltersRepository = SavedFiltersRepository( context = applicationContext, @@ -81,6 +83,7 @@ class AppBackupAgent : BackupAgent() { context = applicationContext, db = MangaDatabase(context = applicationContext), settings = AppSettings(applicationContext), + mihonExtensionManager = MihonExtensionManager(applicationContext), ), savedFiltersRepository = SavedFiltersRepository( context = applicationContext, 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..cc0f0d6b94 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 @@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat import androidx.core.text.inSpans import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource +import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource 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 @@ -42,6 +43,27 @@ fun MangaSource(name: String?): MangaSource { val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource return ExternalMangaSource(packageName = parts.first, authority = parts.second) } + if (name.startsWith("mihon:")) { + val parts = name.substringAfter(':').split(':') + if (parts.size >= 3) { + val packageName = parts[2] + var className = parts.getOrNull(3) ?: "" + if (className.startsWith(".")) { + className = packageName + className + } + var factoryClassName = parts.getOrNull(4) + if (factoryClassName?.startsWith(".") == true) { + factoryClassName = packageName + factoryClassName + } + return MihonMangaSource( + id = parts[0].toLongOrNull() ?: 0L, + title = parts[1], + packageName = packageName, + className = className, + factoryClassName = factoryClassName + ) + } + } MangaParserSource.entries.forEach { if (it.name == name) return it } @@ -98,9 +120,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.name 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/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..3b078884ba 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 @@ -11,6 +11,9 @@ import io.github.landwarderer.futon.core.model.TestMangaSource 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.core.parser.mihon.MihonMangaRepository +import io.github.landwarderer.futon.core.parser.mihon.loader.MihonModule +import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource import io.github.landwarderer.futon.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.model.Manga @@ -60,6 +63,7 @@ interface MangaRepository { private val loaderContext: MangaLoaderContext, private val contentCache: MemoryContentCache, private val mirrorSwitcher: MirrorSwitcher, + private val mihonModule: MihonModule, ) { private val cache = ArrayMap>() @@ -106,6 +110,12 @@ interface MangaRepository { EmptyMangaRepository(source) } + is MihonMangaSource -> MihonMangaRepository( + source = source, + mihonModule = mihonModule, + cache = contentCache, + ) + else -> null } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonDataConverters.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonDataConverters.kt new file mode 100644 index 0000000000..95b01e6e82 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonDataConverters.kt @@ -0,0 +1,141 @@ +package io.github.landwarderer.futon.core.parser.mihon + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import java.util.Locale + +/** + * Utility functions to convert data models between Mihon (Tachiyomi) and Futon (Kotatsu). + * Since Mihon uses its own internal models (SManga, SChapter, Page), we need to map + * them to the models used by the Futon parser layer using reflection. + */ +object MihonDataConverters { + + fun toFutonManga(mihonManga: Any, source: MangaSource): Manga { + val url = getStringField(mihonManga, "url") ?: "" + val title = getStringField(mihonManga, "title") ?: "" + val thumbnail = getStringField(mihonManga, "thumbnail_url") + val author = getStringField(mihonManga, "author") + val artist = getStringField(mihonManga, "artist") + val genre = getStringField(mihonManga, "genre") + val status = getIntField(mihonManga, "status") + + return Manga( + id = (source.name + url).hashCode().toLong(), + title = title, + altTitles = emptySet(), + url = url, + publicUrl = url, + rating = 0f, + contentRating = null, + coverUrl = thumbnail, + tags = genre?.split(",")?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { MangaTag(it, it, source) } + ?.toSet() ?: emptySet(), + state = mapStatus(status), + authors = listOfNotNull(author, artist).toSet(), + source = source + ) + } + + fun toFutonChapter(mihonChapter: Any, source: MangaSource): MangaChapter { + val url = getStringField(mihonChapter, "url") ?: "" + val name = getStringField(mihonChapter, "name") ?: "" + val dateUpload = getLongField(mihonChapter, "date_upload") ?: 0L + val chapterNumber = getFloatField(mihonChapter, "chapter_number") ?: -1f + val scanlator = getStringField(mihonChapter, "scanlator") + + return MangaChapter( + id = (source.name + url).hashCode().toLong(), + title = name, + url = url, + number = chapterNumber, + volume = 0, + scanlator = scanlator, + uploadDate = dateUpload, + branch = null, + source = source + ) + } + + fun toFutonPage(mihonPage: Any, source: MangaSource): MangaPage { + val index = getIntField(mihonPage, "index") ?: 0 + val url = getStringField(mihonPage, "url") ?: "" + val imageUrl = getStringField(mihonPage, "imageUrl") + + return MangaPage( + id = index.toLong(), + url = imageUrl ?: url, + preview = null, + source = source + ) + } + + private fun mapStatus(status: Int?): MangaState? = when (status) { + 1 -> MangaState.ONGOING // SManga.ONGOING + 2 -> MangaState.FINISHED // SManga.COMPLETED + 3 -> MangaState.PAUSED // SManga.LICENSED (closest match) + else -> null + } + + private fun getStringField(obj: Any, name: String): String? { + return try { + val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") + field.invoke(obj) as? String + } catch (e: Exception) { + try { + val field = obj.javaClass.getField(name) + field.get(obj) as? String + } catch (e2: Exception) { + null + } + } + } + + private fun getIntField(obj: Any, name: String): Int? { + return try { + val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") + field.invoke(obj) as? Int + } catch (e: Exception) { + try { + val field = obj.javaClass.getField(name) + field.get(obj) as? Int + } catch (e2: Exception) { + null + } + } + } + + private fun getLongField(obj: Any, name: String): Long? { + return try { + val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") + field.invoke(obj) as? Long + } catch (e: Exception) { + try { + val field = obj.javaClass.getField(name) + field.get(obj) as? Long + } catch (e2: Exception) { + null + } + } + } + + private fun getFloatField(obj: Any, name: String): Float? { + return try { + val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") + field.invoke(obj) as? Float + } catch (e: Exception) { + try { + val field = obj.javaClass.getField(name) + field.get(obj) as? Float + } catch (e2: Exception) { + null + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt new file mode 100644 index 0000000000..a384db9ef1 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt @@ -0,0 +1,44 @@ +package io.github.landwarderer.futon.core.parser.mihon + +import android.content.Context +import androidx.collection.ArrayMap +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.landwarderer.futon.core.parser.mihon.loader.MihonExtensionLoader +import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages Mihon (Tachiyomi) extensions, including discovery and life cycle. + */ +@Singleton +class MihonExtensionManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val loader = MihonExtensionLoader(context) + private val sources = ArrayMap() + + /** + * Scans for installed Mihon extensions and updates the internal cache. + * @return A list of found [MihonMangaSource]s. + */ + fun findExtensions(): List { + val found = loader.loadExtensions() + synchronized(sources) { + sources.clear() + for (source in found) { + sources[source.id] = source + } + } + return found + } + + /** + * Retrieves a [MihonMangaSource] by its ID. + */ + fun getSource(id: Long): MihonMangaSource? { + return synchronized(sources) { + sources[id] + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt new file mode 100644 index 0000000000..886ba19df6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt @@ -0,0 +1,211 @@ +package io.github.landwarderer.futon.core.parser.mihon + +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import io.github.landwarderer.futon.core.parser.CachingMangaRepository +import io.github.landwarderer.futon.core.parser.mihon.loader.ChildFirstPathClassLoader +import io.github.landwarderer.futon.core.parser.mihon.loader.MihonModule +import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource +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.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder + +/** + * A repository that delegates calls to a Mihon (Tachiyomi) extension. + * It handles the initialization of the extension's environment and maps + * its data models to Futon's models. + */ +class MihonMangaRepository( + override val source: MihonMangaSource, + private val mihonModule: MihonModule, + cache: MemoryContentCache +) : CachingMangaRepository(cache) { + + private var internalSource: Any? = null + + override val sortOrders: Set + get() = setOf(SortOrder.POPULARITY, SortOrder.UPDATED, SortOrder.RELEVANCE) + + override var defaultSortOrder: SortOrder = SortOrder.POPULARITY + + @Suppress("OPT_IN_USAGE") + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = false + ) + + override suspend fun getFilterOptions(): MangaListFilterOptions { + return withContext(Dispatchers.IO) { + try { + val src = getInternalSource() + val getFilterList = src.javaClass.getMethod("getFilterList") + val filterList = getFilterList.invoke(src) as List<*> + + val tags = mutableSetOf() + for (filter in filterList) { + if (filter == null) continue + // Handle Filter.Tag and Filter.Group which often contain tags + if (filter.javaClass.name.endsWith(".Filter\$Tag") || filter.javaClass.name.endsWith(".Filter\$CheckBox")) { + val name = filter.javaClass.getMethod("getName").invoke(filter) as String + tags.add(MangaTag(name, name, source)) + } else if (filter.javaClass.name.endsWith(".Filter\$Group")) { + val state = filter.javaClass.getMethod("getState").invoke(filter) as List<*> + for (subFilter in state) { + if (subFilter == null) continue + val name = subFilter.javaClass.getMethod("getName").invoke(subFilter) as String + tags.add(MangaTag(name, name, source)) + } + } + } + + if (tags.isEmpty()) return@withContext MangaListFilterOptions() + + @Suppress("OPT_IN_USAGE") + MangaListFilterOptions(availableTags = tags) + } catch (e: Exception) { + MangaListFilterOptions() + } + } + } + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = + withContext(Dispatchers.IO) { + val src = getInternalSource() + val page = (offset / 20) + 1 // Mihon usually uses 1-based page index + + val observable = if (filter?.query?.isNotEmpty() == true) { + val fetchSearchManga = src.javaClass.getMethod("fetchSearchManga", Int::class.java, String::class.java, Any::class.java) + fetchSearchManga.invoke(src, page, filter.query, getEmptyFilterList(src)) + } else if (order == SortOrder.UPDATED) { + val fetchLatestUpdates = src.javaClass.getMethod("fetchLatestUpdates", Int::class.java) + fetchLatestUpdates.invoke(src, page) + } else { + val fetchPopularManga = src.javaClass.getMethod("fetchPopularManga", Int::class.java) + fetchPopularManga.invoke(src, page) + } + + val mangapage = observable.javaClass.getMethod("toBlocking").invoke(observable) + .javaClass.getMethod("first").invoke(observable.javaClass.getMethod("toBlocking").invoke(observable)) + + val mangas = mangapage.javaClass.getField("mangas").get(mangapage) as List<*> + mangas.map { MihonDataConverters.toFutonManga(it!!, source) } + } + + override suspend fun getDetailsImpl(manga: Manga): Manga = withContext(Dispatchers.IO) { + val src = getInternalSource() + val classLoader = src.javaClass.classLoader!! + val mihonManga = classLoader.loadClass("eu.kanade.tachiyomi.source.model.SManga").getDeclaredConstructor().newInstance() + mihonManga.javaClass.getMethod("setUrl", String::class.java).invoke(mihonManga, manga.url) + + val fetchMangaDetails = src.javaClass.getMethod("fetchMangaDetails", mihonManga.javaClass) + val observableManga = fetchMangaDetails.invoke(src, mihonManga) + val detailedMihonManga = observableManga.javaClass.getMethod("toBlocking").invoke(observableManga) + .javaClass.getMethod("first").invoke(observableManga.javaClass.getMethod("toBlocking").invoke(observableManga)) + + val fetchChapterList = src.javaClass.getMethod("fetchChapterList", mihonManga.javaClass) + val observableChapters = fetchChapterList.invoke(src, mihonManga) + val mihonChapters = observableChapters.javaClass.getMethod("toBlocking").invoke(observableChapters) + .javaClass.getMethod("first").invoke(observableChapters.javaClass.getMethod("toBlocking").invoke(observableChapters)) as List<*> + + MihonDataConverters.toFutonManga(detailedMihonManga!!, source).copy( + id = manga.id, // Keep original ID + chapters = mihonChapters.map { MihonDataConverters.toFutonChapter(it!!, source) } + ) + } + + override suspend fun getPagesImpl(chapter: MangaChapter): List = withContext(Dispatchers.IO) { + val src = getInternalSource() + val classLoader = src.javaClass.classLoader!! + val mihonChapter = classLoader.loadClass("eu.kanade.tachiyomi.source.model.SChapter").getDeclaredConstructor().newInstance() + mihonChapter.javaClass.getMethod("setUrl", String::class.java).invoke(mihonChapter, chapter.url) + + val fetchPageList = src.javaClass.getMethod("fetchPageList", mihonChapter.javaClass) + val observable = fetchPageList.invoke(src, mihonChapter) + val pages = observable.javaClass.getMethod("toBlocking").invoke(observable) + .javaClass.getMethod("first").invoke(observable.javaClass.getMethod("toBlocking").invoke(observable)) as List<*> + + pages.map { MihonDataConverters.toFutonPage(it!!, source) } + } + + override suspend fun getPageUrl(page: MangaPage): String = withContext(Dispatchers.IO) { + // If URL is already an image (usually true for simple sources), return it + if (page.url.endsWith(".jpg") || page.url.endsWith(".png") || page.url.endsWith(".webp")) { + return@withContext page.url + } + + val src = getInternalSource() + val classLoader = src.javaClass.classLoader!! + val mihonPage = classLoader.loadClass("eu.kanade.tachiyomi.source.model.Page") + .getConstructor(Int::class.java, String::class.java, String::class.java) + .newInstance(page.id.toInt(), "", page.url) + + val fetchImageUrl = src.javaClass.getMethod("fetchImageUrl", mihonPage.javaClass) + val observable = fetchImageUrl.invoke(src, mihonPage) + val imageUrl = observable.javaClass.getMethod("toBlocking").invoke(observable) + .javaClass.getMethod("first").invoke(observable.javaClass.getMethod("toBlocking").invoke(observable)) as String + + imageUrl + } + + override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() + + private fun getInternalSource(): Any { + internalSource?.let { return it } + synchronized(this) { + internalSource?.let { return it } + val pkgInfo = mihonModule.application.packageManager.getPackageInfo(source.packageName, 0) + val appInfo = pkgInfo.applicationInfo!! + val dexPath = buildString { + append(appInfo.sourceDir) + appInfo.splitSourceDirs?.forEach { + append(java.io.File.pathSeparator) + append(it) + } + } + val classLoader = ChildFirstPathClassLoader( + dexPath, + appInfo.nativeLibraryDir, + mihonModule.application.classLoader + ) + + val sourceClass = if (source.factoryClassName != null) { + val factoryClass = classLoader.loadClass(source.factoryClassName) + val factory = factoryClass.getDeclaredConstructor().newInstance() + val createSources = factoryClass.getMethod("createSources") + val sources = createSources.invoke(factory) as List<*> + return sources.first { it!!.javaClass.name == source.className }!! + } else { + classLoader.loadClass(source.className) + } + + val instance = try { + sourceClass.getDeclaredConstructor().newInstance() + } catch (e: Exception) { + // Some sources might have different constructor patterns, but usually it's empty + sourceClass.getConstructor().newInstance() + } + + // Initialize HttpSource if applicable + try { + val setClient = sourceClass.getMethod("setClient", mihonModule.httpClient.javaClass) + setClient.invoke(instance, mihonModule.httpClient) + } catch (e: Exception) {} + + internalSource = instance + return instance + } + } + + private fun getEmptyFilterList(src: Any): Any { + val classLoader = src.javaClass.classLoader!! + val filterListClass = classLoader.loadClass("eu.kanade.tachiyomi.source.model.FilterList") + return filterListClass.getConstructor(List::class.java).newInstance(emptyList()) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt new file mode 100644 index 0000000000..cdd3b3f768 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt @@ -0,0 +1,44 @@ +package io.github.landwarderer.futon.core.parser.mihon.loader + +import dalvik.system.PathClassLoader + +/** + * A custom [ClassLoader] that prioritizes loading classes from the extension APK before + * delegating to the parent ClassLoader. This is used to prevent dependency clashes + * between the host app and the Mihon plugin. + * + * Specific prefixes (like kotlin.*, android.*, etc.) are always delegated to the parent + * to ensure compatibility with shared system and app APIs. + */ +class ChildFirstPathClassLoader( + dexPath: String, + librarySearchPath: String?, + parent: ClassLoader +) : PathClassLoader(dexPath, librarySearchPath, parent) { + + private val parentClassLoader = parent + + override fun loadClass(name: String, resolve: Boolean): Class<*> { + // Always delegate these to parent + if (name.startsWith("java.") || + name.startsWith("javax.") || + name.startsWith("android.") || + name.startsWith("androidx.") || + name.startsWith("kotlin.") || + name.startsWith("kotlinx.serialization.") || + name.startsWith("okhttp3.") || + name.startsWith("okio.") || + (name.startsWith("eu.kanade.tachiyomi.") && !name.startsWith("eu.kanade.tachiyomi.extension.")) || + name.startsWith("org.koitharu.kotatsu.parsers.") // Internal Futon/Kotatsu parser API + ) { + return parentClassLoader.loadClass(name) + } + + // Try loading from child first + return try { + findClass(name) + } catch (e: ClassNotFoundException) { + parentClassLoader.loadClass(name) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt new file mode 100644 index 0000000000..e52d08de01 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt @@ -0,0 +1,82 @@ +package io.github.landwarderer.futon.core.parser.mihon.loader + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource + +/** + * Loads and scans installed Mihon (Tachiyomi) extensions from the system. + */ +class MihonExtensionLoader(private val context: Context) { + + private val packageManager = context.packageManager + + /** + * Scans all installed packages for Mihon extensions. + * An extension is identified by the "tachiyomi.extension" metadata in its manifest. + */ + fun loadExtensions(): List { + val extensions = mutableListOf() + val installedPackages = getInstalledPackages() + + for (pkg in installedPackages) { + val ai = pkg.applicationInfo ?: continue + if (ai.metaData == null) continue + + var extensionClass = ai.metaData.get(METADATA_SOURCE_CLASS)?.toString() + var extensionFactory = ai.metaData.get(METADATA_SOURCE_FACTORY)?.toString() + + if (extensionClass == null && extensionFactory == null) continue + + if (extensionClass != null && extensionClass.startsWith(".")) { + extensionClass = ai.packageName + extensionClass + } + if (extensionFactory != null && extensionFactory.startsWith(".")) { + extensionFactory = ai.packageName + extensionFactory + } + + val name = packageManager.getApplicationLabel(ai).toString().replace("Mihon: ", "").replace("Tachiyomi: ", "") + val libVersion = ai.metaData.get(METADATA_LIB_VERSION)?.toString() + + // Mihon extensions usually have a lib version between 1.2 and 1.9 + if (libVersion != null && !isSupportedLibVersion(libVersion)) { + continue + } + + // We don't instantiate the source here, just collect metadata + // The actual instantiation happens when the repository is created + extensions.add( + MihonMangaSource( + id = ai.packageName.hashCode().toLong(), // Placeholder ID, actual ID comes from source + title = name, + packageName = ai.packageName, + className = extensionClass ?: "", + factoryClassName = extensionFactory + ) + ) + } + return extensions + } + + private fun getInstalledPackages(): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PackageManager.GET_META_DATA.toLong())) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(PackageManager.GET_META_DATA) + } + } + + private fun isSupportedLibVersion(version: String): Boolean { + val v = version.toDoubleOrNull() ?: return true + return v >= 1.2 + } + + companion object { + private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" + private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" + private const val METADATA_LIB_VERSION = "tachiyomi.extension.lib.version" + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt new file mode 100644 index 0000000000..a7611b68d9 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt @@ -0,0 +1,32 @@ +package io.github.landwarderer.futon.core.parser.mihon.loader + +import android.app.Application +import android.content.Context +import io.github.landwarderer.futon.core.network.MangaHttpClient +import io.github.landwarderer.futon.core.network.cookies.MutableCookieJar +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Bridge class to provide host app dependencies to Mihon extensions. + * Mihon extensions expect these via Injekt, but since Futon uses Hilt, + * we provide a way to access them. + */ +@Singleton +class MihonModule @Inject constructor( + val application: Application, + @MangaHttpClient val httpClient: OkHttpClient, + val cookieJar: MutableCookieJar +) { + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + /** + * Returns a [Context] compatible with Mihon extensions. + */ + fun getContext(): Context = application +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt new file mode 100644 index 0000000000..a5e9c7851b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt @@ -0,0 +1,26 @@ +package io.github.landwarderer.futon.core.parser.mihon.model + +import org.koitharu.kotatsu.parsers.model.MangaSource + +/** + * Represents a Mihon (Tachiyomi) extension source within the Futon app. + * + * @property id The unique identifier for the source, usually provided by the extension. + * @property title The display name of the source. + * @property packageName The Android package name of the extension APK. + * @property className The fully qualified name of the source class in the extension. + * @property factoryClassName Optional factory class name if the source is created via a SourceFactory. + */ +data class MihonMangaSource( + val id: Long, + val title: String, + val packageName: String, + val className: String, + val factoryClassName: String? = null +) : MangaSource { + + override val name: String + get() = "mihon:$id:$title:$packageName:$className" + (if (factoryClassName != null) ":$factoryClassName" else "") + + override fun toString(): String = name +} 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..e2434b194a 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 @@ -25,10 +15,21 @@ import io.github.landwarderer.futon.core.model.MangaSourceInfo import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource +import io.github.landwarderer.futon.core.parser.mihon.MihonExtensionManager 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 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 +44,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 +108,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 +121,25 @@ 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 { (it as? MangaParserSource)?.locale == locale || it !is MangaParserSource } } if (excludeBroken) { - sources.removeAll { it.isBroken } + sources.removeAll { (it as? MangaParserSource)?.isBroken == true } } if (types.isNotEmpty()) { - sources.retainAll { it.contentType in types } + sources.retainAll { (it as? MangaParserSource)?.contentType in types || it !is MangaParserSource } } if (!query.isNullOrEmpty()) { sources.retainAll { @@ -324,7 +335,7 @@ class MangaSourcesRepository @Inject constructor( } } - private fun observeExternalSources(): Flow> { + private fun observeExternalSources(): Flow> { return callbackFlow { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -353,13 +364,17 @@ class MangaSourcesRepository @Inject constructor( .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.findExtensions() + return external + mihon } private fun List.toSources( @@ -401,5 +416,8 @@ class MangaSourcesRepository @Inject constructor( isAllSourcesEnabled } - private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this } + private fun String.toMangaSourceOrNull(): MangaSource? { + if (startsWith("mihon:")) return 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/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItem.kt index 89111f56ea..83c0ea02ab 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItem.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItem.kt @@ -3,12 +3,12 @@ package io.github.landwarderer.futon.settings.sources.catalog import androidx.annotation.DrawableRes import androidx.annotation.StringRes import io.github.landwarderer.futon.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaSource sealed interface SourceCatalogItem : ListModel { data class Source( - val source: MangaParserSource, + val source: MangaSource, ) : SourceCatalogItem { override fun areItemsTheSame(other: ListModel): Boolean { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt index 97ecca1b9e..e52a36f090 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt @@ -7,6 +7,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.getSummary import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.core.model.isBroken import io.github.landwarderer.futon.core.ui.image.FaviconDrawable import io.github.landwarderer.futon.core.ui.list.OnListItemClickListener import io.github.landwarderer.futon.core.util.ext.drawableStart diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt index 0709db060c..9dc2343dda 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt @@ -4,28 +4,26 @@ import android.content.Context import androidx.room.InvalidationTracker import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.LocalizedAppContext import io.github.landwarderer.futon.core.db.TABLE_SOURCES import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.model.isNsfw -import io.github.landwarderer.futon.core.model.unwrap import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.util.ext.lifecycleScope import io.github.landwarderer.futon.explore.data.MangaSourcesRepository import io.github.landwarderer.futon.explore.data.SourcesSortOrder -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.util.mapToSet import io.github.landwarderer.futon.settings.sources.model.SourceConfigItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.util.mapToSet import javax.inject.Inject @ViewModelScoped @@ -66,7 +64,7 @@ class SourcesListProducer @Inject constructor( } private suspend fun buildList(): List { - val enabledSources = repository.getEnabledSources().filter { it.unwrap() is MangaParserSource } + val enabledSources = repository.getEnabledSources() val pinned = repository.getPinnedSources().mapToSet { it.name } val isNsfwDisabled = settings.isNsfwContentDisabled val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..2a9fb51155 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3b229f4037ddd54a404cc0f14fe7b485/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/d9860c84f5bfba01793e5c60d3f4b531/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3b229f4037ddd54a404cc0f14fe7b485/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/d9860c84f5bfba01793e5c60d3f4b531/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5688013a87b605f5439414c4ab0925d4/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/a3ccac19d753e57e6ad2e56df868aa58/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3b229f4037ddd54a404cc0f14fe7b485/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/d9860c84f5bfba01793e5c60d3f4b531/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/10987b9025e5703fd0371a908aed9243/redirect +toolchainVendor=ORACLE +toolchainVersion=21 From cf15f06793edf6dfa53786d570736c2cc3fd21a6 Mon Sep 17 00:00:00 2001 From: Ash Date: Mon, 13 Apr 2026 14:33:14 -0300 Subject: [PATCH 02/13] fix: adjust class loader --- .../mihon/loader/ChildFirstPathClassLoader.kt | 74 +++++++++++-------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt index cdd3b3f768..4da732f1b7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt @@ -3,42 +3,54 @@ package io.github.landwarderer.futon.core.parser.mihon.loader import dalvik.system.PathClassLoader /** - * A custom [ClassLoader] that prioritizes loading classes from the extension APK before - * delegating to the parent ClassLoader. This is used to prevent dependency clashes - * between the host app and the Mihon plugin. + * A ClassLoader that loads classes from its own path before delegating to its parent. * - * Specific prefixes (like kotlin.*, android.*, etc.) are always delegated to the parent - * to ensure compatibility with shared system and app APIs. + * This is necessary for Mihon extensions because they may bundle different versions + * of libraries than Kototoro uses, and we need to isolate them. */ class ChildFirstPathClassLoader( - dexPath: String, - librarySearchPath: String?, - parent: ClassLoader + dexPath: String, + librarySearchPath: String?, + parent: ClassLoader, ) : PathClassLoader(dexPath, librarySearchPath, parent) { - private val parentClassLoader = 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<*> { - // Always delegate these to parent - if (name.startsWith("java.") || - name.startsWith("javax.") || - name.startsWith("android.") || - name.startsWith("androidx.") || - name.startsWith("kotlin.") || - name.startsWith("kotlinx.serialization.") || - name.startsWith("okhttp3.") || - name.startsWith("okio.") || - (name.startsWith("eu.kanade.tachiyomi.") && !name.startsWith("eu.kanade.tachiyomi.extension.")) || - name.startsWith("org.koitharu.kotatsu.parsers.") // Internal Futon/Kotatsu parser API - ) { - return parentClassLoader.loadClass(name) - } + override fun loadClass(name: String, resolve: Boolean): Class<*> { + // Check if we should delegate to parent immediately + if (parentPackages.any { name.startsWith(it) }) { + return parent.loadClass(name) + } - // Try loading from child first - return try { - findClass(name) - } catch (e: ClassNotFoundException) { - parentClassLoader.loadClass(name) - } - } + // Try to find the class in our own path first + return try { + findLoadedClass(name) ?: findClass(name) + } catch (e: ClassNotFoundException) { + // Fall back to parent ClassLoader + parent.loadClass(name) + } + } } From 0588b7b065da5fc532925ad499a76e7f4aab4029 Mon Sep 17 00:00:00 2001 From: Ash Date: Tue, 21 Apr 2026 15:33:57 -0300 Subject: [PATCH 03/13] feat: implemented support for mihon extensions --- app/build.gradle | 5 + .../kotlin/eu/kanade/tachiyomi/AppInfo.kt | 35 ++ .../tachiyomi/network/JavaScriptEngine.kt | 33 ++ .../kanade/tachiyomi/network/NetworkHelper.kt | 29 ++ .../tachiyomi/network/OkHttpExtensions.kt | 131 +++++++ .../tachiyomi/network/ProgressListener.kt | 8 + .../tachiyomi/network/ProgressResponseBody.kt | 51 +++ .../eu/kanade/tachiyomi/network/Requests.kt | 125 +++++++ .../interceptor/CloudflareInterceptor.kt | 14 + .../interceptor/RateLimitInterceptor.kt | 41 ++ .../SpecificHostRateLimitInterceptor.kt | 110 ++++++ .../tachiyomi/source/CatalogueSource.kt | 83 +++++ .../tachiyomi/source/ConfigurableSource.kt | 33 ++ .../tachiyomi/source/PreferenceScreen.kt | 8 + .../eu/kanade/tachiyomi/source/Source.kt | 84 +++++ .../kanade/tachiyomi/source/SourceFactory.kt | 12 + .../tachiyomi/source/UnmeteredSource.kt | 8 + .../kanade/tachiyomi/source/model/Filter.kt | 63 ++++ .../tachiyomi/source/model/MangasPage.kt | 6 + .../eu/kanade/tachiyomi/source/model/Page.kt | 52 +++ .../kanade/tachiyomi/source/model/SChapter.kt | 52 +++ .../kanade/tachiyomi/source/model/SManga.kt | 90 +++++ .../tachiyomi/source/model/UpdateStrategy.kt | 23 ++ .../tachiyomi/source/online/HttpSource.kt | 290 +++++++++++++++ .../source/online/ParsedHttpSource.kt | 126 +++++++ .../kanade/tachiyomi/util/JsoupExtensions.kt | 30 ++ .../eu/kanade/tachiyomi/util/RxExtensions.kt | 21 ++ .../futon/backups/domain/AppBackupAgent.kt | 11 +- .../github/landwarderer/futon/core/BaseApp.kt | 19 +- .../futon/core/db/MangaDatabase.kt | 14 +- .../core/db/dao/ExternalExtensionRepoDao.kt | 30 ++ .../db/entity/ExternalExtensionRepoEntity.kt | 28 ++ .../futon/core/model/MangaSource.kt | 35 +- .../futon/core/network/HttpClients.kt | 4 + .../futon/core/parser/MangaRepository.kt | 21 +- .../core/parser/mihon/MihonDataConverters.kt | 141 ------- .../parser/mihon/MihonExtensionManager.kt | 44 --- .../core/parser/mihon/MihonMangaRepository.kt | 211 ----------- .../mihon/loader/MihonExtensionLoader.kt | 82 ---- .../core/parser/mihon/loader/MihonModule.kt | 32 -- .../parser/mihon/model/MihonMangaSource.kt | 26 -- .../futon/core/prefs/AppSettings.kt | 17 +- .../futon/core/prefs/GitHubMirror.kt | 8 + .../explore/data/MangaSourcesRepository.kt | 58 ++- .../ChildFirstPathClassLoader.kt | 22 +- .../futon/mihon/GetMihonSourcesUseCase.kt | 101 +++++ .../futon/mihon/MihonExtensionLoader.kt | 343 +++++++++++++++++ .../futon/mihon/MihonExtensionManager.kt | 150 ++++++++ .../futon/mihon/MihonFilterMapper.kt | 212 +++++++++++ .../futon/mihon/MihonMangaRepository.kt | 351 ++++++++++++++++++ .../landwarderer/futon/mihon/MihonModule.kt | 61 +++ .../futon/mihon/compat/MihonInjektBridge.kt | 84 +++++ .../futon/mihon/compat/MihonNetworkHelper.kt | 341 +++++++++++++++++ .../install/ExtensionInstallService.kt | 137 +++++++ .../repo/ExtensionFingerprintTrust.kt | 15 + .../extensions/repo/ExtensionRepoService.kt | 294 +++++++++++++++ .../extensions/repo/ExternalExtensionRepo.kt | 18 + .../repo/ExternalExtensionRepoRepository.kt | 191 ++++++++++ .../extensions/repo/ExternalExtensionType.kt | 8 + .../InstalledExtensionSignatureValidator.kt | 63 ++++ .../extensions/repo/RepoAvailableExtension.kt | 19 + .../runtime/ExternalExtensionLanguage.kt | 27 ++ .../runtime/ExternalExtensionLoaderSupport.kt | 87 +++++ .../runtime/ExternalExtensionManagerFacade.kt | 113 ++++++ .../ExternalExtensionManagerRuntime.kt | 80 ++++ .../ExternalExtensionMetadataSupport.kt | 59 +++ .../ExternalExtensionPackageObserver.kt | 39 ++ .../ExternalExtensionRuntimeProcessor.kt | 81 ++++ .../ExternalExtensionSourceLoaderSupport.kt | 85 +++++ .../landwarderer/futon/mihon/model/Content.kt | 233 ++++++++++++ .../futon/mihon/model/ContentHistory.kt | 17 + .../futon/mihon/model/ContentSource.kt | 288 ++++++++++++++ .../futon/mihon/model/ContentSourceInfo.kt | 9 + .../mihon/model/ContentSourceSerializer.kt | 20 + .../futon/mihon/model/FavouriteCategory.kt | 35 ++ .../futon/mihon/model/KotatsuParserSource.kt | 27 ++ .../futon/mihon/model/MihonDataConverters.kt | 285 ++++++++++++++ .../futon/mihon/model/MihonLoadResult.kt | 65 ++++ .../futon/mihon/model/MihonMangaSource.kt | 88 +++++ .../futon/mihon/model/ParserModelMappers.kt | 182 +++++++++ .../futon/mihon/model/QuickFilter.kt | 14 + .../futon/mihon/model/SortDirection.kt | 6 + .../futon/mihon/model/ZoomMode.kt | 6 + .../model/jsonsource/LegadoBookSource.kt | 109 ++++++ .../futon/mihon/model/jsonsource/README.md | 108 ++++++ .../model/parcelable/ContentSourceParceler.kt | 16 + .../model/parcelable/ParcelableChapter.kt | 44 +++ .../model/parcelable/ParcelableContent.kt | 116 ++++++ .../parcelable/ParcelableContentListFilter.kt | 56 +++ .../model/parcelable/ParcelableContentPage.kt | 30 ++ .../model/parcelable/ParcelableContentTags.kt | 28 ++ .../futon/mihon/parsers/Broken.kt | 14 + .../parsers/CategorizedFavoritesProvider.kt | 20 + .../mihon/parsers/ContentLoaderContext.kt | 83 +++++ .../futon/mihon/parsers/ContentParser.kt | 96 +++++ .../parsers/ContentParserAuthProvider.kt | 27 ++ .../ContentParserCredentialsAuthProvider.kt | 15 + .../mihon/parsers/ContentSourceParser.kt | 28 ++ .../futon/mihon/parsers/ErrorMessages.kt | 18 + .../futon/mihon/parsers/FavoritesProvider.kt | 8 + .../mihon/parsers/FavoritesSyncProvider.kt | 10 + .../futon/mihon/parsers/InternalParsersApi.kt | 14 + .../futon/mihon/parsers/bitmap/Bitmap.kt | 9 + .../futon/mihon/parsers/bitmap/Rect.kt | 15 + .../futon/mihon/parsers/config/ConfigKey.kt | 55 +++ .../parsers/config/ContentSourceConfig.kt | 5 + .../parsers/core/AbstractContentParser.kt | 124 +++++++ .../parsers/core/ContentParserWrapper.kt | 88 +++++ .../core/FlexiblePagedContentParser.kt | 63 ++++ .../mihon/parsers/core/PagedContentParser.kt | 58 +++ .../parsers/core/SinglePageContentParser.kt | 25 ++ .../exception/AuthRequiredException.kt | 14 + .../exception/ContentUnavailableException.kt | 3 + .../parsers/exception/GraphQLException.kt | 17 + .../parsers/exception/NotFoundException.kt | 9 + .../mihon/parsers/exception/ParseException.kt | 10 + .../exception/TooManyRequestExceptions.kt | 31 ++ .../futon/mihon/parsers/model/Constants.kt | 11 + .../futon/mihon/parsers/model/Content.kt | 238 ++++++++++++ .../mihon/parsers/model/ContentChapter.kt | 90 +++++ .../mihon/parsers/model/ContentListFilter.kt | 88 +++++ .../model/ContentListFilterCapabilities.kt | 57 +++ .../parsers/model/ContentListFilterOptions.kt | 62 ++++ .../futon/mihon/parsers/model/ContentPage.kt | 33 ++ .../mihon/parsers/model/ContentRating.kt | 7 + .../mihon/parsers/model/ContentSource.kt | 10 + .../futon/mihon/parsers/model/ContentState.kt | 5 + .../futon/mihon/parsers/model/ContentTag.kt | 17 + .../mihon/parsers/model/ContentTagGroup.kt | 11 + .../futon/mihon/parsers/model/ContentType.kt | 51 +++ .../futon/mihon/parsers/model/Demographic.kt | 10 + .../futon/mihon/parsers/model/Favicon.kt | 28 ++ .../futon/mihon/parsers/model/Favicons.kt | 59 +++ .../parsers/model/NovelChapterContent.kt | 16 + .../futon/mihon/parsers/model/SortOrder.kt | 22 ++ .../futon/mihon/parsers/model/WordSet.kt | 12 + .../model/search/ContentSearchQuery.kt | 91 +++++ .../search/ContentSearchQueryCapabilities.kt | 52 +++ .../parsers/model/search/QueryCriteria.kt | 106 ++++++ .../parsers/model/search/SearchCapability.kt | 34 ++ .../parsers/model/search/SearchableField.kt | 29 ++ .../mihon/parsers/network/CloudFlareHelper.kt | 73 ++++ .../mihon/parsers/network/GZipOptions.kt | 5 + .../parsers/network/NoCookiesCookieJar.kt | 18 + .../mihon/parsers/network/OkHttpWebClient.kt | 155 ++++++++ .../futon/mihon/parsers/network/UserAgents.kt | 18 + .../futon/mihon/parsers/network/WebClient.kt | 117 ++++++ .../futon/mihon/parsers/util/Assert.kt | 8 + .../futon/mihon/parsers/util/CSSBackground.kt | 56 +++ .../futon/mihon/parsers/util/Chapters.kt | 76 ++++ .../futon/mihon/parsers/util/Collection.kt | 101 +++++ .../mihon/parsers/util/ContentParserEnv.kt | 104 ++++++ .../mihon/parsers/util/ContentParsersUtils.kt | 18 + .../parsers/util/ContinuationCallCallback.kt | 34 ++ .../futon/mihon/parsers/util/CookieJar.kt | 48 +++ .../futon/mihon/parsers/util/Coroutines.kt | 35 ++ .../futon/mihon/parsers/util/CryptoAES.kt | 128 +++++++ .../futon/mihon/parsers/util/Enum.kt | 13 + .../futon/mihon/parsers/util/FaviconParser.kt | 93 +++++ .../futon/mihon/parsers/util/Jsoup.kt | 228 ++++++++++++ .../futon/mihon/parsers/util/LinkResolver.kt | 12 + .../futon/mihon/parsers/util/Number.kt | 85 +++++ .../futon/mihon/parsers/util/OkHttp.kt | 55 +++ .../futon/mihon/parsers/util/Paginator.kt | 22 ++ .../futon/mihon/parsers/util/Parse.kt | 114 ++++++ .../parsers/util/RelatedContentFinder.kt | 74 ++++ .../futon/mihon/parsers/util/Result.kt | 41 ++ .../parsers/util/SearchQueryConverter.kt | 251 +++++++++++++ .../futon/mihon/parsers/util/String.kt | 237 ++++++++++++ .../futon/mihon/parsers/util/WebViewHelper.kt | 13 + .../mihon/parsers/util/json/EscapeUtils.kt | 38 ++ .../util/json/JSONArrayTypedIterator.kt | 33 ++ .../util/json/JSONArrayTypedListWrapper.kt | 53 +++ .../json/JSONObjectTypedIterableWrapper.kt | 24 ++ .../futon/mihon/parsers/util/json/JsonExt.kt | 168 +++++++++ .../util/suspendlazy/SoftSuspendLazyImpl.kt | 41 ++ .../parsers/util/suspendlazy/SuspendLazy.kt | 40 ++ .../util/suspendlazy/SuspendLazyImpl.kt | 47 +++ .../catalog/SourcesCatalogViewModel.kt | 65 +++- .../sources/manage/SourcesListProducer.kt | 11 +- app/src/main/res/values/arrays.xml | 12 + app/src/main/res/values/strings.xml | 9 + app/src/main/res/xml/pref_network_storage.xml | 8 + gradle/libs.versions.toml | 7 + 184 files changed, 11275 insertions(+), 618 deletions(-) create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/AppInfo.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressListener.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/RateLimitInterceptor.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/SpecificHostRateLimitInterceptor.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/ConfigurableSource.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/SourceFactory.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/UnmeteredSource.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Filter.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/util/JsoupExtensions.kt create mode 100644 app/src/main/kotlin/eu/kanade/tachiyomi/util/RxExtensions.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ExternalExtensionRepoDao.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/ExternalExtensionRepoEntity.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonDataConverters.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/GitHubMirror.kt rename app/src/main/kotlin/io/github/landwarderer/futon/{core/parser/mihon/loader => mihon}/ChildFirstPathClassLoader.kt (71%) create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/GetMihonSourcesUseCase.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionLoader.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonExtensionManager.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonFilterMapper.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonModule.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonInjektBridge.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/install/ExtensionInstallService.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionFingerprintTrust.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionRepoService.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepo.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepoRepository.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionType.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/InstalledExtensionSignatureValidator.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/RepoAvailableExtension.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLanguage.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionMetadataSupport.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionPackageObserver.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionRuntimeProcessor.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionSourceLoaderSupport.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/Content.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentHistory.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceInfo.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/FavouriteCategory.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/KotatsuParserSource.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonLoadResult.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonMangaSource.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ParserModelMappers.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableChapter.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContent.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentListFilter.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentPage.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentTags.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/Broken.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/CategorizedFavoritesProvider.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentLoaderContext.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParser.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserAuthProvider.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserCredentialsAuthProvider.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentSourceParser.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ErrorMessages.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesProvider.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesSyncProvider.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/InternalParsersApi.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Bitmap.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Rect.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ConfigKey.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ContentSourceConfig.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/AbstractContentParser.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/ContentParserWrapper.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/FlexiblePagedContentParser.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/PagedContentParser.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/SinglePageContentParser.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/AuthRequiredException.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ContentUnavailableException.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/GraphQLException.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/NotFoundException.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ParseException.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/TooManyRequestExceptions.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Constants.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Content.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentChapter.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilter.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterCapabilities.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterOptions.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentPage.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentRating.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentSource.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentState.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTag.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTagGroup.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentType.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Demographic.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicon.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicons.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/NovelChapterContent.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/SortOrder.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/WordSet.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQuery.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQueryCapabilities.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/QueryCriteria.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchCapability.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchableField.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/CloudFlareHelper.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/GZipOptions.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/NoCookiesCookieJar.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/OkHttpWebClient.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/UserAgents.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/WebClient.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Assert.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CSSBackground.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Chapters.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Collection.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParserEnv.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParsersUtils.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContinuationCallCallback.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CookieJar.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Coroutines.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CryptoAES.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Enum.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/FaviconParser.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Jsoup.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/LinkResolver.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Number.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/OkHttp.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Paginator.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Parse.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/RelatedContentFinder.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Result.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/SearchQueryConverter.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/String.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/WebViewHelper.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/EscapeUtils.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedIterator.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedListWrapper.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONObjectTypedIterableWrapper.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JsonExt.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SoftSuspendLazyImpl.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazy.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazyImpl.kt 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/kotlin/eu/kanade/tachiyomi/AppInfo.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/AppInfo.kt new file mode 100644 index 0000000000..15e849d42a --- /dev/null +++ b/app/src/main/kotlin/eu/kanade/tachiyomi/AppInfo.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi + +import io.github.landwarderer.futon.BuildConfig + +/** + * Stub class for Mihon extensions that reference AppInfo. + * Extensions may call these methods for User-Agent strings or version checks. + * + * @since extension-lib 1.3 + */ +@Suppress("UNUSED") +object AppInfo { + /** + * Version code of the host application. + */ + fun getVersionCode(): Int = BuildConfig.VERSION_CODE + + /** + * Version name of the host application. + */ + fun getVersionName(): String = BuildConfig.VERSION_NAME + + /** + * Supported image MIME types by the reader. + */ + fun getSupportedImageMimeTypes(): List = 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 ec58354a9d..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 @@ -10,10 +10,10 @@ import androidx.annotation.VisibleForTesting import com.google.common.io.ByteStreams import io.github.landwarderer.futon.backups.data.BackupRepository import io.github.landwarderer.futon.core.db.MangaDatabase -import io.github.landwarderer.futon.core.parser.mihon.MihonExtensionManager 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 @@ -22,8 +22,12 @@ 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?, @@ -39,6 +43,7 @@ class AppBackupAgent : BackupAgent() { override fun onFullBackup(data: FullBackupDataOutput) { super.onFullBackup(data) + val file = createBackupFile( this, BackupRepository( @@ -49,7 +54,7 @@ class AppBackupAgent : BackupAgent() { context = applicationContext, db = MangaDatabase(context = applicationContext), settings = AppSettings(applicationContext), - mihonExtensionManager = MihonExtensionManager(applicationContext), + mihonExtensionManager = mihonExtensionManager.get(), ), savedFiltersRepository = SavedFiltersRepository( context = applicationContext, @@ -83,7 +88,7 @@ class AppBackupAgent : BackupAgent() { context = applicationContext, db = MangaDatabase(context = applicationContext), settings = AppSettings(applicationContext), - mihonExtensionManager = MihonExtensionManager(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/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/model/MangaSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSource.kt index cc0f0d6b94..75d77d42a4 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 @@ -11,10 +11,10 @@ import androidx.core.content.ContextCompat import androidx.core.text.inSpans import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource -import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource 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 org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource @@ -43,26 +43,8 @@ fun MangaSource(name: String?): MangaSource { val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource return ExternalMangaSource(packageName = parts.first, authority = parts.second) } - if (name.startsWith("mihon:")) { - val parts = name.substringAfter(':').split(':') - if (parts.size >= 3) { - val packageName = parts[2] - var className = parts.getOrNull(3) ?: "" - if (className.startsWith(".")) { - className = packageName + className - } - var factoryClassName = parts.getOrNull(4) - if (factoryClassName?.startsWith(".") == true) { - factoryClassName = packageName + factoryClassName - } - return MihonMangaSource( - id = parts[0].toLongOrNull() ?: 0L, - title = parts[1], - packageName = packageName, - className = className, - factoryClassName = factoryClassName - ) - } + if (name.startsWith("mihon:") || name.startsWith("MIHON_")) { + return AnonymousMangaSource(name) } MangaParserSource.entries.forEach { if (it.name == name) return it @@ -70,11 +52,14 @@ fun MangaSource(name: String?): MangaSource { 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 } @@ -120,7 +105,7 @@ 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.name + is MihonMangaSource -> source.displayName else -> context.getString(R.string.unknown) } 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/parser/MangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaRepository.kt index 3b078884ba..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 @@ -11,10 +11,10 @@ import io.github.landwarderer.futon.core.model.TestMangaSource 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.core.parser.mihon.MihonMangaRepository -import io.github.landwarderer.futon.core.parser.mihon.loader.MihonModule -import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource 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 @@ -63,7 +63,7 @@ interface MangaRepository { private val loaderContext: MangaLoaderContext, private val contentCache: MemoryContentCache, private val mirrorSwitcher: MirrorSwitcher, - private val mihonModule: MihonModule, + private val mihonExtensionManager: MihonExtensionManager, ) { private val cache = ArrayMap>() @@ -112,11 +112,20 @@ interface MangaRepository { is MihonMangaSource -> MihonMangaRepository( source = source, - mihonModule = mihonModule, cache = contentCache, ) - else -> null + 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/mihon/MihonDataConverters.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonDataConverters.kt deleted file mode 100644 index 95b01e6e82..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonDataConverters.kt +++ /dev/null @@ -1,141 +0,0 @@ -package io.github.landwarderer.futon.core.parser.mihon - -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import java.util.Locale - -/** - * Utility functions to convert data models between Mihon (Tachiyomi) and Futon (Kotatsu). - * Since Mihon uses its own internal models (SManga, SChapter, Page), we need to map - * them to the models used by the Futon parser layer using reflection. - */ -object MihonDataConverters { - - fun toFutonManga(mihonManga: Any, source: MangaSource): Manga { - val url = getStringField(mihonManga, "url") ?: "" - val title = getStringField(mihonManga, "title") ?: "" - val thumbnail = getStringField(mihonManga, "thumbnail_url") - val author = getStringField(mihonManga, "author") - val artist = getStringField(mihonManga, "artist") - val genre = getStringField(mihonManga, "genre") - val status = getIntField(mihonManga, "status") - - return Manga( - id = (source.name + url).hashCode().toLong(), - title = title, - altTitles = emptySet(), - url = url, - publicUrl = url, - rating = 0f, - contentRating = null, - coverUrl = thumbnail, - tags = genre?.split(",")?.map { it.trim() } - ?.filter { it.isNotEmpty() } - ?.map { MangaTag(it, it, source) } - ?.toSet() ?: emptySet(), - state = mapStatus(status), - authors = listOfNotNull(author, artist).toSet(), - source = source - ) - } - - fun toFutonChapter(mihonChapter: Any, source: MangaSource): MangaChapter { - val url = getStringField(mihonChapter, "url") ?: "" - val name = getStringField(mihonChapter, "name") ?: "" - val dateUpload = getLongField(mihonChapter, "date_upload") ?: 0L - val chapterNumber = getFloatField(mihonChapter, "chapter_number") ?: -1f - val scanlator = getStringField(mihonChapter, "scanlator") - - return MangaChapter( - id = (source.name + url).hashCode().toLong(), - title = name, - url = url, - number = chapterNumber, - volume = 0, - scanlator = scanlator, - uploadDate = dateUpload, - branch = null, - source = source - ) - } - - fun toFutonPage(mihonPage: Any, source: MangaSource): MangaPage { - val index = getIntField(mihonPage, "index") ?: 0 - val url = getStringField(mihonPage, "url") ?: "" - val imageUrl = getStringField(mihonPage, "imageUrl") - - return MangaPage( - id = index.toLong(), - url = imageUrl ?: url, - preview = null, - source = source - ) - } - - private fun mapStatus(status: Int?): MangaState? = when (status) { - 1 -> MangaState.ONGOING // SManga.ONGOING - 2 -> MangaState.FINISHED // SManga.COMPLETED - 3 -> MangaState.PAUSED // SManga.LICENSED (closest match) - else -> null - } - - private fun getStringField(obj: Any, name: String): String? { - return try { - val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") - field.invoke(obj) as? String - } catch (e: Exception) { - try { - val field = obj.javaClass.getField(name) - field.get(obj) as? String - } catch (e2: Exception) { - null - } - } - } - - private fun getIntField(obj: Any, name: String): Int? { - return try { - val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") - field.invoke(obj) as? Int - } catch (e: Exception) { - try { - val field = obj.javaClass.getField(name) - field.get(obj) as? Int - } catch (e2: Exception) { - null - } - } - } - - private fun getLongField(obj: Any, name: String): Long? { - return try { - val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") - field.invoke(obj) as? Long - } catch (e: Exception) { - try { - val field = obj.javaClass.getField(name) - field.get(obj) as? Long - } catch (e2: Exception) { - null - } - } - } - - private fun getFloatField(obj: Any, name: String): Float? { - return try { - val field = obj.javaClass.getMethod("get${name.replaceFirstChar { it.uppercase(Locale.ROOT) }}") - field.invoke(obj) as? Float - } catch (e: Exception) { - try { - val field = obj.javaClass.getField(name) - field.get(obj) as? Float - } catch (e2: Exception) { - null - } - } - } -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt deleted file mode 100644 index a384db9ef1..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonExtensionManager.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.landwarderer.futon.core.parser.mihon - -import android.content.Context -import androidx.collection.ArrayMap -import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.landwarderer.futon.core.parser.mihon.loader.MihonExtensionLoader -import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Manages Mihon (Tachiyomi) extensions, including discovery and life cycle. - */ -@Singleton -class MihonExtensionManager @Inject constructor( - @ApplicationContext private val context: Context -) { - private val loader = MihonExtensionLoader(context) - private val sources = ArrayMap() - - /** - * Scans for installed Mihon extensions and updates the internal cache. - * @return A list of found [MihonMangaSource]s. - */ - fun findExtensions(): List { - val found = loader.loadExtensions() - synchronized(sources) { - sources.clear() - for (source in found) { - sources[source.id] = source - } - } - return found - } - - /** - * Retrieves a [MihonMangaSource] by its ID. - */ - fun getSource(id: Long): MihonMangaSource? { - return synchronized(sources) { - sources[id] - } - } -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt deleted file mode 100644 index 886ba19df6..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/MihonMangaRepository.kt +++ /dev/null @@ -1,211 +0,0 @@ -package io.github.landwarderer.futon.core.parser.mihon - -import io.github.landwarderer.futon.core.cache.MemoryContentCache -import io.github.landwarderer.futon.core.parser.CachingMangaRepository -import io.github.landwarderer.futon.core.parser.mihon.loader.ChildFirstPathClassLoader -import io.github.landwarderer.futon.core.parser.mihon.loader.MihonModule -import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource -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.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder - -/** - * A repository that delegates calls to a Mihon (Tachiyomi) extension. - * It handles the initialization of the extension's environment and maps - * its data models to Futon's models. - */ -class MihonMangaRepository( - override val source: MihonMangaSource, - private val mihonModule: MihonModule, - cache: MemoryContentCache -) : CachingMangaRepository(cache) { - - private var internalSource: Any? = null - - override val sortOrders: Set - get() = setOf(SortOrder.POPULARITY, SortOrder.UPDATED, SortOrder.RELEVANCE) - - override var defaultSortOrder: SortOrder = SortOrder.POPULARITY - - @Suppress("OPT_IN_USAGE") - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = false - ) - - override suspend fun getFilterOptions(): MangaListFilterOptions { - return withContext(Dispatchers.IO) { - try { - val src = getInternalSource() - val getFilterList = src.javaClass.getMethod("getFilterList") - val filterList = getFilterList.invoke(src) as List<*> - - val tags = mutableSetOf() - for (filter in filterList) { - if (filter == null) continue - // Handle Filter.Tag and Filter.Group which often contain tags - if (filter.javaClass.name.endsWith(".Filter\$Tag") || filter.javaClass.name.endsWith(".Filter\$CheckBox")) { - val name = filter.javaClass.getMethod("getName").invoke(filter) as String - tags.add(MangaTag(name, name, source)) - } else if (filter.javaClass.name.endsWith(".Filter\$Group")) { - val state = filter.javaClass.getMethod("getState").invoke(filter) as List<*> - for (subFilter in state) { - if (subFilter == null) continue - val name = subFilter.javaClass.getMethod("getName").invoke(subFilter) as String - tags.add(MangaTag(name, name, source)) - } - } - } - - if (tags.isEmpty()) return@withContext MangaListFilterOptions() - - @Suppress("OPT_IN_USAGE") - MangaListFilterOptions(availableTags = tags) - } catch (e: Exception) { - MangaListFilterOptions() - } - } - } - - override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = - withContext(Dispatchers.IO) { - val src = getInternalSource() - val page = (offset / 20) + 1 // Mihon usually uses 1-based page index - - val observable = if (filter?.query?.isNotEmpty() == true) { - val fetchSearchManga = src.javaClass.getMethod("fetchSearchManga", Int::class.java, String::class.java, Any::class.java) - fetchSearchManga.invoke(src, page, filter.query, getEmptyFilterList(src)) - } else if (order == SortOrder.UPDATED) { - val fetchLatestUpdates = src.javaClass.getMethod("fetchLatestUpdates", Int::class.java) - fetchLatestUpdates.invoke(src, page) - } else { - val fetchPopularManga = src.javaClass.getMethod("fetchPopularManga", Int::class.java) - fetchPopularManga.invoke(src, page) - } - - val mangapage = observable.javaClass.getMethod("toBlocking").invoke(observable) - .javaClass.getMethod("first").invoke(observable.javaClass.getMethod("toBlocking").invoke(observable)) - - val mangas = mangapage.javaClass.getField("mangas").get(mangapage) as List<*> - mangas.map { MihonDataConverters.toFutonManga(it!!, source) } - } - - override suspend fun getDetailsImpl(manga: Manga): Manga = withContext(Dispatchers.IO) { - val src = getInternalSource() - val classLoader = src.javaClass.classLoader!! - val mihonManga = classLoader.loadClass("eu.kanade.tachiyomi.source.model.SManga").getDeclaredConstructor().newInstance() - mihonManga.javaClass.getMethod("setUrl", String::class.java).invoke(mihonManga, manga.url) - - val fetchMangaDetails = src.javaClass.getMethod("fetchMangaDetails", mihonManga.javaClass) - val observableManga = fetchMangaDetails.invoke(src, mihonManga) - val detailedMihonManga = observableManga.javaClass.getMethod("toBlocking").invoke(observableManga) - .javaClass.getMethod("first").invoke(observableManga.javaClass.getMethod("toBlocking").invoke(observableManga)) - - val fetchChapterList = src.javaClass.getMethod("fetchChapterList", mihonManga.javaClass) - val observableChapters = fetchChapterList.invoke(src, mihonManga) - val mihonChapters = observableChapters.javaClass.getMethod("toBlocking").invoke(observableChapters) - .javaClass.getMethod("first").invoke(observableChapters.javaClass.getMethod("toBlocking").invoke(observableChapters)) as List<*> - - MihonDataConverters.toFutonManga(detailedMihonManga!!, source).copy( - id = manga.id, // Keep original ID - chapters = mihonChapters.map { MihonDataConverters.toFutonChapter(it!!, source) } - ) - } - - override suspend fun getPagesImpl(chapter: MangaChapter): List = withContext(Dispatchers.IO) { - val src = getInternalSource() - val classLoader = src.javaClass.classLoader!! - val mihonChapter = classLoader.loadClass("eu.kanade.tachiyomi.source.model.SChapter").getDeclaredConstructor().newInstance() - mihonChapter.javaClass.getMethod("setUrl", String::class.java).invoke(mihonChapter, chapter.url) - - val fetchPageList = src.javaClass.getMethod("fetchPageList", mihonChapter.javaClass) - val observable = fetchPageList.invoke(src, mihonChapter) - val pages = observable.javaClass.getMethod("toBlocking").invoke(observable) - .javaClass.getMethod("first").invoke(observable.javaClass.getMethod("toBlocking").invoke(observable)) as List<*> - - pages.map { MihonDataConverters.toFutonPage(it!!, source) } - } - - override suspend fun getPageUrl(page: MangaPage): String = withContext(Dispatchers.IO) { - // If URL is already an image (usually true for simple sources), return it - if (page.url.endsWith(".jpg") || page.url.endsWith(".png") || page.url.endsWith(".webp")) { - return@withContext page.url - } - - val src = getInternalSource() - val classLoader = src.javaClass.classLoader!! - val mihonPage = classLoader.loadClass("eu.kanade.tachiyomi.source.model.Page") - .getConstructor(Int::class.java, String::class.java, String::class.java) - .newInstance(page.id.toInt(), "", page.url) - - val fetchImageUrl = src.javaClass.getMethod("fetchImageUrl", mihonPage.javaClass) - val observable = fetchImageUrl.invoke(src, mihonPage) - val imageUrl = observable.javaClass.getMethod("toBlocking").invoke(observable) - .javaClass.getMethod("first").invoke(observable.javaClass.getMethod("toBlocking").invoke(observable)) as String - - imageUrl - } - - override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() - - private fun getInternalSource(): Any { - internalSource?.let { return it } - synchronized(this) { - internalSource?.let { return it } - val pkgInfo = mihonModule.application.packageManager.getPackageInfo(source.packageName, 0) - val appInfo = pkgInfo.applicationInfo!! - val dexPath = buildString { - append(appInfo.sourceDir) - appInfo.splitSourceDirs?.forEach { - append(java.io.File.pathSeparator) - append(it) - } - } - val classLoader = ChildFirstPathClassLoader( - dexPath, - appInfo.nativeLibraryDir, - mihonModule.application.classLoader - ) - - val sourceClass = if (source.factoryClassName != null) { - val factoryClass = classLoader.loadClass(source.factoryClassName) - val factory = factoryClass.getDeclaredConstructor().newInstance() - val createSources = factoryClass.getMethod("createSources") - val sources = createSources.invoke(factory) as List<*> - return sources.first { it!!.javaClass.name == source.className }!! - } else { - classLoader.loadClass(source.className) - } - - val instance = try { - sourceClass.getDeclaredConstructor().newInstance() - } catch (e: Exception) { - // Some sources might have different constructor patterns, but usually it's empty - sourceClass.getConstructor().newInstance() - } - - // Initialize HttpSource if applicable - try { - val setClient = sourceClass.getMethod("setClient", mihonModule.httpClient.javaClass) - setClient.invoke(instance, mihonModule.httpClient) - } catch (e: Exception) {} - - internalSource = instance - return instance - } - } - - private fun getEmptyFilterList(src: Any): Any { - val classLoader = src.javaClass.classLoader!! - val filterListClass = classLoader.loadClass("eu.kanade.tachiyomi.source.model.FilterList") - return filterListClass.getConstructor(List::class.java).newInstance(emptyList()) - } -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt deleted file mode 100644 index e52d08de01..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonExtensionLoader.kt +++ /dev/null @@ -1,82 +0,0 @@ -package io.github.landwarderer.futon.core.parser.mihon.loader - -import android.content.Context -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import io.github.landwarderer.futon.core.parser.mihon.model.MihonMangaSource - -/** - * Loads and scans installed Mihon (Tachiyomi) extensions from the system. - */ -class MihonExtensionLoader(private val context: Context) { - - private val packageManager = context.packageManager - - /** - * Scans all installed packages for Mihon extensions. - * An extension is identified by the "tachiyomi.extension" metadata in its manifest. - */ - fun loadExtensions(): List { - val extensions = mutableListOf() - val installedPackages = getInstalledPackages() - - for (pkg in installedPackages) { - val ai = pkg.applicationInfo ?: continue - if (ai.metaData == null) continue - - var extensionClass = ai.metaData.get(METADATA_SOURCE_CLASS)?.toString() - var extensionFactory = ai.metaData.get(METADATA_SOURCE_FACTORY)?.toString() - - if (extensionClass == null && extensionFactory == null) continue - - if (extensionClass != null && extensionClass.startsWith(".")) { - extensionClass = ai.packageName + extensionClass - } - if (extensionFactory != null && extensionFactory.startsWith(".")) { - extensionFactory = ai.packageName + extensionFactory - } - - val name = packageManager.getApplicationLabel(ai).toString().replace("Mihon: ", "").replace("Tachiyomi: ", "") - val libVersion = ai.metaData.get(METADATA_LIB_VERSION)?.toString() - - // Mihon extensions usually have a lib version between 1.2 and 1.9 - if (libVersion != null && !isSupportedLibVersion(libVersion)) { - continue - } - - // We don't instantiate the source here, just collect metadata - // The actual instantiation happens when the repository is created - extensions.add( - MihonMangaSource( - id = ai.packageName.hashCode().toLong(), // Placeholder ID, actual ID comes from source - title = name, - packageName = ai.packageName, - className = extensionClass ?: "", - factoryClassName = extensionFactory - ) - ) - } - return extensions - } - - private fun getInstalledPackages(): List { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PackageManager.GET_META_DATA.toLong())) - } else { - @Suppress("DEPRECATION") - packageManager.getInstalledPackages(PackageManager.GET_META_DATA) - } - } - - private fun isSupportedLibVersion(version: String): Boolean { - val v = version.toDoubleOrNull() ?: return true - return v >= 1.2 - } - - companion object { - private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" - private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" - private const val METADATA_LIB_VERSION = "tachiyomi.extension.lib.version" - } -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt deleted file mode 100644 index a7611b68d9..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/MihonModule.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.landwarderer.futon.core.parser.mihon.loader - -import android.app.Application -import android.content.Context -import io.github.landwarderer.futon.core.network.MangaHttpClient -import io.github.landwarderer.futon.core.network.cookies.MutableCookieJar -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Bridge class to provide host app dependencies to Mihon extensions. - * Mihon extensions expect these via Injekt, but since Futon uses Hilt, - * we provide a way to access them. - */ -@Singleton -class MihonModule @Inject constructor( - val application: Application, - @MangaHttpClient val httpClient: OkHttpClient, - val cookieJar: MutableCookieJar -) { - val json = Json { - ignoreUnknownKeys = true - explicitNulls = false - } - - /** - * Returns a [Context] compatible with Mihon extensions. - */ - fun getContext(): Context = application -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt deleted file mode 100644 index a5e9c7851b..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/model/MihonMangaSource.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.landwarderer.futon.core.parser.mihon.model - -import org.koitharu.kotatsu.parsers.model.MangaSource - -/** - * Represents a Mihon (Tachiyomi) extension source within the Futon app. - * - * @property id The unique identifier for the source, usually provided by the extension. - * @property title The display name of the source. - * @property packageName The Android package name of the extension APK. - * @property className The fully qualified name of the source class in the extension. - * @property factoryClassName Optional factory class name if the source is created via a SourceFactory. - */ -data class MihonMangaSource( - val id: Long, - val title: String, - val packageName: String, - val className: String, - val factoryClassName: String? = null -) : MangaSource { - - override val name: String - get() = "mihon:$id:$title:$packageName:$className" + (if (factoryClassName != null) ":$factoryClassName" else "") - - override fun toString(): String = name -} 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..c9455495c4 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.NATIVE) + set(value) = prefs.edit { putEnumValue(KEY_GITHUB_MIRROR, value) } + val isAutoLocalChaptersCleanupEnabled: Boolean get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false) @@ -822,6 +826,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..3a1739c12b --- /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; +} 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 e2434b194a..2840e3b954 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 @@ -15,11 +15,11 @@ import io.github.landwarderer.futon.core.model.MangaSourceInfo import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource -import io.github.landwarderer.futon.core.parser.mihon.MihonExtensionManager 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 @@ -133,13 +133,43 @@ class MangaSourcesRepository @Inject constructor( } if (locale != null) { - sources.retainAll { (it as? MangaParserSource)?.locale == locale || it !is MangaParserSource } + 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 as? MangaParserSource)?.isBroken == true } } if (types.isNotEmpty()) { - sources.retainAll { (it as? MangaParserSource)?.contentType in types || it !is MangaParserSource } + 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 { @@ -316,7 +346,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) } @@ -336,7 +368,7 @@ class MangaSourcesRepository @Inject constructor( } private fun observeExternalSources(): Flow> { - return callbackFlow { + val packageChanges = callbackFlow { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { trySendBlocking(intent) @@ -358,7 +390,13 @@ class MangaSourcesRepository @Inject constructor( awaitClose { context.unregisterReceiver(receiver) } }.onStart { emit(null) - }.map { + } + + return combine( + packageChanges, + mihonExtensionManager.installedExtensions, + mihonExtensionManager.failedExtensions, + ) { _, _, _ -> getExternalSources() }.distinctUntilChanged() .conflate() @@ -373,7 +411,7 @@ class MangaSourcesRepository @Inject constructor( authority = resolveInfo.providerInfo.authority, ) } - val mihon = mihonExtensionManager.findExtensions() + val mihon = mihonExtensionManager.getMihonMangaSources() return external + mihon } @@ -388,7 +426,7 @@ class MangaSourcesRepository @Inject constructor( if (skipNsfwSources && source.isNsfw()) { continue } - if (source in allMangaSources) { + if (source is MangaParserSource || source.name.startsWith("mihon:") || source.name.startsWith("MIHON_")) { result.add( MangaSourceInfo( mangaSource = source, @@ -417,7 +455,9 @@ class MangaSourcesRepository @Inject constructor( } private fun String.toMangaSourceOrNull(): MangaSource? { - if (startsWith("mihon:")) return io.github.landwarderer.futon.core.model.MangaSource(this) + 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/core/parser/mihon/loader/ChildFirstPathClassLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt similarity index 71% rename from app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt rename to app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt index 4da732f1b7..584d3c7993 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/mihon/loader/ChildFirstPathClassLoader.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/ChildFirstPathClassLoader.kt @@ -1,12 +1,13 @@ -package io.github.landwarderer.futon.core.parser.mihon.loader +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 Kototoro uses, and we need to isolate them. + * of libraries than App uses, and we need to isolate them. */ class ChildFirstPathClassLoader( dexPath: String, @@ -42,7 +43,11 @@ class ChildFirstPathClassLoader( override fun loadClass(name: String, resolve: Boolean): Class<*> { // Check if we should delegate to parent immediately if (parentPackages.any { name.startsWith(it) }) { - return parent.loadClass(name) + try { + return parent.loadClass(name) + } catch (e: ClassNotFoundException) { + // fall through to child loading + } } // Try to find the class in our own path first @@ -50,7 +55,14 @@ class ChildFirstPathClassLoader( findLoadedClass(name) ?: findClass(name) } catch (e: ClassNotFoundException) { // Fall back to parent ClassLoader - parent.loadClass(name) + 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..097e3d5959 --- /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 = ExternalExtensionLoaderSupport.getAppLabel(context, appInfo) + val lang = ExternalExtensionLoaderSupport.extractLanguage(pkgName, "extension") + + Log.d(TAG, "Loading extension: $pkgName (lib $libVersion, $lang)") + + // 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, + 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..826b87a27f --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt @@ -0,0 +1,351 @@ +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) ?: "", + ).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() +} 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..e0ec5b6abd --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/install/ExtensionInstallService.kt @@ -0,0 +1,137 @@ +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +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" + } + } + return url + } + + private val activeCalls = ConcurrentHashMap() + private val _downloadStates = MutableStateFlow>(emptyMap()) + + val downloadStates: StateFlow> = _downloadStates.asStateFlow() + + suspend fun createInstallIntent(extension: RepoAvailableExtension): Intent? { + val apkUrl = 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)) + check(activeCalls.putIfAbsent(extension.pkgName, call) == null) { + "Extension install download already in progress for ${extension.pkgName}" + } + updateDownloadState(extension.pkgName, bytesRead = 0L, contentLength = -1L) + try { + call.awaitSuccess().use { response -> + val body = requireNotNull(response.body) { "Missing APK response body" } + outputFile.outputStream().use { output -> + body.byteStream().use { input -> + input.copyTo(output) + } + } + } + } catch (e: IOException) { + if (call.isCanceled()) { + throw CancellationException("Extension install download cancelled for ${extension.pkgName}", e) + } + throw e + } finally { + activeCalls.remove(extension.pkgName) + _downloadStates.update { it - extension.pkgName } + } + + if (extension.type == io.github.landwarderer.futon.mihon.extensions.repo.ExternalExtensionType.JAR) { + val pluginsDir = File(context.filesDir, "plugins").apply { mkdirs() } + val jarFile = File(pluginsDir, "${extension.pkgName}.jar") + outputFile.copyTo(jarFile, overwrite = true) + outputFile.delete() + context.getSharedPreferences("jar_plugin_versions", Context.MODE_PRIVATE) + .edit { + putLong(extension.pkgName, extension.versionCode) + } + return null + } + + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", outputFile) + return Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + } + } + + fun cancelDownload(packageName: String) { + activeCalls[packageName]?.cancel() + } + + private fun updateDownloadState(packageName: String, bytesRead: Long, contentLength: Long) { + _downloadStates.update { states -> + states + (packageName to ExtensionInstallDownloadState(packageName, bytesRead, contentLength)) + } + } + + private inner class ExtensionInstallProgressListener( + private val packageName: String, + ) : ProgressListener { + + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + updateDownloadState(packageName, bytesRead, contentLength) + } + } +} + +data class ExtensionInstallDownloadState( + val packageName: String, + val bytesRead: Long, + val contentLength: Long, +) { + + val progressPercent: Int? + get() = if (contentLength <= 0L) { + null + } else { + ((bytesRead * 100L) / contentLength) + .coerceIn(0L, 100L) + .toInt() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionFingerprintTrust.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionFingerprintTrust.kt new file mode 100644 index 0000000000..97969e4bec --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionFingerprintTrust.kt @@ -0,0 +1,15 @@ +package io.github.landwarderer.futon.mihon.extensions.repo + +internal object ExtensionFingerprintTrust { + + fun isTrusted(expectedFingerprint: String, actualFingerprints: Set): Boolean { + if (expectedFingerprint.isBlank()) return true + val normalizedExpected = expectedFingerprint.normalizeExtensionFingerprint() + if (normalizedExpected.isEmpty()) return true + return actualFingerprints.any { it.normalizeExtensionFingerprint() == normalizedExpected } + } +} + +internal fun String.normalizeExtensionFingerprint(): String { + return lowercase().replace(":", "").replace(" ", "") +} 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 new file mode 100644 index 0000000000..ae499629ac --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExtensionRepoService.kt @@ -0,0 +1,294 @@ +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 kotlinx.coroutines.withTimeout +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ExtensionRepoService @Inject constructor( + @MangaHttpClient private val httpClient: OkHttpClient, + private val json: Json, + 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" + } + } + return url + } + + private fun deriveRepoName(baseUrl: String, defaultName: String): String { + val url = baseUrl.toHttpUrlOrNull() ?: return defaultName + val segments = url.pathSegments.filter { it.isNotEmpty() } + if (segments.size >= 2 && url.host.contains("githubusercontent.com")) { + return "${segments[0]}/${segments[1]}" + } else if (segments.size >= 2 && url.host == "github.com") { + return "${segments[0]}/${segments[1]}" + } else if (segments.isNotEmpty()) { + return segments.last() + } + return url.host + } + + suspend fun fetchRepoDetails(baseUrl: String, type: ExternalExtensionType): ExternalExtensionRepo { + if (type == ExternalExtensionType.IREADER || type == ExternalExtensionType.JAR) { + val now = System.currentTimeMillis() + val derived = deriveRepoName(baseUrl, if (type == ExternalExtensionType.IREADER) "IReader" else "Futon") + val repoName = if (type == ExternalExtensionType.IREADER) "IReader: $derived" else "Futon: $derived" + val repoShort = derived + var version: String? = null + if (type == ExternalExtensionType.JAR) { + val indexUrl = applyMirror("$baseUrl/index.min.json") + runCatching { + withTimeout(REPO_DETAILS_TIMEOUT_MS) { + val body = httpClient.newCall(GET(indexUrl)).awaitSuccess().use { response -> + response.body.string() + } + val dto = json.decodeFromString>(body) + version = dto.firstOrNull()?.version + } + } + } + + return ExternalExtensionRepo( + type = type, + baseUrl = baseUrl, + name = repoName, + shortName = repoShort, + website = baseUrl, + signingKeyFingerprint = baseUrl.hashCode().toString(16), // Use baseUrl hash as pseudo-fingerprint for JAR/IReader + createdAt = now, + updatedAt = now, + lastSuccessAt = now, + lastError = null, + version = version, + ) + } + + val repoJsonUrl = applyMirror("$baseUrl/repo.json") + val startedAt = System.currentTimeMillis() + Log.d(TAG, "fetchRepoDetails:start type=$type url=$repoJsonUrl") + return withTimeout(REPO_DETAILS_TIMEOUT_MS) { + val body = httpClient.newCall(GET(repoJsonUrl)).awaitSuccess().use { response -> + response.body.string() + } + val dto = json.decodeFromString(body) + val now = System.currentTimeMillis() + ExternalExtensionRepo( + type = type, + baseUrl = baseUrl, + name = dto.meta.name, + shortName = dto.meta.shortName, + website = dto.meta.website, + signingKeyFingerprint = dto.meta.signingKeyFingerprint, + createdAt = now, + updatedAt = now, + lastSuccessAt = now, + lastError = null, + ) + }.also { repo -> + Log.d( + TAG, + "fetchRepoDetails:success type=$type baseUrl=${repo.baseUrl} name=${repo.displayName} elapsedMs=${System.currentTimeMillis() - startedAt}", + ) + } + } + + suspend fun fetchAvailableExtensions(repo: ExternalExtensionRepo): List { + 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") + return runCatching { + withTimeout(CATALOG_TIMEOUT_MS) { + val body = httpClient.newCall(GET(requestUrl)).awaitSuccess().use { response -> + response.body.string() + } + if (repo.type == ExternalExtensionType.IREADER) { + val dto = json.decodeFromString>(body) + dto.asSequence() + .mapNotNull { item -> item.toAvailableExtension(repo) } + .toList() + } else { + val dto = json.decodeFromString>(body) + dto.asSequence() + .mapNotNull { item -> item.toAvailableExtension(repo) } + .toList() + } + } + }.onSuccess { extensions -> + Log.d( + TAG, + "fetchAvailableExtensions:success type=${repo.type} baseUrl=${repo.baseUrl} count=${extensions.size} elapsedMs=${System.currentTimeMillis() - startedAt}", + ) + }.onFailure { error -> + Log.e( + TAG, + "fetchAvailableExtensions:failed type=${repo.type} baseUrl=${repo.baseUrl} elapsedMs=${System.currentTimeMillis() - startedAt} message=${error.message}", + error, + ) + }.getOrDefault(emptyList()) + } + + fun normalizeIndexUrl(input: String): String? { + val processUrl = input.trim() + + val url = processUrl.toHttpUrlOrNull() ?: return null + if (url.scheme != "https") { + return null + } + val normalizedSegments = url.pathSegments + .filter { it.isNotEmpty() } + .toMutableList() + if (normalizedSegments.lastOrNull() != "index.min.json") { + normalizedSegments += "index.min.json" + } + val normalizedPath = "/" + normalizedSegments.joinToString("/") + return url.newBuilder() + .encodedPath(normalizedPath) + .fragment(null) + .query(null) + .build() + .toString() + } + + fun baseUrlFromIndexUrl(indexUrl: String): String { + return indexUrl.removeSuffix("/index.min.json") + } + + private fun ExtensionIndexDto.toAvailableExtension(repo: ExternalExtensionRepo): RepoAvailableExtension? { + val libVersion = runCatching { version.substringBeforeLast('.').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.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 + } + + return RepoAvailableExtension( + type = repo.type, + name = displayName, + pkgName = pkg, + versionName = version, + versionCode = code, + libVersion = libVersion, + lang = lang, + isNsfw = nsfw == 1, + sourceNames = sources.orEmpty().map { it.name }, + apkName = apk, + iconUrl = applyMirror(if (repo.type == ExternalExtensionType.IREADER) "${repo.baseUrl}/icon/${apk.replace(".apk", ".png")}" else "${repo.baseUrl}/icon/$pkg.png"), + repoUrl = repo.baseUrl, + repoName = repo.displayName, + signatureHash = repo.signingKeyFingerprint, + isCompatible = supported, + ) + } + + private fun IReaderExtensionIndexDto.toAvailableExtension(repo: ExternalExtensionRepo): RepoAvailableExtension { + val libVersion = runCatching { version.substringBeforeLast('.').toDouble() }.getOrNull() ?: 0.0 + val displayName = name.removePrefix("IReader: ") + + return RepoAvailableExtension( + type = repo.type, + name = displayName, + pkgName = pkg, + versionName = version, + versionCode = code, + libVersion = libVersion, + lang = lang, + isNsfw = nsfw, + sourceNames = emptyList(), // IReader plugins don't declare subset sources natively + 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( + val meta: RepoMetaDto, + ) + + @Keep + @Serializable + private data class RepoMetaDto( + val name: String, + @SerialName("shortName") + val shortName: String? = null, + val website: String, + @SerialName("signingKeyFingerprint") + val signingKeyFingerprint: String, + ) + + @Keep + @Serializable + private data class ExtensionIndexDto( + val name: String, + val pkg: String, + val apk: String, + val lang: String = "all", + val code: Long, + val version: String, + val nsfw: Int = 0, + val sources: List? = null, + ) + + @Keep + @Serializable + private data class ExtensionSourceDto( + val name: String, + ) + + @Keep + @Serializable + private data class IReaderExtensionIndexDto( + val name: String = "", + val pkg: String = "", + val apk: String = "", + val lang: String = "en", + val code: Long = 1, + val version: String = "1.0", + val nsfw: Boolean = false, + ) + + private companion object { + const val TAG = "ExtensionRepo" + const val REPO_DETAILS_TIMEOUT_MS = 15_000L + const val CATALOG_TIMEOUT_MS = 20_000L + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepo.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepo.kt new file mode 100644 index 0000000000..83e08c09f4 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepo.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.mihon.extensions.repo + +data class ExternalExtensionRepo( + 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, +) { + val displayName: String + get() = shortName ?: name +} 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 new file mode 100644 index 0000000000..4ebb2a94f9 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionRepoRepository.kt @@ -0,0 +1,191 @@ +package io.github.landwarderer.futon.mihon.extensions.repo + +import android.content.Context +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.db.dao.ExternalExtensionRepoDao +import io.github.landwarderer.futon.core.db.entity.ExternalExtensionRepoEntity +import io.github.landwarderer.futon.core.util.ext.getDisplayMessage +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ExternalExtensionRepoRepository @Inject constructor( + @ApplicationContext private val appContext: Context, + private val db: MangaDatabase, + private val service: ExtensionRepoService, +) { + + private val dao: ExternalExtensionRepoDao + get() = db.getExternalExtensionRepoDao() + + + fun observeByType(type: ExternalExtensionType): Flow> { + return dao.observeByType(type).map { list -> list.map { it.toDomain() } } + } + + suspend fun getByType(type: ExternalExtensionType): List { + return dao.getByType(type).map { it.toDomain() } + } + + suspend fun addRepo(type: ExternalExtensionType, indexUrl: String): AddRepoResult { + return when (val prepared = prepareAddRepo(type, indexUrl)) { + is PrepareAddRepoResult.Ready -> confirmAddRepo(prepared.repo) + is PrepareAddRepoResult.DuplicateFingerprint -> AddRepoResult.DuplicateFingerprint(prepared.existingRepo) + is PrepareAddRepoResult.FetchFailed -> AddRepoResult.FetchFailed(prepared.error) + PrepareAddRepoResult.InvalidUrl -> AddRepoResult.InvalidUrl + PrepareAddRepoResult.RepoAlreadyExists -> AddRepoResult.RepoAlreadyExists + } + } + + suspend fun prepareAddRepo(type: ExternalExtensionType, indexUrl: String): PrepareAddRepoResult { + Log.d(TAG, "prepareAddRepo:start type=$type input=$indexUrl") + val normalizedIndexUrl = service.normalizeIndexUrl(indexUrl) ?: return PrepareAddRepoResult.InvalidUrl + .also { Log.d(TAG, "prepareAddRepo:invalidUrl type=$type input=$indexUrl") } + val baseUrl = service.baseUrlFromIndexUrl(normalizedIndexUrl) + Log.d(TAG, "prepareAddRepo:normalized type=$type normalizedIndexUrl=$normalizedIndexUrl baseUrl=$baseUrl") + if (dao.get(type, baseUrl) != null) { + Log.d(TAG, "prepareAddRepo:duplicateBaseUrl type=$type baseUrl=$baseUrl") + return PrepareAddRepoResult.RepoAlreadyExists + } + val repo = runCatching { service.fetchRepoDetails(baseUrl, type) } + .onFailure { error -> + Log.e(TAG, "prepareAddRepo:fetchFailed type=$type baseUrl=$baseUrl message=${error.message}", error) + } + .getOrElse { error -> + return PrepareAddRepoResult.FetchFailed(error) + } + val duplicate = dao.getByFingerprint(type, repo.signingKeyFingerprint) + if (duplicate != null) { + Log.d( + TAG, + "prepareAddRepo:duplicateFingerprint type=$type baseUrl=$baseUrl fingerprint=${repo.signingKeyFingerprint} existingBaseUrl=${duplicate.baseUrl}", + ) + return PrepareAddRepoResult.DuplicateFingerprint(duplicate.toDomain()) + } + Log.d(TAG, "prepareAddRepo:ready type=$type baseUrl=$baseUrl name=${repo.displayName}") + return PrepareAddRepoResult.Ready(repo) + } + + suspend fun confirmAddRepo(repo: ExternalExtensionRepo): AddRepoResult { + Log.d(TAG, "confirmAddRepo:start type=${repo.type} baseUrl=${repo.baseUrl} name=${repo.displayName}") + if (dao.get(repo.type, repo.baseUrl) != null) { + Log.d(TAG, "confirmAddRepo:duplicateBaseUrl type=${repo.type} baseUrl=${repo.baseUrl}") + return AddRepoResult.RepoAlreadyExists + } + val duplicate = dao.getByFingerprint(repo.type, repo.signingKeyFingerprint) + if (duplicate != null) { + Log.d( + TAG, + "confirmAddRepo:duplicateFingerprint type=${repo.type} baseUrl=${repo.baseUrl} fingerprint=${repo.signingKeyFingerprint} existingBaseUrl=${duplicate.baseUrl}", + ) + return AddRepoResult.DuplicateFingerprint(duplicate.toDomain()) + } + dao.upsert(repo.toEntity()) + Log.d(TAG, "confirmAddRepo:success type=${repo.type} baseUrl=${repo.baseUrl} name=${repo.displayName}") + return AddRepoResult.Success(repo) + } + + suspend fun delete(repo: ExternalExtensionRepo) { + dao.delete(repo.type, repo.baseUrl) + } + + suspend fun refresh(type: ExternalExtensionType) { + getByType(type).forEach { refresh(it) } + } + + suspend fun refresh(repo: ExternalExtensionRepo) { + val refreshed = runCatching { service.fetchRepoDetails(repo.baseUrl, repo.type) } + val now = System.currentTimeMillis() + val entity = if (refreshed.isSuccess) { + refreshed.getOrThrow().copy( + createdAt = repo.createdAt, + updatedAt = now, + lastSuccessAt = now, + lastError = null, + ).toEntity() + } else { + val error = refreshed.exceptionOrNull() + Log.e(TAG, "refresh:failed type=${repo.type} baseUrl=${repo.baseUrl} message=${error?.message}", error) + repo.copy( + updatedAt = now, + lastError = error?.getDisplayMessage(appContext.resources) + ?: "Unknown error", + ).toEntity() + } + dao.upsert(entity) + } + + suspend fun getAvailableExtensions(type: ExternalExtensionType): List = coroutineScope { + getCatalogExtensions(type) + .filter { it.isCompatible } + } + + suspend fun getCatalogExtensions(type: ExternalExtensionType): List = coroutineScope { + getByType(type) + .map { repo -> async { service.fetchAvailableExtensions(repo) } } + .awaitAll() + .flatten() + .groupBy { it.pkgName } + .map { (_, list) -> list.maxByOrNull { it.versionCode }!! } + .sortedWith(compareBy { it.lang }.thenBy { it.name.lowercase() }) + } + + sealed interface AddRepoResult { + data class Success(val repo: ExternalExtensionRepo) : AddRepoResult + data class DuplicateFingerprint(val existingRepo: ExternalExtensionRepo) : AddRepoResult + data class FetchFailed(val error: Throwable) : AddRepoResult + data object InvalidUrl : AddRepoResult + data object RepoAlreadyExists : AddRepoResult + } + + sealed interface PrepareAddRepoResult { + data class Ready(val repo: ExternalExtensionRepo) : PrepareAddRepoResult + data class DuplicateFingerprint(val existingRepo: ExternalExtensionRepo) : PrepareAddRepoResult + data class FetchFailed(val error: Throwable) : PrepareAddRepoResult + data object InvalidUrl : PrepareAddRepoResult + data object RepoAlreadyExists : PrepareAddRepoResult + } + + private companion object { + const val TAG = "ExtensionRepo" + } +} + +private fun ExternalExtensionRepoEntity.toDomain(): ExternalExtensionRepo { + return ExternalExtensionRepo( + type = type, + baseUrl = baseUrl, + name = name, + shortName = shortName, + website = website, + signingKeyFingerprint = signingKeyFingerprint, + createdAt = createdAt, + updatedAt = updatedAt, + lastSuccessAt = lastSuccessAt, + lastError = lastError, + version = version, + ) +} + +private fun ExternalExtensionRepo.toEntity(): ExternalExtensionRepoEntity { + return ExternalExtensionRepoEntity( + type = type, + baseUrl = baseUrl, + name = name, + shortName = shortName, + website = website, + signingKeyFingerprint = signingKeyFingerprint, + createdAt = createdAt, + updatedAt = updatedAt, + lastSuccessAt = lastSuccessAt, + lastError = lastError, + version = version, + ) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionType.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionType.kt new file mode 100644 index 0000000000..b5980a71bd --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/ExternalExtensionType.kt @@ -0,0 +1,8 @@ +package io.github.landwarderer.futon.mihon.extensions.repo + +enum class ExternalExtensionType { + MIHON, + ANIYOMI, + IREADER, + JAR, +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/InstalledExtensionSignatureValidator.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/InstalledExtensionSignatureValidator.kt new file mode 100644 index 0000000000..51e4522550 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/InstalledExtensionSignatureValidator.kt @@ -0,0 +1,63 @@ +package io.github.landwarderer.futon.mihon.extensions.repo + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InstalledExtensionSignatureValidator @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val cache = ConcurrentHashMap>() + + fun isTrusted(packageName: String, expectedFingerprint: String): Boolean { + return ExtensionFingerprintTrust.isTrusted(expectedFingerprint, getFingerprints(packageName)) + } + + private fun getFingerprints(packageName: String): Set { + return cache.getOrPut(packageName) { + runCatching { + val packageInfo = context.packageManager.getPackageInfoCompat(packageName) + getSignatures(packageInfo) + .mapTo(LinkedHashSet()) { signature -> + MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { byte -> "%02x".format(byte) } + } + }.getOrDefault(emptySet()) + } + } + + private fun getSignatures(packageInfo: PackageInfo): Array = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> { + val signingInfo = packageInfo.signingInfo ?: return emptyArray() + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } + + else -> { + @Suppress("DEPRECATION") + packageInfo.signatures ?: emptyArray() + } + } + + private fun PackageManager.getPackageInfoCompat(packageName: String): PackageInfo { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong())) + } else { + @Suppress("DEPRECATION") + getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/RepoAvailableExtension.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/RepoAvailableExtension.kt new file mode 100644 index 0000000000..0c4770d046 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/repo/RepoAvailableExtension.kt @@ -0,0 +1,19 @@ +package io.github.landwarderer.futon.mihon.extensions.repo + +data class RepoAvailableExtension( + val type: ExternalExtensionType, + val name: String, + val pkgName: String, + val versionName: String, + val versionCode: Long, + val libVersion: Double, + val lang: String, + val isNsfw: Boolean, + val sourceNames: List, + val apkName: String, + val iconUrl: String, + val repoUrl: String, + val repoName: String, + val signatureHash: String, + val isCompatible: Boolean, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLanguage.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLanguage.kt new file mode 100644 index 0000000000..a4c3ffb6b0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLanguage.kt @@ -0,0 +1,27 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +fun getExternalExtensionLanguageDisplayName(langCode: String): String { + return when (langCode.lowercase()) { + "zh" -> "中文" + "zh-hans" -> "简体中文" + "zh-hant" -> "繁體中文" + "en" -> "English" + "ja" -> "日本語" + "ko" -> "한국어" + "es" -> "Español" + "pt" -> "Português" + "pt-br" -> "Português (Brasil)" + "fr" -> "Français" + "de" -> "Deutsch" + "it" -> "Italiano" + "ru" -> "Русский" + "th" -> "ไทย" + "vi" -> "Tiếng Việt" + "id" -> "Bahasa Indonesia" + "ar" -> "العربية" + "tr" -> "Türkçe" + "pl" -> "Polski" + "all" -> "Multi" + else -> langCode.uppercase() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt new file mode 100644 index 0000000000..cc263531cf --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt @@ -0,0 +1,87 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build + +object ExternalExtensionLoaderSupport { + + @Suppress("DEPRECATION") + val packageQueryFlags: Int = PackageManager.GET_CONFIGURATIONS or + PackageManager.GET_META_DATA or + PackageManager.GET_SIGNATURES or + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0) + + val scanFlags: Int = PackageManager.GET_META_DATA or PackageManager.GET_CONFIGURATIONS + + fun looksLikeMihonPackage(packageName: String): Boolean { + return packageName.contains(".extension") || + packageName.startsWith("eu.kanade.tachiyomi.") || + packageName.startsWith("org.keiyoushi.") || + packageName.startsWith("io.github.landwarderer.futon.extension.") + } + + fun looksLikeAniyomiPackage(packageName: String): Boolean { + return packageName.contains(".animeextension") || + packageName.startsWith("eu.kanade.tachiyomi.animeextension.") + } + + fun getInstalledPackages(pkgManager: PackageManager): List { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pkgManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of(scanFlags.toLong()), + ) + } else { + @Suppress("DEPRECATION") + pkgManager.getInstalledPackages(scanFlags) + } + } catch (e: Exception) { + emptyList() + } + } + + fun getPackageInfoOrNull(pkgManager: PackageManager, packageName: String): PackageInfo? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pkgManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(packageQueryFlags.toLong()), + ) + } else { + @Suppress("DEPRECATION") + pkgManager.getPackageInfo(packageName, packageQueryFlags) + } + } catch (_: PackageManager.NameNotFoundException) { + null + } + } + + fun refreshPackageInfoIfNeeded(pkgManager: PackageManager, pkgInfo: PackageInfo): PackageInfo { + val needsRefresh = pkgInfo.applicationInfo?.metaData == null || pkgInfo.reqFeatures == null + if (!needsRefresh) { + return pkgInfo + } + return getPackageInfoOrNull(pkgManager, pkgInfo.packageName) ?: pkgInfo + } + + fun getAppLabel(context: Context, appInfo: ApplicationInfo): String { + return try { + context.packageManager.getApplicationLabel(appInfo).toString() + } catch (_: Exception) { + appInfo.packageName.substringAfterLast('.') + } + } + + fun extractLanguage(pkgName: String, marker: String): String { + val parts = pkgName.split(".") + val markerIndex = parts.indexOf(marker) + return if (markerIndex >= 0 && markerIndex + 1 < parts.size) { + parts[markerIndex + 1] + } else { + "all" + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt new file mode 100644 index 0000000000..8d7b7450a0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt @@ -0,0 +1,113 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +class ExternalExtensionManagerFacade( + context: Context, + scope: CoroutineScope, + private val logTag: String, + private val ecosystem: String, + private val sourceNamePrefix: String, + private val loadResults: suspend (Context) -> List, + private val successOf: (ResultT) -> SuccessT?, + private val errorOf: (ResultT) -> ErrorT?, + private val untrustedPackageNameOf: (ResultT) -> String?, + private val successSources: (SuccessT) -> List, + private val successPackageName: (SuccessT) -> String, + private val successIsNsfw: (SuccessT) -> Boolean, + private val successCatalogueSources: (SuccessT) -> List, + private val sourceId: (SourceT) -> Long, + private val asCatalogueSource: (SourceT) -> CatalogueT?, + private val catalogueSourceName: (CatalogueT) -> String, + private val catalogueSourceLang: (CatalogueT) -> String, + private val buildWrappedSource: (CatalogueT, String, Boolean, Boolean) -> WrappedSourceT, + private val errorPackageName: (ErrorT) -> String, + private val errorMessage: (ErrorT) -> String, +) { + + private val runtime = ExternalExtensionManagerRuntime< + ResultT, + SuccessT, + ErrorT, + SourceT, + WrappedSourceT, + >( + context = context, + scope = scope, + ) + + val installedExtensions: StateFlow> = runtime.installedExtensions + val failedExtensions: StateFlow> = runtime.failedExtensions + val isLoading: StateFlow = runtime.isLoading + + fun initialize() { + runtime.initialize(::loadExtensions) + } + + suspend fun loadExtensions() { + runtime.loadExtensions( + loadResults = loadResults, + processResults = { results -> + Log.d(logTag, "load_start ecosystem=$ecosystem") + processExternalExtensionResults( + results = results, + successOf = successOf, + errorOf = errorOf, + untrustedPackageNameOf = untrustedPackageNameOf, + successSources = successSources, + successPackageName = successPackageName, + successIsNsfw = successIsNsfw, + sourceId = sourceId, + asCatalogueSource = asCatalogueSource, + catalogueSourceName = catalogueSourceName, + buildWrappedSource = buildWrappedSource, + onError = { error -> + Log.e( + logTag, + "load_error ecosystem=$ecosystem pkg=${errorPackageName(error)} message=${errorMessage(error)}", + ) + }, + onUntrusted = { pkgName -> + Log.w(logTag, "load_untrusted ecosystem=$ecosystem pkg=$pkgName") + }, + ).also { processed: ProcessedExternalExtensions -> + Log.d( + logTag, + "load_complete ecosystem=$ecosystem success=${processed.successful.size} failed=${processed.failed.size} untrusted=${processed.untrustedPackages.size} sources=${processed.wrappedSourceById.size}", + ) + } + }, + ) + } + + fun getInstalledExtensions(): List = installedExtensions.value + + fun getCatalogueSources(): List { + return installedExtensions.value.flatMap(successCatalogueSources) + } + + fun getWrappedSources(): List = runtime.getWrappedSources() + + fun getSourceById(sourceId: Long): SourceT? = runtime.getSourceById(sourceId) + + fun getCatalogueSourceById(sourceId: Long): CatalogueT? = runtime.getSourceById(sourceId)?.let(asCatalogueSource) + + fun getWrappedSourceById(sourceId: Long): WrappedSourceT? = runtime.getWrappedSourceById(sourceId) + + fun getWrappedSourceByName(name: String): WrappedSourceT? { + if (!name.startsWith(sourceNamePrefix)) return null + val sourceId = name.substringAfter(sourceNamePrefix).toLongOrNull() ?: return null + return getWrappedSourceById(sourceId) + } + + fun getSourcesByLanguage(): Map> { + return getCatalogueSources().groupBy(catalogueSourceLang) + } + + fun getSourceCount(): Int = runtime.getSourceCount() + + fun hasExtensions(): Boolean = runtime.hasExtensions() +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt new file mode 100644 index 0000000000..8f84221bac --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt @@ -0,0 +1,80 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ExternalExtensionManagerRuntime( + private val context: Context, + private val scope: CoroutineScope, +) { + + private val _installedExtensions = MutableStateFlow>(emptyList()) + val installedExtensions: StateFlow> = _installedExtensions.asStateFlow() + + private val _failedExtensions = MutableStateFlow>(emptyList()) + val failedExtensions: StateFlow> = _failedExtensions.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val sourceCache = mutableMapOf() + private val wrappedSourceCache = mutableMapOf() + + @Volatile + private var isPackageObserverRegistered = false + + fun initialize(loadAction: suspend () -> Unit) { + registerPackageObserver(loadAction) + scope.launchInRuntime(loadAction) + } + + suspend fun loadExtensions( + loadResults: suspend (Context) -> List, + processResults: (List) -> ProcessedExternalExtensions, + ) { + if (_isLoading.value) return + + _isLoading.value = true + try { + sourceCache.clear() + wrappedSourceCache.clear() + val processed = processResults(loadResults(context)) + sourceCache.putAll(processed.sourceById) + wrappedSourceCache.putAll(processed.wrappedSourceById) + _installedExtensions.value = processed.successful + _failedExtensions.value = processed.failed + } finally { + _isLoading.value = false + } + } + + fun getInstalledExtensions(): List = installedExtensions.value + + fun getSourceById(sourceId: Long): SourceT? = sourceCache[sourceId] + + fun getWrappedSourceById(sourceId: Long): WrappedSourceT? = wrappedSourceCache[sourceId] + + fun getWrappedSources(): List = wrappedSourceCache.values.toList() + + fun getSourceCount(): Int = sourceCache.size + + fun hasExtensions(): Boolean = installedExtensions.value.isNotEmpty() + + private fun registerPackageObserver(loadAction: suspend () -> Unit) { + if (isPackageObserverRegistered) return + registerExternalExtensionPackageObserver(context) { + loadAction() + } + isPackageObserverRegistered = true + } + + private fun CoroutineScope.launchInRuntime(loadAction: suspend () -> Unit) { + launch { + loadAction() + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionMetadataSupport.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionMetadataSupport.kt new file mode 100644 index 0000000000..b9c74bd053 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionMetadataSupport.kt @@ -0,0 +1,59 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +import android.content.pm.ApplicationInfo +import android.os.Bundle + +object ExternalExtensionMetadataSupport { + + data class DeclaredSourceMetadata( + val sourceClassName: String, + val isNsfw: Boolean, + ) + + fun getMetaDataOrNull(appInfo: ApplicationInfo?): Bundle? = appInfo?.metaData + + fun hasDeclaredSource( + metaData: Bundle?, + sourceClassKey: String, + sourceFactoryKey: String, + ): Boolean { + return metaData?.containsKey(sourceClassKey) == true || + metaData?.containsKey(sourceFactoryKey) == true + } + + fun getSourceClassNameOrNull( + metaData: Bundle, + sourceClassKey: String, + sourceFactoryKey: String, + ): String? { + val sourceClass = metaData.getString(sourceClassKey) + val sourceFactory = metaData.getString(sourceFactoryKey) + return when { + sourceClass != null && sourceFactory != null -> "$sourceClass;$sourceFactory" + sourceClass != null -> sourceClass + sourceFactory != null -> sourceFactory + else -> null + } + } + + fun isNsfw(metaData: Bundle, nsfwKey: String): Boolean { + return metaData.getInt(nsfwKey, 0) == 1 + } + + fun getDeclaredSourceMetadataOrNull( + metaData: Bundle, + sourceClassKey: String, + sourceFactoryKey: String, + nsfwKey: String, + ): DeclaredSourceMetadata? { + val sourceClassName = getSourceClassNameOrNull( + metaData = metaData, + sourceClassKey = sourceClassKey, + sourceFactoryKey = sourceFactoryKey, + ) ?: return null + return DeclaredSourceMetadata( + sourceClassName = sourceClassName, + isNsfw = isNsfw(metaData, nsfwKey), + ) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionPackageObserver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionPackageObserver.kt new file mode 100644 index 0000000000..e57b20659e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionPackageObserver.kt @@ -0,0 +1,39 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import kotlinx.coroutines.launch + +fun registerExternalExtensionPackageObserver( + context: Context, + onPackageChanged: suspend () -> Unit, +): BroadcastReceiver { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val pendingResult = goAsync() + io.github.landwarderer.futon.core.util.ext.processLifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + onPackageChanged() + } finally { + pendingResult?.finish() + } + } + } + } + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED) + addDataScheme("package") + }, + ContextCompat.RECEIVER_EXPORTED, + ) + return receiver +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionRuntimeProcessor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionRuntimeProcessor.kt new file mode 100644 index 0000000000..4f395f284c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionRuntimeProcessor.kt @@ -0,0 +1,81 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +data class ProcessedExternalExtensions( + val successful: List, + val failed: List, + val sourceById: Map, + val wrappedSourceById: Map, + val untrustedPackages: List, +) + +fun processExternalExtensionResults( + results: List, + successOf: (ResultT) -> SuccessT?, + errorOf: (ResultT) -> ErrorT?, + untrustedPackageNameOf: (ResultT) -> String?, + successSources: (SuccessT) -> List, + successPackageName: (SuccessT) -> String, + successIsNsfw: (SuccessT) -> Boolean, + sourceId: (SourceT) -> Long, + asCatalogueSource: (SourceT) -> CatalogueSourceT?, + catalogueSourceName: (CatalogueSourceT) -> String, + buildWrappedSource: (CatalogueSourceT, String, Boolean, Boolean) -> WrappedSourceT, + onError: (ErrorT) -> Unit = {}, + onUntrusted: (String) -> Unit = {}, +): ProcessedExternalExtensions { + val successful = mutableListOf() + val failed = mutableListOf() + val sourceById = linkedMapOf() + val catalogueSources = mutableListOf>() + val untrustedPackages = mutableListOf() + + results.forEach { result -> + when { + successOf(result) != null -> { + val success = requireNotNull(successOf(result)) + successful += success + successSources(success).forEach { source -> + sourceById[sourceId(source)] = source + val catalogueSource = asCatalogueSource(source) ?: return@forEach + catalogueSources += Triple( + catalogueSource, + successPackageName(success), + successIsNsfw(success), + ) + } + } + + errorOf(result) != null -> { + val error = requireNotNull(errorOf(result)) + failed += error + onError(error) + } + + untrustedPackageNameOf(result) != null -> { + val pkgName = requireNotNull(untrustedPackageNameOf(result)) + untrustedPackages += pkgName + onUntrusted(pkgName) + } + } + } + + val nameCount = catalogueSources.groupingBy { catalogueSourceName(it.first) }.eachCount() + val wrappedSourceById = linkedMapOf() + catalogueSources.forEach { (catalogueSource, pkgName, isNsfw) -> + val hasLanguageSuffix = (nameCount[catalogueSourceName(catalogueSource)] ?: 0) > 1 + wrappedSourceById[sourceId(catalogueSource)] = buildWrappedSource( + catalogueSource, + pkgName, + isNsfw, + hasLanguageSuffix, + ) + } + + return ProcessedExternalExtensions( + successful = successful, + failed = failed, + sourceById = sourceById, + wrappedSourceById = wrappedSourceById, + untrustedPackages = untrustedPackages, + ) +} 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 new file mode 100644 index 0000000000..3e33c21ba8 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionSourceLoaderSupport.kt @@ -0,0 +1,85 @@ +package io.github.landwarderer.futon.mihon.extensions.runtime + +import android.util.Log +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +object ExternalExtensionSourceLoaderSupport { + + fun resolveSourceClassNames( + pkgName: String, + sourceClassNames: String, + ): List { + return sourceClassNames.split(";") + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { className -> + if (className.startsWith(".")) { + pkgName + className + } else { + className + } + } + } + + fun loadSources( + pkgName: String, + sourceClassNames: String, + classLoader: ClassLoader, + asSource: (Any) -> SourceT?, + createSourcesFromFactory: (Any) -> List?, + onUnknownInstance: (String) -> Unit = {}, + ): List { + val names = resolveSourceClassNames(pkgName, sourceClassNames) + return names.flatMap { fullClassName -> + try { + val clazz = try { + classLoader.loadClass(fullClassName) + } catch (e: ClassNotFoundException) { + Class.forName(fullClassName) + } + + val instance = try { + val constructor = clazz.getDeclaredConstructor() + constructor.isAccessible = true + constructor.newInstance() + } catch (e: Exception) { + try { + val field = clazz.getField("INSTANCE") + field.isAccessible = true + field.get(null) + } catch (e2: Exception) { + throw Exception("Could not instantiate $fullClassName: no constructor or INSTANCE field") + } + } + + asSource(instance)?.let { source -> + return@flatMap listOf(source) + } + + // Fallback to shim interface check + if (instance is Source) { + @Suppress("UNCHECKED_CAST") + return@flatMap listOf(instance as SourceT) + } + + createSourcesFromFactory(instance)?.let { sources -> + return@flatMap sources + } + + // Fallback to shim factory check + if (instance is SourceFactory) { + @Suppress("UNCHECKED_CAST") + return@flatMap instance.createSources() as List + } + + onUnknownInstance(instance.javaClass.name) + emptyList() + } catch (e: Throwable) { + Log.e("MihonExtensionLoader", "Error loading class $fullClassName", e) + onUnknownInstance("Failed to load $fullClassName: ${e.message}") + emptyList() + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/Content.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/Content.kt new file mode 100644 index 0000000000..7b986c8e33 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/Content.kt @@ -0,0 +1,233 @@ +package io.github.landwarderer.futon.mihon.model + +import android.content.res.Resources +import android.text.SpannableStringBuilder +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.collection.MutableObjectIntMap +import androidx.core.os.LocaleListCompat +import androidx.core.text.buildSpannedString +import androidx.core.text.strikeThrough +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.ui.model.MangaOverride +import io.github.landwarderer.futon.core.util.ext.iterator +import io.github.landwarderer.futon.details.ui.model.ChapterListItem +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentState +import io.github.landwarderer.futon.mihon.parsers.model.Demographic +import io.github.landwarderer.futon.mihon.parsers.util.findById +import io.github.landwarderer.futon.mihon.parsers.util.ifNullOrEmpty +import io.github.landwarderer.futon.mihon.parsers.util.mapToSet +import com.google.android.material.R as materialR + +typealias ContentOverride = MangaOverride + + +@JvmName("mangaIds") +fun Collection.ids() = mapToSet { it.id } + +fun Collection.distinctById() = distinctBy { it.id } + +@JvmName("chaptersIds") +fun Collection.ids() = mapToSet { it.id } + +fun Collection.countChaptersByBranch(): Int { + if (size <= 1) { + return size + } + val acc = MutableObjectIntMap() + for (item in this) { + val branch = item.chapter.branch + acc[branch] = acc.getOrDefault(branch, 0) + 1 + } + var max = 0 + acc.forEachValue { x -> if (x > max) max = x } + return max +} + +@get:StringRes +val ContentState.titleResId: Int + get() = when (this) { + ContentState.ONGOING -> R.string.state_ongoing + ContentState.FINISHED -> R.string.state_finished + ContentState.ABANDONED -> R.string.state_abandoned + ContentState.PAUSED -> R.string.state_paused + ContentState.UPCOMING -> R.string.state_upcoming + ContentState.RESTRICTED -> R.string.unavailable + } + +@get:DrawableRes +val ContentState.iconResId: Int + get() = when (this) { + ContentState.ONGOING -> R.drawable.ic_play + ContentState.FINISHED -> R.drawable.ic_state_finished + ContentState.ABANDONED -> R.drawable.ic_state_abandoned + ContentState.PAUSED -> R.drawable.ic_action_pause + ContentState.UPCOMING -> materialR.drawable.ic_clock_black_24dp + ContentState.RESTRICTED -> R.drawable.ic_disable + } + +@get:StringRes +val ContentRating.titleResId: Int + get() = when (this) { + ContentRating.SAFE -> R.string.rating_safe + ContentRating.SUGGESTIVE -> R.string.rating_suggestive + ContentRating.ADULT -> R.string.rating_adult + } + +@get:StringRes +val Demographic.titleResId: Int + get() = when (this) { + Demographic.SHOUNEN -> R.string.demographic_shounen + Demographic.SHOUJO -> R.string.demographic_shoujo + Demographic.SEINEN -> R.string.demographic_seinen + Demographic.JOSEI -> R.string.demographic_josei + Demographic.KODOMO -> R.string.demographic_kodomo + Demographic.NONE -> R.string.none + } + +fun Content.getPreferredBranch(history: ContentHistory?): String? { + val ch = chapters + if (ch.isNullOrEmpty()) { + return null + } + if (history != null) { + val currentChapter = ch.findById(history.chapterId) + if (currentChapter != null) { + return currentChapter.branch + } + } + val groups = ch.groupBy { it.branch } + if (groups.size == 1) { + return groups.keys.first() + } + for (locale in LocaleListCompat.getAdjustedDefault()) { + val displayLanguage = locale.getDisplayLanguage(locale) + val displayName = locale.getDisplayName(locale) + val candidates = HashMap>(3) + for (branch in groups.keys) { + if (branch != null && ( + branch.contains(displayLanguage, ignoreCase = true) || + branch.contains(displayName, ignoreCase = true) + ) + ) { + candidates[branch] = groups[branch] ?: continue + } + } + if (candidates.isNotEmpty()) { + return candidates.maxBy { it.value.size }.key + } + } + return groups.maxByOrNull { it.value.size }?.key +} + +val Content.isLocal: Boolean + get() = source.isLocal + +val Content.isBroken: Boolean + get() = source == UnknownContentSource + +fun Content.chaptersCount(): Int { + if (chapters.isNullOrEmpty()) { + return 0 + } + val counters = MutableObjectIntMap() + var max = 0 + chapters?.forEach { x -> + val c = counters.getOrDefault(x.branch, 0) + 1 + counters[x.branch] = c + if (max < c) { + max = c + } + } + return max +} + +fun Content.isNsfw(): Boolean { + if (contentRating == ContentRating.SAFE) return false + + val safeTags = setOf("safe", "all ages", "non-h", "sfw", "非h", "正常向", "全年龄", "全年龄向") + val isExplicitlySafe = tags.any { it.title.lowercase() in safeTags } + if (isExplicitlySafe) return false + + if (contentRating == ContentRating.ADULT) return true + + return source.isNsfw() +} + +fun ContentListFilter.getSummary() = buildSpannedString { + if (!query.isNullOrEmpty()) { + append(query) + if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) { + append(' ') + append('(') + appendTagsSummary(this@getSummary) + append(')') + } + } else { + appendTagsSummary(this@getSummary) + } +} + +private fun SpannableStringBuilder.appendTagsSummary(filter: ContentListFilter) { + var isFirst = true + val separator = ", " + for (tag in filter.tags) { + if (isFirst) { + isFirst = false + } else { + append(separator) + } + append(tag.title) + } + for (tag in filter.tagsExclude) { + if (isFirst) { + isFirst = false + } else { + append(separator) + } + strikeThrough { + append(tag.title) + } + } +} + +fun ContentChapter.getLocalizedTitle(resources: Resources, index: Int = -1): String { + title?.let { + if (it.isNotBlank()) { + return it + } + } + val num = numberString() + val vol = volumeString() + return when { + num != null && vol != null -> resources.getString(R.string.chapter_volume_number, vol, num) + num != null -> resources.getString(R.string.chapter_number, num) + index > 0 -> resources.getString( + R.string.chapters_time_pattern, + resources.getString(R.string.unnamed_chapter), + index.toString(), + ) + + else -> resources.getString(R.string.unnamed_chapter) + } +} + +fun Content.withOverride(override: ContentOverride?) = if (override != null) { + copy( + title = override.title.ifNullOrEmpty { title }, + coverUrl = override.coverUrl.ifNullOrEmpty { coverUrl }, + largeCoverUrl = override.coverUrl.ifNullOrEmpty { largeCoverUrl }, + contentRating = when (override.contentRating) { + org.koitharu.kotatsu.parsers.model.ContentRating.SAFE -> ContentRating.SAFE + org.koitharu.kotatsu.parsers.model.ContentRating.SUGGESTIVE -> ContentRating.SUGGESTIVE + org.koitharu.kotatsu.parsers.model.ContentRating.ADULT -> ContentRating.ADULT + null -> contentRating + }, + ) +} else { + this +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentHistory.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentHistory.kt new file mode 100644 index 0000000000..f05f29ad1c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentHistory.kt @@ -0,0 +1,17 @@ +package io.github.landwarderer.futon.mihon.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.time.Instant + +@Parcelize +data class ContentHistory( + val createdAt: Instant, + val updatedAt: Instant, + val chapterId: Long, + val page: Int, + val scroll: Int, + val percent: Float, + val chaptersCount: Int, + val parentChapterId: Long? = null, // EPUB父章节ID,用于支持内部章节 +) : Parcelable diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt new file mode 100644 index 0000000000..7e622bea58 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt @@ -0,0 +1,288 @@ +package io.github.landwarderer.futon.mihon.model + +import android.content.Context +import android.os.Build +import android.text.SpannableStringBuilder +import android.text.style.ImageSpan +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.text.inSpans +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.util.ext.toLocaleOrNull +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentType +import io.github.landwarderer.futon.mihon.parsers.util.splitTwoParts +import java.util.Locale + +data object LocalMangaSource : ContentSource { + override val name = "LOCAL" + override val locale = "" + override val contentType = ContentType.MANGA +} + +val ContentSource.isLocal: Boolean + get() = this == LocalMangaSource || this == LocalNovelSource || this == LocalVideoSource + +data object LocalNovelSource : ContentSource { + override val name = "LOCAL_NOVEL" + override val locale = "" + override val contentType = ContentType.NOVEL +} + +data object LocalVideoSource : ContentSource { + override val name = "LOCAL_VIDEO" + override val locale = "" + override val contentType = ContentType.VIDEO +} + +data object UnknownContentSource : ContentSource { + override val name = "UNKNOWN" + override val locale = "" + override val contentType = ContentType.OTHER +} + +data object TestContentSource : ContentSource { + override val name = "TEST" + override val locale = "" + override val contentType = ContentType.OTHER +} + +data class ExternalContentSource( + val packageName: String, + val authority: String, +) : ContentSource { + override val name: String + get() = "content:$packageName/$authority" + override val locale = "" + override val contentType = ContentType.MANGA + + private var cachedName: String? = null + + fun isAvailable(context: Context): Boolean { + return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true + } + + fun resolveName(context: Context): String { + cachedName?.let { + return it + } + val pm = context.packageManager + val info = pm.resolveContentProvider(authority, 0) + return info?.loadLabel(pm)?.toString()?.also { + cachedName = it + } ?: authority + } +} + +fun contentSource(name: String?): ContentSource { + when (name ?: return UnknownContentSource) { + UnknownContentSource.name -> return UnknownContentSource + LocalMangaSource.name -> return LocalMangaSource + LocalNovelSource.name -> return LocalNovelSource + LocalVideoSource.name -> return LocalVideoSource + TestContentSource.name -> return TestContentSource + } + if (name.startsWith("content:")) { + val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownContentSource + return ExternalContentSource(packageName = parts.first, authority = parts.second) + } + // GlobalExtensionManager.mangaSources.value.find { it.name == name }?.let { return io.github.landwarderer.futon.core.parser.kotatsu.KotatsuParserSource(it) } + // GlobalExtensionManager.contentSources.value.find { it.name == name }?.let { return it } + + // Fallbacks: If not loaded yet, return stable AnonymousContentSource + // Keep the original name so it isn't lost if the source loads later + return AnonymousContentSource(name) +} + +fun Collection.toContentSources() = map(::contentSource) + +fun ContentSource.isNsfw(): Boolean = when (this) { + is ContentSourceInfo -> mangaSource.isNsfw() + is MihonMangaSource -> isNsfw + else -> contentType in setOf( + ContentType.HENTAI_MANGA, + ContentType.HENTAI_NOVEL, + ContentType.HENTAI_VIDEO, + ) +} + +@get:StringRes +val ContentType.titleResId + get() = when (this) { + ContentType.MANGA -> R.string.content_type_manga + ContentType.HENTAI_MANGA -> R.string.content_type_manga // Fallback + ContentType.HENTAI_NOVEL -> R.string.content_type_novel // Fallback + ContentType.HENTAI_VIDEO -> R.string.content_type_other // Fallback + ContentType.COMICS -> R.string.content_type_comics + ContentType.VIDEO -> R.string.content_type_other // Fallback + ContentType.OTHER -> R.string.content_type_other + ContentType.MANHWA -> R.string.content_type_manhwa + ContentType.MANHUA -> R.string.content_type_manhua + ContentType.NOVEL -> R.string.content_type_novel + ContentType.ONE_SHOT -> R.string.content_type_one_shot + ContentType.DOUJINSHI -> R.string.content_type_doujinshi + ContentType.IMAGE_SET -> R.string.content_type_image_set + ContentType.ARTIST_CG -> R.string.content_type_artist_cg + ContentType.GAME_CG -> R.string.content_type_game_cg + } + +fun ContentType.getEnableSourceTitleResId(): Int = when (this) { + ContentType.NOVEL, ContentType.HENTAI_NOVEL -> R.string.enable_source_manga + else -> R.string.enable_source_manga +} + +fun ContentType.getUnsupportedSourceTitleResId(): Int = when (this) { + ContentType.NOVEL, ContentType.HENTAI_NOVEL -> R.string.unsupported_source + else -> R.string.unsupported_source +} + +fun ContentType.getDomainTitleResId(): Int = when (this) { + ContentType.NOVEL, ContentType.HENTAI_NOVEL -> R.string.domain_manga + else -> R.string.domain_manga +} + +fun ContentType.getSaveTitleResId(): Int = when (this) { + ContentType.NOVEL, ContentType.HENTAI_NOVEL -> R.string.download_option_whole_manga_manga + else -> R.string.download_option_whole_manga_manga +} + +fun ContentType.getRecommendationTermResId(): Int = when (this) { + ContentType.NOVEL, ContentType.HENTAI_NOVEL -> R.string.recommendation_manga + else -> R.string.recommendation_manga +} + +tailrec fun ContentSource.unwrap(): ContentSource = if (this is ContentSourceInfo) { + mangaSource.unwrap() +} else { + this +} + +fun ContentSource.getLocale(): Locale? = unwrap().locale.takeIf { it.isNotEmpty() }?.toLocaleOrNull() + +fun ContentSource.getContentType(): ContentType = unwrap().contentType + +fun ContentSource.getSummary(context: Context, contentType: ContentType? = null): String? = when (val source = unwrap()) { + is io.github.landwarderer.futon.mihon.model.MihonMangaSource -> { + val resolvedContentType = contentType ?: getContentType() + val type = context.getString(resolvedContentType.titleResId) + val localeObj = source.locale.toLocaleOrNull() ?: Locale.getDefault() + val locale = localeObj.getDisplayName(localeObj) + val base = context.getString(R.string.source_summary_pattern, type, locale) + appendOriginSuffix(context, base, source.getOriginLabel(context)) + } + + else -> { + val resolvedContentType = contentType ?: getContentType() + val type = context.getString(resolvedContentType.titleResId) + val base = if (source.locale.isNotEmpty()) { + val localeObj = source.locale.toLocaleOrNull() ?: Locale.getDefault() + val locale = localeObj.getDisplayName(localeObj) + context.getString(R.string.source_summary_pattern, type, locale) + } else type + appendOriginSuffix(context, base, source.getOriginLabel(context)) + } +} + +@RequiresApi(Build.VERSION_CODES.N) +private fun appendOriginSuffix(context: Context, base: String, originLabel: String?): String { + if (originLabel.isNullOrBlank()) { + return base + } + val currentLanguage = context.resources.configuration.locales[0]?.language.orEmpty() + val (open, close) = if (currentLanguage == "zh") "(" to ")" else "(" to ")" + return "$base$open$originLabel$close" +} + +fun ContentSource.getOriginLabel(context: Context): String? = when (this) { + is ContentSourceInfo -> mangaSource.getOriginLabel(context) + is ExternalContentSource -> context.getString(R.string.external_source) + is io.github.landwarderer.futon.mihon.model.MihonMangaSource -> "Mihon" + else -> null +} + +fun ContentSource.getTitle(context: Context): String { + val baseTitle = when (val source = unwrap()) { + LocalMangaSource -> context.getString(R.string.local_storage) + LocalNovelSource -> context.getString(R.string.domain_manga) + " " + context.getString(R.string.local_storage) + LocalVideoSource -> context.getString(R.string.domain_manga) + " " + context.getString(R.string.local_storage) + TestContentSource -> context.getString(R.string.test_parser) + is ExternalContentSource -> source.resolveName(context) + is io.github.landwarderer.futon.mihon.model.MihonMangaSource -> source.displayName + else -> { + source.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + } + return if (this.isBroken) { + "$baseTitle (Broken)" + } else { + baseTitle + } +} + +val ContentSource.isBroken: Boolean + get() { + val unwrapped = this.unwrap() + return when (unwrapped) { + is KotatsuParserSource -> unwrapped.isBroken + else -> { + // io.github.landwarderer.futon.core.extensions.GlobalExtensionManager.contentSources.value.find { it.originalSource == unwrapped || it.name == unwrapped.name }?.isBroken == true || + // io.github.landwarderer.futon.core.extensions.GlobalExtensionManager.mangaSources.value.find { it.originalSource == unwrapped || it.name == unwrapped.name }?.isBroken == true + false + } + } + } + + +fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder { + val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this + icon.setTintList(textView.textColors) + val size = textView.lineHeight + icon.setBounds(0, 0, size, size) + val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ImageSpan.ALIGN_CENTER + } else { + ImageSpan.ALIGN_BOTTOM + } + return inSpans(ImageSpan(icon, alignment)) { append(' ') } +} + +private class AnonymousContentSource(override val name: String) : ContentSource { + override val locale: String = "" + override val contentType: ContentType get() = when { + name.startsWith("ANIYOMI_") -> ContentType.VIDEO + name.startsWith("JSON_TVBOX_") -> ContentType.VIDEO + name.startsWith("JSON_LNREADER_") -> ContentType.NOVEL + name.startsWith("JSON_LEGADO_M_") -> ContentType.MANGA + name.startsWith("JSON_LEGADO_") -> ContentType.NOVEL + name.startsWith("MIHON_") -> ContentType.MANGA + name.startsWith("IREADER_") -> ContentType.NOVEL + else -> ContentType.OTHER + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ContentSource) return false + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "AnonymousContentSource(name=$name)" +} + +/** + * Maps IReader language/country codes to ISO 639-1 language codes. + * IReader extensions use country codes (e.g., "cn") while App uses language codes (e.g., "zh"). + */ +fun mapIReaderLangToLocale(lang: String): String? = when (lang.lowercase()) { + "cn" -> "zh" + "en" -> "en" + "jp" -> "ja" + "kr" -> "ko" + "tw" -> "zh" + "all" -> "" + else -> lang // Fallback: try using the code directly +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceInfo.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceInfo.kt new file mode 100644 index 0000000000..8daa46975b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceInfo.kt @@ -0,0 +1,9 @@ +package io.github.landwarderer.futon.mihon.model + +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource + +data class ContentSourceInfo( + val mangaSource: ContentSource, + val isEnabled: Boolean, + val isPinned: Boolean, +) : ContentSource by mangaSource diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt new file mode 100644 index 0000000000..cf3a7d9c71 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt @@ -0,0 +1,20 @@ +package io.github.landwarderer.futon.mihon.model + +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.serialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object ContentSourceSerializer : KSerializer { + + override val descriptor: SerialDescriptor = serialDescriptor() + + override fun serialize( + encoder: Encoder, + value: ContentSource + ) = encoder.encodeString(value.name) + + override fun deserialize(decoder: Decoder): ContentSource = contentSource(decoder.decodeString()) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/FavouriteCategory.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/FavouriteCategory.kt new file mode 100644 index 0000000000..fd5446299a --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/FavouriteCategory.kt @@ -0,0 +1,35 @@ +package io.github.landwarderer.futon.mihon.model + +import android.os.Parcelable +import io.github.landwarderer.futon.list.domain.ListSortOrder +import io.github.landwarderer.futon.list.ui.ListModelDiffCallback +import io.github.landwarderer.futon.list.ui.model.ListModel +import kotlinx.parcelize.Parcelize +import java.time.Instant + +@Parcelize +data class FavouriteCategory( + val id: Long, + val title: String, + val sortKey: Int, + val order: ListSortOrder, + val createdAt: Instant, + val isTrackingEnabled: Boolean, + val isVisibleInLibrary: Boolean, +) : Parcelable, ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is FavouriteCategory && id == other.id + } + + override fun getChangePayload(previousState: ListModel): Any? { + if (previousState !is FavouriteCategory) { + return null + } + return if (isTrackingEnabled != previousState.isTrackingEnabled || isVisibleInLibrary != previousState.isVisibleInLibrary) { + ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED + } else { + null + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/KotatsuParserSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/KotatsuParserSource.kt new file mode 100644 index 0000000000..1083024080 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/KotatsuParserSource.kt @@ -0,0 +1,27 @@ +package io.github.landwarderer.futon.mihon.model + +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaParserSource + +data class KotatsuParserSource( + val mangaSource: MangaParserSource +) : ContentSource { + override val name: String get() = mangaSource.name + override val locale: String get() = mangaSource.locale + override val contentType: ContentType get() = when (mangaSource.contentType) { + org.koitharu.kotatsu.parsers.model.ContentType.MANGA -> ContentType.MANGA + org.koitharu.kotatsu.parsers.model.ContentType.HENTAI -> ContentType.HENTAI_MANGA + org.koitharu.kotatsu.parsers.model.ContentType.COMICS -> ContentType.COMICS + org.koitharu.kotatsu.parsers.model.ContentType.OTHER -> ContentType.OTHER + org.koitharu.kotatsu.parsers.model.ContentType.MANHWA -> ContentType.MANHWA + org.koitharu.kotatsu.parsers.model.ContentType.MANHUA -> ContentType.MANHUA + org.koitharu.kotatsu.parsers.model.ContentType.NOVEL -> ContentType.NOVEL + org.koitharu.kotatsu.parsers.model.ContentType.ONE_SHOT -> ContentType.ONE_SHOT + org.koitharu.kotatsu.parsers.model.ContentType.DOUJINSHI -> ContentType.DOUJINSHI + org.koitharu.kotatsu.parsers.model.ContentType.IMAGE_SET -> ContentType.IMAGE_SET + org.koitharu.kotatsu.parsers.model.ContentType.ARTIST_CG -> ContentType.ARTIST_CG + org.koitharu.kotatsu.parsers.model.ContentType.GAME_CG -> ContentType.GAME_CG + } + val isBroken: Boolean get() = mangaSource.isBroken +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt new file mode 100644 index 0000000000..246b922ace --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt @@ -0,0 +1,285 @@ +package io.github.landwarderer.futon.mihon.model + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import io.github.landwarderer.futon.mihon.parsers.model.ContentPage +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentState +import io.github.landwarderer.futon.mihon.parsers.model.ContentTag + +/** + * Convert Mihon SManga to Domain Content. + */ +fun SManga.toDomainContent( + source: MihonMangaSource, + chapters: List? = null, + publicUrl: String = "", +): Content { + // Get baseUrl from source if available to resolve relative URLs + val baseUrl = (source.catalogueSource as? HttpSource)?.baseUrl ?: "" + + val safeUrl = try { url } catch (e: UninitializedPropertyAccessException) { "" } + val safeThumbnail = try { thumbnail_url } catch (e: UninitializedPropertyAccessException) { null } + val absoluteThumbnailUrl = resolveUrl(baseUrl, safeThumbnail) + val absolutePublicUrl = resolveUrl(baseUrl, safeUrl) ?: safeUrl + + // Safely access lateinit properties + val safeTitle = try { title } catch (e: UninitializedPropertyAccessException) { "Unknown" } + val safeGenres = try { getGenres() } catch (e: UninitializedPropertyAccessException) { null } + val safeAuthor = try { author } catch (e: UninitializedPropertyAccessException) { null } + val safeArtist = try { artist } catch (e: UninitializedPropertyAccessException) { null } + val safeDescription = try { description } catch (e: UninitializedPropertyAccessException) { null } + val safeStatus = try { status } catch (e: UninitializedPropertyAccessException) { SManga.UNKNOWN } + + android.util.Log.i("MihonDataConverters", "toDomainContent: title='$safeTitle' url='$safeUrl' -> absoluteThumbnail='$absoluteThumbnailUrl'") + + return Content( + id = generateContentId(safeUrl, source.name), + title = safeTitle.ifBlank { "Unknown" }, + altTitles = emptySet(), + url = safeUrl, + Url = publicUrl.ifBlank { absolutePublicUrl }, + rating = 0.0f, + contentRating = run { + val safeTags = setOf("safe", "all ages", "non-h", "sfw", "非h", "正常向", "全年龄", "全年龄向") + val isExplicitlySafe = safeGenres?.any { it.lowercase() in safeTags } == true + + val adultGenres = setOf("adult", "hentai", "18+", "nsfw", "mature", "ecchi") + val isContentNsfw = (!isExplicitlySafe && source.isNsfw) || safeGenres?.any { it.lowercase() in adultGenres } == true + + if (isExplicitlySafe) { + ContentRating.SAFE + } else if (isContentNsfw) { + ContentRating.ADULT + } else { + null + } + }, + coverUrl = absoluteThumbnailUrl, + largeCoverUrl = absoluteThumbnailUrl, // Also set largeCoverUrl for details page + tags = safeGenres?.map { genreName: String -> + ContentTag( + title = genreName, + key = genreName.lowercase().replace(" ", "_"), + source = source, + ) + }?.toSet() ?: emptySet(), + state = when (safeStatus) { + SManga.ONGOING -> ContentState.ONGOING + SManga.COMPLETED -> ContentState.FINISHED + SManga.ON_HIATUS -> ContentState.PAUSED + SManga.CANCELLED -> ContentState.ABANDONED + SManga.LICENSED -> ContentState.RESTRICTED + SManga.PUBLISHING_FINISHED -> ContentState.FINISHED + else -> ContentState.ONGOING + }, + authors = buildSet { + safeAuthor?.takeIf { it.isNotBlank() }?.let { add(it) } + safeArtist?.takeIf { it.isNotBlank() && it != safeAuthor }?.let { add(it) } + }, + description = safeDescription, + chapters = chapters, + source = source, + ) +} + +/** + * Convert app Content to Mihon SManga (for calling Mihon APIs). + */ +fun Content.toMihonManga(): SManga { + // Get baseUrl from source if available + val baseUrl = (source as? MihonMangaSource)?.let { mihonSource -> + (mihonSource.catalogueSource as? HttpSource)?.baseUrl ?: "" + } ?: "" + + var cleanUrl = url + + // Check if URL has duplicate protocol/baseUrl (e.g., "https://domain.comhttps//domain.com/path") + // Look for embedded "http" that's not at the start + val httpIndex = cleanUrl.indexOf("http", startIndex = 1) + if (httpIndex > 0) { + // Extract everything from the second "http" onwards + cleanUrl = cleanUrl.substring(httpIndex) + android.util.Log.w("MihonDataConverters", "Detected duplicate baseUrl, extracting: '$url' -> '$cleanUrl'") + } + + // Fix malformed protocols (https// -> https://) + cleanUrl = cleanUrl.replace(Regex("^(https?)/+"), "$1://") + + // If URL is absolute and starts with baseUrl, strip it to avoid duplicates in HttpSource + if (baseUrl.isNotBlank()) { + val baseHost = baseUrl.trimEnd('/') + if (cleanUrl.startsWith(baseHost)) { + val stripped = cleanUrl.substring(baseHost.length) + if (stripped.startsWith("/") || stripped.isEmpty()) { + cleanUrl = stripped + android.util.Log.d("MihonDataConverters", "Stripped baseUrl from absolute URL: '$url' -> '$cleanUrl'") + } + } + } + + // If URL still doesn't look absolute, log warning + if (!cleanUrl.matches(Regex("^https?://.*")) && !cleanUrl.startsWith("/")) { + android.util.Log.w("MihonDataConverters", "URL may be invalid after cleanup: '$cleanUrl' (original: '$url')") + } + + // NOTE: Do NOT add a leading slash to non-absolute URLs. + // Some extensions (e.g., zaimanhua) use pure IDs like "84652" which are then + // internally combined with their API path. Adding a slash would cause + // double-slash issues like "detail//84652" instead of "detail/84652". + + android.util.Log.d("MihonDataConverters", "toMihonManga: original='$url' cleaned='$cleanUrl'") + + return SManga.create().apply { + this.url = cleanUrl + this.title = this@toMihonManga.title + this.author = this@toMihonManga.authors.firstOrNull() + this.artist = this@toMihonManga.authors.drop(1).firstOrNull() + this.description = this@toMihonManga.description + this.genre = this@toMihonManga.tags.joinToString(", ") { it.title } + this.status = when (this@toMihonManga.state) { + ContentState.ONGOING -> SManga.ONGOING + ContentState.FINISHED -> SManga.COMPLETED + ContentState.PAUSED -> SManga.ON_HIATUS + ContentState.ABANDONED -> SManga.CANCELLED + ContentState.RESTRICTED -> SManga.LICENSED + ContentState.UPCOMING -> SManga.UNKNOWN + null -> SManga.UNKNOWN + } + this.thumbnail_url = this@toMihonManga.coverUrl + this.initialized = true + } +} + +// ============ SChapter <-> ContentChapter ============ + +/** + * Convert Mihon SChapter to App ContentChapter. + */ +fun SChapter.toContentChapter(source: ContentSource, overrideNumber: Float? = null): ContentChapter { + val chapterId = generateChapterId(url, source.name) + val finalNumber = overrideNumber ?: (if (chapter_number >= 0) chapter_number else 0f) + + android.util.Log.d("MihonDataConverters", "toContentChapter: name='$name' url='$url' -> id=$chapterId number=$finalNumber") + + return ContentChapter( + id = chapterId, + title = name.takeIf { it.isNotBlank() }, + number = finalNumber, + volume = 0, // Mihon doesn't have volume numbers in SChapter + url = url, + scanlator = scanlator, + uploadDate = date_upload, + branch = scanlator, // Use scanlator as branch for grouping + source = source, + ) +} + +/** + * Convert Apps ContentChapter to Mihon SChapter. + */ +fun ContentChapter.toMihonChapter(): SChapter { + return SChapter.create().apply { + this.url = this@toMihonChapter.url + this.name = this@toMihonChapter.title ?: "Chapter ${this@toMihonChapter.number}" + this.chapter_number = this@toMihonChapter.number + this.date_upload = this@toMihonChapter.uploadDate + this.scanlator = this@toMihonChapter.scanlator + } +} + +// ============ Page <-> ContentPage ============ + +/** + * Convert Mihon Page to App's ContentPage. + * + * NOTE: The chapter parameter is needed to generate unique page IDs. + * Without it, all chapters would have pages with IDs 0, 1, 2... which causes + * cache conflicts in the reader. + */ +fun Page.asContentPage( + source: ContentSource, + chapter: SChapter, + headers: Map = emptyMap() +): ContentPage { + // Generate a unique page ID by combining chapter URL and page index + // This prevents cache collisions between pages from different chapters + val pageId = "${chapter.url}|page|$index".hashCode().toLong() and Long.MAX_VALUE + + return ContentPage( + id = pageId, + url = imageUrl ?: url, + preview = null, + headers = headers, + source = source, + ) +} + +/** + * Convert App's ContentPage to Mihon Page. + */ +fun ContentPage.toMihonPage(): Page { + return Page( + index = id.toInt(), + url = url, + imageUrl = url.takeIf { it.isNotBlank() }, + ) +} + +// ============ ID Generation ============ + +/** + * Generate a stable ID for a manga based on URL and source. + */ +private fun generateContentId(url: String, sourceName: String): Long { + return "$sourceName|$url".hashCode().toLong() and Long.MAX_VALUE +} + +/** + * Generate a stable ID for a chapter based on URL and source. + */ +private fun generateChapterId(url: String, sourceName: String): Long { + return "$sourceName|chapter|$url".hashCode().toLong() and Long.MAX_VALUE +} + +// ============ URL Helpers ============ + +/** + * Get the public URL for a manga from an HttpSource. + */ +fun HttpSource.getPublicContentUrl(manga: SManga): String { + return try { + getMangaUrl(manga) + } catch (e: Exception) { + "" + } +} + +/** + * Get the public URL for a chapter from an HttpSource. + */ +fun HttpSource.getPublicChapterUrl(chapter: SChapter): String { + return try { + getChapterUrl(chapter) + } catch (e: Exception) { + "" + } +} +/** + * Resolve relative URL using baseUrl. + */ +private fun resolveUrl(baseUrl: String, url: String?): String? { + if (url.isNullOrBlank()) return null + if (url.startsWith("http")) return url + if (url.startsWith("//")) return "https:$url" + + if (baseUrl.isNotBlank()) { + return baseUrl.trimEnd('/') + "/" + url.trimStart('/') + } + return url +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonLoadResult.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonLoadResult.kt new file mode 100644 index 0000000000..5aabb8f72c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonLoadResult.kt @@ -0,0 +1,65 @@ +package io.github.landwarderer.futon.mihon.model + +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source + +/** + * Result of loading a Mihon extension. + */ +sealed class MihonLoadResult { + + /** + * Successfully loaded extension. + */ + data class Success( + val pkgName: String, + val appName: String, + val versionCode: Long, + val versionName: String, + val libVersion: Double, + val lang: String, + val isNsfw: Boolean, + val sources: List, + ) : MihonLoadResult() { + + /** + * Get only CatalogueSource instances (sources that support browsing). + */ + val catalogueSources: List + get() = sources.filterIsInstance() + } + + /** + * Failed to load extension. + */ + data class Error( + val pkgName: String, + val message: String, + val exception: Throwable? = null, + ) : MihonLoadResult() + + /** + * Extension is untrusted (signature not verified). + */ + data class Untrusted( + val pkgName: String, + val appName: String, + val versionCode: Long, + val versionName: String, + ) : MihonLoadResult() +} + +/** + * Extension metadata extracted from APK. + */ +data class MihonExtensionInfo( + val pkgName: String, + val appName: String, + val versionCode: Long, + val versionName: String, + val libVersion: Double, + val lang: String, + val isNsfw: Boolean, + val sourceClassName: String, + val apkPath: String, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonMangaSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonMangaSource.kt new file mode 100644 index 0000000000..acdcf65af2 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonMangaSource.kt @@ -0,0 +1,88 @@ +package io.github.landwarderer.futon.mihon.model + +import eu.kanade.tachiyomi.source.CatalogueSource +import io.github.landwarderer.futon.mihon.extensions.runtime.getExternalExtensionLanguageDisplayName +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentType + +/** + * Wrapper that adapts a Mihon CatalogueSource to App's ContentSource interface. + * + * This allows Mihon sources to be used interchangeably with native App sources + * throughout the application. + */ +data class MihonMangaSource( + val catalogueSource: CatalogueSource, + val pkgName: String, + val isNsfw: Boolean = false, + /** + * Whether this source should display its language in the name. + * Used for multi-language extensions where the same source name appears multiple times. + */ + val hasLanguageSuffix: Boolean = false, +) : ContentSource { + + override val locale: String get() = language + override val contentType: ContentType get() = if (isNsfw) ContentType.HENTAI_MANGA else ContentType.MANGA + + /** + * The source name, which follows the Mihon convention: MIHON_{sourceId} + */ + override val name: String + get() = "MIHON_${catalogueSource.id}" + + /** + * The display name for the source (from Mihon). + * If hasLanguageSuffix is true, appends the language name. + */ + val displayName: String + get() = if (hasLanguageSuffix) { + "${catalogueSource.name} (${getLanguageDisplayName(language)})" + } else { + catalogueSource.name + } + + /** + * The language code (ISO 639-1). + */ + val language: String + get() = catalogueSource.lang + + /** + * The unique source ID from Mihon. + */ + val sourceId: Long + get() = catalogueSource.id + + /** + * Whether this source supports latest updates. + */ + val supportsLatest: Boolean + get() = catalogueSource.supportsLatest + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ContentSource) return false + // Compare by name to support comparison with anonymous ContentSource objects + // that are created when loading from the database + return name == other.name + } + + override fun hashCode(): Int { + // Use name for hashCode to be consistent with equals + return name.hashCode() + } + + override fun toString(): String { + return "MihonMangaSource(id=${catalogueSource.id}, name=${catalogueSource.name}, lang=$language)" + } + + companion object { + /** + * Convert ISO 639-1 language code to display name. + */ + fun getLanguageDisplayName(langCode: String): String { + return getExternalExtensionLanguageDisplayName(langCode) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ParserModelMappers.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ParserModelMappers.kt new file mode 100644 index 0000000000..dede4c0189 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ParserModelMappers.kt @@ -0,0 +1,182 @@ +package io.github.landwarderer.futon.mihon.model + +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +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.ContentPage +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentState +import io.github.landwarderer.futon.mihon.parsers.model.ContentTag +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.MangaListFilterOptions +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag + + +fun Content.toManga(): Manga { + return Manga( + id = id, + title = title, + altTitles = altTitles, + url = url, + publicUrl = Url, + rating = rating, + contentRating = when (contentRating) { + ContentRating.SAFE -> org.koitharu.kotatsu.parsers.model.ContentRating.SAFE + ContentRating.SUGGESTIVE -> org.koitharu.kotatsu.parsers.model.ContentRating.SUGGESTIVE + ContentRating.ADULT -> org.koitharu.kotatsu.parsers.model.ContentRating.ADULT + null -> null + }, + coverUrl = coverUrl, + tags = tags.map { it.toMangaTag() }.toSet(), + state = state?.toMangaState() ?: org.koitharu.kotatsu.parsers.model.MangaState.ONGOING, + authors = authors, + largeCoverUrl = largeCoverUrl, + description = description, + chapters = chapters?.map { it.toMangaChapter() }, + source = source.toMangaSource() + ) +} + +fun Manga.toContent(source: ContentSource): Content { + return Content( + id = id, + title = title, + altTitles = altTitles, + url = url, + Url = publicUrl, + rating = rating, + contentRating = when (contentRating) { + org.koitharu.kotatsu.parsers.model.ContentRating.SAFE -> ContentRating.SAFE + org.koitharu.kotatsu.parsers.model.ContentRating.SUGGESTIVE -> ContentRating.SUGGESTIVE + org.koitharu.kotatsu.parsers.model.ContentRating.ADULT -> ContentRating.ADULT + null -> null + }, + coverUrl = coverUrl, + tags = tags.map { it.toContentTag() }.toSet(), + state = state?.toContentState(), + authors = authors, + largeCoverUrl = largeCoverUrl, + description = description, + chapters = chapters?.map { it.toContentChapter(source) }, + source = source + ) +} + +fun ContentChapter.toMangaChapter(): MangaChapter { + return MangaChapter( + id = id, + title = title ?: "", + number = number, + volume = volume, + url = url, + scanlator = scanlator, + uploadDate = uploadDate, + branch = branch, + source = source.toMangaSource() + ) +} + +fun MangaChapter.toContentChapter(source: ContentSource): ContentChapter { + return ContentChapter( + id = id, + title = title, + number = number, + volume = volume, + url = url, + scanlator = scanlator, + uploadDate = uploadDate, + branch = branch, + source = source + ) +} + +fun ContentPage.toMangaPage(): MangaPage { + return MangaPage( + id = id, + url = url, + preview = preview, + source = source.toMangaSource() + ) +} + +fun MangaPage.toContentPage(source: ContentSource): ContentPage { + return ContentPage( + id = id, + url = url, + preview = preview, + headers = emptyMap(), + source = source + ) +} + +fun ContentListFilterOptions.toMangaListFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableTags = availableTags.map { it.toMangaTag() }.toSet(), + availableStates = availableStates.mapNotNull { it.toMangaState() }.toSet() + ) +} + +fun MangaListFilter.toContentListFilter(): ContentListFilter { + return ContentListFilter( + query = query, + tags = tags.map { it.toContentTag() }.toSet(), + tagsExclude = tagsExclude.map { it.toContentTag() }.toSet() + ) +} + +fun ContentState.toMangaState(): MangaState { + return when (this) { + ContentState.ONGOING -> MangaState.ONGOING + ContentState.FINISHED -> MangaState.FINISHED + ContentState.ABANDONED -> MangaState.ABANDONED + ContentState.PAUSED -> MangaState.PAUSED + ContentState.UPCOMING -> MangaState.UPCOMING + ContentState.RESTRICTED -> MangaState.RESTRICTED + } +} + +fun MangaState.toContentState(): ContentState { + return when (this) { + MangaState.ONGOING -> ContentState.ONGOING + MangaState.FINISHED -> ContentState.FINISHED + MangaState.ABANDONED -> ContentState.ABANDONED + MangaState.PAUSED -> ContentState.PAUSED + MangaState.UPCOMING -> ContentState.UPCOMING + MangaState.RESTRICTED -> ContentState.RESTRICTED + } +} + +fun ContentTag.toMangaTag(): MangaTag { + return MangaTag( + title = title, + key = key, + source = source.toMangaSource() + ) +} + +fun MangaTag.toContentTag(): ContentTag { + return ContentTag( + title = title, + key = key, + source = object : ContentSource { + override val name = "Dummy" + override val locale = "" + override val contentType = io.github.landwarderer.futon.mihon.parsers.model.ContentType.OTHER + } + ) +} + +fun ContentSource.toMangaSource(): MangaSource { + val src = this + return object : MangaSource { + override val name = src.name + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt new file mode 100644 index 0000000000..17beb93c77 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt @@ -0,0 +1,14 @@ +package io.github.landwarderer.futon.mihon.model + +import io.github.landwarderer.futon.core.ui.widgets.ChipsView +import io.github.landwarderer.futon.list.domain.ListFilterOption + +fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel( + title = titleText, + titleResId = titleResId, + icon = iconResId, + iconData = getIconData(), + isChecked = isChecked, + counter = if (this is ListFilterOption.Branch) chaptersCount else 0, + data = this, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt new file mode 100644 index 0000000000..31c700abf2 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt @@ -0,0 +1,6 @@ +package io.github.landwarderer.futon.mihon.model + +enum class SortDirection { + + ASC, DESC; +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt new file mode 100644 index 0000000000..3d6d03968c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt @@ -0,0 +1,6 @@ +package io.github.landwarderer.futon.mihon.model + +enum class ZoomMode { + + FIT_CENTER, FIT_HEIGHT, FIT_WIDTH, KEEP_START +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt new file mode 100644 index 0000000000..1b80568570 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt @@ -0,0 +1,109 @@ +package io.github.landwarderer.futon.mihon.model.jsonsource + +import kotlinx.serialization.Serializable + +/** + * Legado book source configuration model + * Represents a complete book source configuration in Legado format + */ +@Serializable +data class LegadoBookSource( + val bookSourceName: String, + val bookSourceUrl: String, + val bookSourceType: Int = 0, // 0=文字, 1=音频, 2=图片 + val bookSourceGroup: String? = null, + val enabled: Boolean = true, + val searchUrl: String? = null, + val exploreUrl: String? = null, + val header: String? = null, // 请求头配置(JSON格式字符串) + val loginUrl: String? = null, // 登录地址 + val loginUi: String? = null, // 登录UI + val loginCheckJs: String? = null, // 登录检测js + val jsLib: String? = null, // js库 + val enabledCookieJar: Boolean? = true, // 启用cookieJar + val concurrentRate: String? = null, // 并发率 + val bookSourceComment: String? = null, // 注释 + val variableComment: String? = null, // 自定义变量说明 + val respondTime: Long = 180000L, // 响应时间 + val weight: Int = 0, // 权重 + val ruleExplore: SearchRule? = null, // 浏览规则 + val ruleSearch: SearchRule? = null, + val ruleBookInfo: BookInfoRule? = null, + val ruleToc: TocRule? = null, + val ruleContent: ContentRule? = null, +) + +/** + * Search rule for parsing search results + */ +@Serializable +data class SearchRule( + val bookList: String? = null, // 列表选择器 + val name: String? = null, // 名称规则 + val author: String? = null, // 作者规则 + val coverUrl: String? = null, // 封面规则 + val bookUrl: String? = null, // 链接规则 + val intro: String? = null, // 简介规则 + val lastChapter: String? = null, // 最新章节规则 + val updateTime: String? = null, // 更新时间规则 + val kind: String? = null, // 分类规则 + val wordCount: String? = null, // 字数规则 + val checkKeyWord: String? = null, // 校验关键字 + val init: String? = null, // 初始化脚本 + val webView: Boolean? = false, // 是否启用 WebView +) + +/** + * Book info rule for parsing book details + */ +@Serializable +data class BookInfoRule( + val name: String? = null, + val author: String? = null, + val coverUrl: String? = null, + val intro: String? = null, + val kind: String? = null, // 分类/标签 + val lastChapter: String? = null, + val updateTime: String? = null, // 更新时间 + val wordCount: String? = null, // 字数 + val tocUrl: String? = null, // 目录页链接规则 + val init: String? = null, // 初始化脚本 + val canReName: String? = null, + val downloadUrls: String? = null, + val webView: Boolean? = false, // 是否启用 WebView +) + +/** + * Table of contents rule for parsing chapter list + */ +@Serializable +data class TocRule( + val chapterList: String? = null, // 章节列表选择器 + val chapterName: String? = null, // 章节名称规则 + val chapterUrl: String? = null, // 章节链接规则 + val nextTocUrl: String? = null, // 下一页目录 + val isVolume: String? = null, // 卷标识 + val isVip: String? = null, // VIP 标识 + val isPay: String? = null, // 付费标识 + val updateTime: String? = null, // 更新时间 + val preUpdateJs: String? = null, // 刷新前JS + val formatJs: String? = null, // 格式化JS + val webView: Boolean? = false, // 是否启用 WebView +) + +/** + * Content rule for parsing chapter content + */ +@Serializable +data class ContentRule( + val content: String? = null, // 内容规则 + val title: String? = null, // 正文页内标题修正 + val nextContentUrl: String? = null,// 正文分页 + val webJs: String? = null, // 网页JS + val sourceRegex: String? = null, // 资源正则 + val replaceRegex: String? = null, // 正文替换 + val imageStyle: String? = null, // 图片样式 + val payAction: String? = null, // 支付操作 + val webView: String? = null, // 是否启用 WebView (Legado 规则中此处可能是字符串 "true" 或 JS 脚本) + val webViewDelayTime: Long? = null, // WebView 延迟时间 +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md new file mode 100644 index 0000000000..764bafe5f2 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md @@ -0,0 +1,108 @@ +# JSON Source Data Models + +This package contains data models for JSON-based source configurations. + +## Models + +### Legado Book Source (`LegadoBookSource.kt`) + +Models for Legado format book sources, which support novels and manga from various websites. + +**Main Model:** +- `LegadoBookSource`: Root configuration containing source metadata and parsing rules + +**Rule Models:** +- `SearchRule`: Defines how to parse search results +- `BookInfoRule`: Defines how to parse book/manga details +- `TocRule`: Defines how to parse table of contents (chapter list) +- `ContentRule`: Defines how to parse chapter content + +**Example JSON:** +```json +{ + "bookSourceName": "Example Source", + "bookSourceUrl": "https://example.com", + "bookSourceType": 0, + "enabled": true, + "ruleSearch": { + "bookList": "div.book-list", + "name": "h2@text", + "author": "span.author@text", + "bookUrl": "a@href" + }, + "ruleToc": { + "chapterList": "div.chapter-list li", + "chapterName": "a@text", + "chapterUrl": "a@href" + }, + "ruleContent": { + "content": "div.content@html" + } +} +``` + +### TVBox Configuration (`TVBoxConfig.kt`) + +Models for TVBox format video site configurations. + +**Main Models:** +- `TVBoxConfig`: Root configuration containing sites and settings +- `TVBoxSite`: Individual video site configuration + +**Supporting Models:** +- `TVBoxLive`: Live stream configuration +- `TVBoxParse`: Video parser configuration +- `TVBoxIjk`: IJK player settings +- `TVBoxIjkOption`: Individual IJK option + +**Example JSON:** +```json +{ + "sites": [ + { + "key": "example", + "name": "Example Video Site", + "type": 1, + "api": "https://example.com/api", + "searchable": 1, + "quickSearch": 1, + "filterable": 1 + } + ] +} +``` + +## Serialization + +All models use `kotlinx.serialization` with the `@Serializable` annotation. They support: + +- JSON deserialization from string +- JSON serialization to string +- Lenient parsing (ignores unknown keys) +- Optional fields with default values + +## Usage + +```kotlin +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString + +val json = Json { + ignoreUnknownKeys = true + isLenient = true +} + +// Parse Legado source +val legadoSource = json.decodeFromString(jsonString) + +// Parse TVBox config +val tvboxConfig = json.decodeFromString(jsonString) +``` + +## Requirements + +These models satisfy requirement 4.1 from the design document: +- Define data structures for Legado and TVBox configurations +- Support JSON serialization/deserialization +- Provide default values for optional fields +- Handle nested rule structures diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt new file mode 100644 index 0000000000..8939d55ff8 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt @@ -0,0 +1,16 @@ +package io.github.landwarderer.futon.mihon.model.parcelable + +import android.os.Parcel +import io.github.landwarderer.futon.mihon.model.contentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import kotlinx.parcelize.Parceler + +class ContentSourceParceler : Parceler { + + override fun create(parcel: Parcel): ContentSource = contentSource(parcel.readString()) + + override fun ContentSource.write(parcel: Parcel, flags: Int) { + parcel.writeString(name) + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableChapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableChapter.kt new file mode 100644 index 0000000000..6886c38f55 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableChapter.kt @@ -0,0 +1,44 @@ +package io.github.landwarderer.futon.mihon.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import io.github.landwarderer.futon.mihon.model.contentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ParcelableChapter( + val chapter: ContentChapter, +) : Parcelable { + + companion object : Parceler { + + override fun create(parcel: Parcel) = ParcelableChapter( + ContentChapter( + id = parcel.readLong(), + title = parcel.readString(), + number = parcel.readFloat(), + volume = parcel.readInt(), + url = parcel.readString().orEmpty(), + scanlator = parcel.readString(), + uploadDate = parcel.readLong(), + branch = parcel.readString(), + source = contentSource(parcel.readString()), + ), + ) + + override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { + parcel.writeLong(id) + parcel.writeString(title) + parcel.writeFloat(number) + parcel.writeInt(volume) + parcel.writeString(url) + parcel.writeString(scanlator) + parcel.writeLong(uploadDate) + parcel.writeString(branch) + parcel.writeString(source.name) + } + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContent.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContent.kt new file mode 100644 index 0000000000..a90c07c2b6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContent.kt @@ -0,0 +1,116 @@ +package io.github.landwarderer.futon.mihon.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import io.github.landwarderer.futon.core.util.ext.readParcelableCompat +import io.github.landwarderer.futon.core.util.ext.readSerializableCompat +import io.github.landwarderer.futon.core.util.ext.readStringSet +import io.github.landwarderer.futon.core.util.ext.writeStringSet +import io.github.landwarderer.futon.mihon.model.contentSource +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentState +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ParcelableContent( + val manga: Content, + private val withDescription: Boolean = true, + private val withChapters: Boolean = false, +) : Parcelable { + + companion object : Parceler { + + override fun ParcelableContent.write(parcel: Parcel, flags: Int): Unit = with(manga) { + parcel.writeLong(id) + parcel.writeString(title) + parcel.writeStringSet(altTitles) + parcel.writeString(url) + parcel.writeString(Url) + parcel.writeFloat(rating) + parcel.writeSerializable(contentRating) + parcel.writeString(coverUrl) + parcel.writeString(largeCoverUrl) + parcel.writeString(description.takeIf { withDescription }) + parcel.writeParcelable(ParcelableContentTags(tags), flags) + parcel.writeSerializable(state) + parcel.writeStringSet(authors) + parcel.writeString(source.name) + // Write chapters if requested + val chaptersToWrite = if (withChapters) chapters else null + parcel.writeInt(chaptersToWrite?.size ?: -1) + chaptersToWrite?.forEach { chapter -> + parcel.writeLong(chapter.id) + parcel.writeString(chapter.title) + parcel.writeFloat(chapter.number) + parcel.writeInt(chapter.volume) + parcel.writeString(chapter.url) + parcel.writeString(chapter.scanlator) + parcel.writeLong(chapter.uploadDate) + parcel.writeString(chapter.branch) + } + } + + override fun create(parcel: Parcel): ParcelableContent { + val id = parcel.readLong() + val title = requireNotNull(parcel.readString()) + val altTitles = parcel.readStringSet() + val url = requireNotNull(parcel.readString()) + val Url = requireNotNull(parcel.readString()) + val rating = parcel.readFloat() + val contentRating = parcel.readSerializableCompat() + val coverUrl = parcel.readString() + val largeCoverUrl = parcel.readString() + val description = parcel.readString() + val tags = requireNotNull(parcel.readParcelableCompat()).tags + val state = parcel.readSerializableCompat() + val authors = parcel.readStringSet() + val sourceName = requireNotNull(parcel.readString()) + + // Read chapters if present + val chaptersSize = parcel.readInt() + val chapters = if (chaptersSize >= 0) { + List(chaptersSize) { + ContentChapter( + id = parcel.readLong(), + title = parcel.readString(), + number = parcel.readFloat(), + volume = parcel.readInt(), + url = requireNotNull(parcel.readString()), + scanlator = parcel.readString(), + uploadDate = parcel.readLong(), + branch = parcel.readString(), + source = contentSource(sourceName), + ) + } + } else { + null + } + + return ParcelableContent( + Content( + id = id, + title = title, + altTitles = altTitles, + url = url, + Url = Url, + rating = rating, + contentRating = contentRating, + coverUrl = coverUrl, + largeCoverUrl = largeCoverUrl, + description = description, + tags = tags, + state = state, + authors = authors, + chapters = chapters, + source = contentSource(sourceName), + ), + withDescription = true, + withChapters = chapters != null, + ) + } + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentListFilter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentListFilter.kt new file mode 100644 index 0000000000..fff7e67c80 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentListFilter.kt @@ -0,0 +1,56 @@ +package io.github.landwarderer.futon.mihon.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import io.github.landwarderer.futon.core.util.ext.readEnumSet +import io.github.landwarderer.futon.core.util.ext.readParcelableCompat +import io.github.landwarderer.futon.core.util.ext.readSerializableCompat +import io.github.landwarderer.futon.core.util.ext.writeEnumSet +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentState +import io.github.landwarderer.futon.mihon.parsers.model.ContentType +import io.github.landwarderer.futon.mihon.parsers.model.Demographic +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler + +object ContentListFilterParceler : Parceler { + + override fun ContentListFilter.write(parcel: Parcel, flags: Int) { + parcel.writeString(query) + parcel.writeParcelable(ParcelableContentTags(tags), 0) + parcel.writeParcelable(ParcelableContentTags(tagsExclude), 0) + parcel.writeSerializable(locale) + parcel.writeSerializable(originalLocale) + parcel.writeEnumSet(states) + parcel.writeEnumSet(contentRating) + parcel.writeEnumSet(types) + parcel.writeEnumSet(demographics) + parcel.writeInt(year) + parcel.writeInt(yearFrom) + parcel.writeInt(yearTo) + parcel.writeString(author) + } + + override fun create(parcel: Parcel) = ContentListFilter( + query = parcel.readString(), + tags = parcel.readParcelableCompat()?.tags.orEmpty(), + tagsExclude = parcel.readParcelableCompat()?.tags.orEmpty(), + locale = parcel.readSerializableCompat(), + originalLocale = parcel.readSerializableCompat(), + states = parcel.readEnumSet().orEmpty(), + contentRating = parcel.readEnumSet().orEmpty(), + types = parcel.readEnumSet().orEmpty(), + demographics = parcel.readEnumSet().orEmpty(), + year = parcel.readInt(), + yearFrom = parcel.readInt(), + yearTo = parcel.readInt(), + author = parcel.readString(), + ) +} + +@Parcelize +@TypeParceler +data class ParcelableContentListFilter(val filter: ContentListFilter) : Parcelable + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentPage.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentPage.kt new file mode 100644 index 0000000000..02351aca20 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentPage.kt @@ -0,0 +1,30 @@ +package io.github.landwarderer.futon.mihon.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import io.github.landwarderer.futon.mihon.model.contentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentPage +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler + +object ContentPageParceler : Parceler { + override fun create(parcel: Parcel) = ContentPage( + id = parcel.readLong(), + url = requireNotNull(parcel.readString()), + preview = parcel.readString(), + source = contentSource(parcel.readString()), + ) + + override fun ContentPage.write(parcel: Parcel, flags: Int) { + parcel.writeLong(id) + parcel.writeString(url) + parcel.writeString(preview) + parcel.writeString(source.name) + } +} + +@Parcelize +@TypeParceler +class ParcelableContentPage(val page: ContentPage) : Parcelable + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentTags.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentTags.kt new file mode 100644 index 0000000000..46aa2076e7 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ParcelableContentTags.kt @@ -0,0 +1,28 @@ +package io.github.landwarderer.futon.mihon.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import io.github.landwarderer.futon.mihon.model.contentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentTag +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler + +object ContentTagParceler : Parceler { + override fun create(parcel: Parcel) = ContentTag( + title = requireNotNull(parcel.readString()), + key = requireNotNull(parcel.readString()), + source = contentSource(parcel.readString()), + ) + + override fun ContentTag.write(parcel: Parcel, flags: Int) { + parcel.writeString(title) + parcel.writeString(key) + parcel.writeString(source.name) + } +} + +@Parcelize +@TypeParceler +data class ParcelableContentTags(val tags: Set) : Parcelable + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/Broken.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/Broken.kt new file mode 100644 index 0000000000..ec9177f87f --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/Broken.kt @@ -0,0 +1,14 @@ +package io.github.landwarderer.futon.mihon.parsers + +/** + * Annotate [ContentParser] implementation to mark this parser as broken instead of removing it + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Broken( + + /** + * Reason why this parser is broken + */ + val message: String = "", +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/CategorizedFavoritesProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/CategorizedFavoritesProvider.kt new file mode 100644 index 0000000000..fc1af9e673 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/CategorizedFavoritesProvider.kt @@ -0,0 +1,20 @@ +package io.github.landwarderer.futon.mihon.parsers + +import io.github.landwarderer.futon.mihon.parsers.model.Content + +interface CategorizedFavoritesProvider : FavoritesProvider { + + suspend fun fetchFavoriteFolders(): List + + suspend fun fetchFavorites(folderId: String): List + + override suspend fun fetchFavorites(): List { + return fetchFavoriteFolders().flatMap { fetchFavorites(it.id) }.distinctBy { it.url } + } +} + +data class ContentFavoriteFolder( + val id: String, + val title: String, + val count: Int? = null, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentLoaderContext.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentLoaderContext.kt new file mode 100644 index 0000000000..554218da05 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentLoaderContext.kt @@ -0,0 +1,83 @@ +package io.github.landwarderer.futon.mihon.parsers + +import io.github.landwarderer.futon.mihon.parsers.bitmap.Bitmap +import io.github.landwarderer.futon.mihon.parsers.config.ContentSourceConfig +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.network.UserAgents +import io.github.landwarderer.futon.mihon.parsers.util.LinkResolver +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Response +import java.util.* + +abstract class ContentLoaderContext { + + abstract val httpClient: OkHttpClient + + abstract val cookieJar: CookieJar + + abstract fun newParserInstance(source: ContentSource): ContentParser + + abstract fun newLinkResolver(link: HttpUrl): LinkResolver + + fun newLinkResolver(link: String): LinkResolver = newLinkResolver(link.toHttpUrl()) + + open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data) + + open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data) + + open fun getPreferredLocales(): List = listOf(Locale.getDefault()) + + /** + * Optional user-facing notification, default no-op for non-Android environments. + */ + open fun showToast(message: String, isLong: Boolean = false) {} + + /** + * Execute JavaScript code and return result + * @param script JavaScript source code + * @return execution result as string, may be null + */ + @Deprecated("Provide a base url") + abstract suspend fun evaluateJs(script: String): String? + + /** + * Execute JavaScript code and return result + * @param script JavaScript source code + * @param baseUrl url of page script will be executed in context of + * @return execution result as string, may be null + */ + abstract suspend fun evaluateJs(baseUrl: String, script: String): String? + + /** + * Open [url] in browser for some external action (e.g. captcha solving or non cookie-based authorization) + */ + open fun requestBrowserAction(parser: ContentParser, url: String): Nothing { + throw UnsupportedOperationException("Browser is not available") + } + + abstract fun getConfig(source: ContentSource): ContentSourceConfig + + open fun getDefaultUserAgent(): String = UserAgents.CHROME_MOBILE + + /** + * Helper function to be used in an interceptor + * to descramble images + * @param response Image response + * @param redraw lambda function to implement descrambling logic + */ + abstract fun redrawImageResponse( + response: Response, + redraw: (image: Bitmap) -> Bitmap, + ): Response + + /** + * create a new empty Bitmap with given dimensions + */ + abstract fun createBitmap( + width: Int, + height: Int, + ): Bitmap +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParser.kt new file mode 100644 index 0000000000..8a17428fb8 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParser.kt @@ -0,0 +1,96 @@ +package io.github.landwarderer.futon.mihon.parsers + +import io.github.landwarderer.futon.mihon.parsers.config.ConfigKey +import io.github.landwarderer.futon.mihon.parsers.config.ContentSourceConfig +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilterCapabilities +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.Favicons +import io.github.landwarderer.futon.mihon.parsers.model.NovelChapterContent +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQuery +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQueryCapabilities +import io.github.landwarderer.futon.mihon.parsers.util.LinkResolver +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.Interceptor +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentPage +import java.util.* + +interface ContentParser : Interceptor { + + val source: ContentSource + + /** + * Supported [SortOrder] variants. Must not be empty. + * + * For better performance use [EnumSet] for more than one item. + */ + val availableSortOrders: Set + + @Deprecated("Too complex. Use filterCapabilities instead") + val searchQueryCapabilities: ContentSearchQueryCapabilities + + val filterCapabilities: ContentListFilterCapabilities + + val config: ContentSourceConfig + + val authorizationProvider: ContentParserAuthProvider? + get() = this as? ContentParserAuthProvider + + /** + * Provide default domain and available alternatives, if any. + * + * Never hardcode domain in requests, use [domain] instead. + */ + val configKeyDomain: ConfigKey.Domain + + val domain: String + + @Deprecated("Too complex. Use getList with filter instead") + suspend fun getList(query: ContentSearchQuery): List + + suspend fun getList(offset: Int, order: SortOrder, filter: ContentListFilter): List + + /** + * Parse details for [Content]: chapters list, description, large cover, etc. + * Must return the same content, may change any fields excepts id, url and source + * @see Content.copy + */ + suspend fun getDetails(manga: Content): Content + + /** + * Parse pages list for specified chapter. + * @see ContentPage for details + */ + suspend fun getPages(chapter: ContentChapter): List + + /** + * Fetch direct link to the page image. + */ + suspend fun getPageUrl(page: ContentPage): String + + /** + * Optional: Returns the complete HTML and image resources for the novel chapter for offline download. Defaults to null. + */ + suspend fun getChapterContent(chapter: ContentChapter): NovelChapterContent? = null + + suspend fun getFilterOptions(): ContentListFilterOptions + + /** + * Parse favicons from the main page of the source`s website + */ + suspend fun getFavicons(): Favicons + + fun onCreateConfig(keys: MutableCollection>) + + suspend fun getRelatedContent(seed: Content): List + + fun getRequestHeaders(): Headers + + @InternalParsersApi + suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Content? +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserAuthProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserAuthProvider.kt new file mode 100644 index 0000000000..68504e94f3 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserAuthProvider.kt @@ -0,0 +1,27 @@ +package io.github.landwarderer.futon.mihon.parsers + +/** + * Implement this in your parser for authorization support + */ +interface ContentParserAuthProvider { + + /** + * Return link to the login page, which will be opened in browser. + * Must be an absolute url + */ + val authUrl: String + + /** + * Quick check if user is logged in. + * In most case you should check for cookies in [ContentLoaderContext.cookieJar]. + */ + suspend fun isAuthorized(): Boolean + + /** + * Fetch and return current user`s name or login. + * Normally should not be called if [isAuthorized] returns false + * @throws [AuthRequiredException] if user is not logged in or authorization is expired + * @throws [ParseException] on parsing error + */ + suspend fun getUsername(): String +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserCredentialsAuthProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserCredentialsAuthProvider.kt new file mode 100644 index 0000000000..f106f1373c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentParserCredentialsAuthProvider.kt @@ -0,0 +1,15 @@ +package io.github.landwarderer.futon.mihon.parsers + +/** + * Optional interface for parsers that support in-app username/password login. + * Host apps can detect and use this to provide credential input fields. + */ +interface ContentParserCredentialsAuthProvider { + + /** + * Perform authorization using provided username and password. + * Returns true if login succeeded and authorization cookies were set. + */ + + suspend fun login(username: String, password: String): Boolean +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentSourceParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentSourceParser.kt new file mode 100644 index 0000000000..0e7b6e7777 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ContentSourceParser.kt @@ -0,0 +1,28 @@ +package io.github.landwarderer.futon.mihon.parsers + +import io.github.landwarderer.futon.mihon.parsers.model.ContentType + +/** + * Annotate each [ContentParser] implementation with this annotation, used by codegen + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +public annotation class ContentSourceParser( + /** + * Name of content source. Used as an Enum value, must be UPPER_CASE and unique. + */ + val name: String, + /** + * User-friendly title of content source. In most case equals the website name. + * Avoid extra whitespaces between the words if it is not required. + */ + val title: String, + /** + * Language code (for example "en" or "ru") or blank if parser provide content on different languages. + */ + val locale: String = "", + /** + * Type of content provided by parser. See [ContentType] for more info + */ + val type: ContentType = ContentType.MANGA, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ErrorMessages.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ErrorMessages.kt new file mode 100644 index 0000000000..353d2a2187 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/ErrorMessages.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.mihon.parsers + +object ErrorMessages { + + const val FILTER_MULTIPLE_STATES_NOT_SUPPORTED: String = "Multiple states are not supported by this source" + const val FILTER_MULTIPLE_GENRES_NOT_SUPPORTED: String = "Multiple genres are not supported by this source" + const val FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED: String = + "Multiple Content ratings are not supported by this source" + const val FILTER_MULTIPLE_CONTENT_TYPES_NOT_SUPPORTED: String = + "Multiple Content types are not supported by this source" + const val FILTER_MULTIPLE_DEMOGRAPHICS_NOT_SUPPORTED: String = + "Multiple Demographics are not supported by this source" + const val FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED: String = + "Filtering by both genres and locale is not supported by this source" + const val FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED: String = + "Filtering by both genres and states is not supported by this source" + const val SEARCH_NOT_SUPPORTED: String = "Search is not supported by this source" +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesProvider.kt new file mode 100644 index 0000000000..e5a35dbad3 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesProvider.kt @@ -0,0 +1,8 @@ +package io.github.landwarderer.futon.mihon.parsers + +import io.github.landwarderer.futon.mihon.parsers.model.Content + +interface FavoritesProvider { + + suspend fun fetchFavorites(): List +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesSyncProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesSyncProvider.kt new file mode 100644 index 0000000000..e3ab439faa --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/FavoritesSyncProvider.kt @@ -0,0 +1,10 @@ +package io.github.landwarderer.futon.mihon.parsers + +import io.github.landwarderer.futon.mihon.parsers.model.Content + +interface FavoritesSyncProvider { + + suspend fun addFavorite(manga: Content): Boolean + + suspend fun removeFavorite(manga: Content): Boolean +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/InternalParsersApi.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/InternalParsersApi.kt new file mode 100644 index 0000000000..6672a028cf --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/InternalParsersApi.kt @@ -0,0 +1,14 @@ +package io.github.landwarderer.futon.mihon.parsers + +/** + * This marker distinguishes the internal API and is used to opt-in for that feature when parsers developing. + * + * Any usage of a declaration annotated with `@InternalParsersApi` must be accepted either by + * annotating that usage with the [OptIn] annotation, e.g. `@OptIn(InternalParsersApi::class)`, + * or by using the compiler argument `-opt-in=io.github.landwarderer.futon.mihon.parsers.InternalParsersApi`. + */ +@Retention(AnnotationRetention.BINARY) +@SinceKotlin("1.3") +@RequiresOptIn +@MustBeDocumented +annotation class InternalParsersApi diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Bitmap.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Bitmap.kt new file mode 100644 index 0000000000..38ac016a27 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Bitmap.kt @@ -0,0 +1,9 @@ +package io.github.landwarderer.futon.mihon.parsers.bitmap + +interface Bitmap { + + val width: Int + val height: Int + + fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Rect.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Rect.kt new file mode 100644 index 0000000000..b895ab5246 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/bitmap/Rect.kt @@ -0,0 +1,15 @@ +package io.github.landwarderer.futon.mihon.parsers.bitmap + +data class Rect( + val left: Int = 0, + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0, +) { + + val width: Int + get() = right - left + + val height: Int + get() = bottom - top +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ConfigKey.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ConfigKey.kt new file mode 100644 index 0000000000..a6fbe3346e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ConfigKey.kt @@ -0,0 +1,55 @@ +package io.github.landwarderer.futon.mihon.parsers.config + +sealed class ConfigKey( + @JvmField val key: String, +) { + + abstract val defaultValue: T + + class Domain( + @JvmField @JvmSuppressWildcards vararg val presetValues: String, + ) : ConfigKey("domain") { + + init { + require(presetValues.isNotEmpty()) { "You must provide at least one domain" } + } + + override val defaultValue: String + get() = presetValues.first() + } + + class Text( + key: String, + @JvmField val title: String, + override val defaultValue: String = "", + ) : ConfigKey(key) + + class ShowSuspiciousContent( + override val defaultValue: Boolean, + ) : ConfigKey("show_suspicious") + + class UserAgent( + override val defaultValue: String, + ) : ConfigKey("user_agent") + + class SplitByTranslations( + override val defaultValue: Boolean, + ) : ConfigKey("split_translations") + + class PreferredImageServer( + val presetValues: Map, + override val defaultValue: String?, + ) : ConfigKey("img_server") + + class Toggle( + key: String, + @JvmField val title: String, + override val defaultValue: Boolean = false, + ) : ConfigKey(key) + + class PreferredLanguage( + @JvmField val title: String, + @JvmField val presetValues: Map, + override val defaultValue: String = "all", + ) : ConfigKey("preferred_language") +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ContentSourceConfig.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ContentSourceConfig.kt new file mode 100644 index 0000000000..8c9a0001cf --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/config/ContentSourceConfig.kt @@ -0,0 +1,5 @@ +package io.github.landwarderer.futon.mihon.parsers.config + +interface ContentSourceConfig { + operator fun get(key: ConfigKey): T +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/AbstractContentParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/AbstractContentParser.kt new file mode 100644 index 0000000000..5f7fd17d4a --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/AbstractContentParser.kt @@ -0,0 +1,124 @@ +package io.github.landwarderer.futon.mihon.parsers.core + +import androidx.annotation.CallSuper +import io.github.landwarderer.futon.mihon.parsers.ContentLoaderContext +import io.github.landwarderer.futon.mihon.parsers.ContentParser +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.config.ConfigKey +import io.github.landwarderer.futon.mihon.parsers.config.ContentSourceConfig +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentPage +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.ContentType +import io.github.landwarderer.futon.mihon.parsers.model.Favicons +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQuery +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQueryCapabilities +import io.github.landwarderer.futon.mihon.parsers.network.OkHttpWebClient +import io.github.landwarderer.futon.mihon.parsers.network.WebClient +import io.github.landwarderer.futon.mihon.parsers.util.FaviconParser +import io.github.landwarderer.futon.mihon.parsers.util.LinkResolver +import io.github.landwarderer.futon.mihon.parsers.util.RelatedContentFinder +import io.github.landwarderer.futon.mihon.parsers.util.convertToContentListFilter +import io.github.landwarderer.futon.mihon.parsers.util.toAbsoluteUrl +import io.github.landwarderer.futon.mihon.parsers.util.toContentSearchQueryCapabilities +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import java.util.Locale + +@Suppress("OVERRIDE_DEPRECATION") +@InternalParsersApi +public abstract class AbstractContentParser @InternalParsersApi constructor( + @property:InternalParsersApi public val context: ContentLoaderContext, + public final override val source: ContentSource, +) : ContentParser { + + public final override val searchQueryCapabilities: ContentSearchQueryCapabilities + get() = filterCapabilities.toContentSearchQueryCapabilities() + + public override val config: ContentSourceConfig by lazy { context.getConfig(source) } + + public open val sourceLocale: Locale + get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale) + + protected val sourceContentRating: ContentRating? + get() = if (source.contentType == ContentType.HENTAI_MANGA) { + ContentRating.ADULT + } else { + null + } + + protected val isNsfwSource: Boolean = source.contentType == ContentType.HENTAI_MANGA + + protected open val userAgentKey: ConfigKey.UserAgent = try { + ConfigKey.UserAgent(context.getDefaultUserAgent()) + } catch (_: NoSuchMethodError) { + ConfigKey.UserAgent(io.github.landwarderer.futon.mihon.parsers.network.UserAgents.CHROME_MOBILE) + } + + override fun getRequestHeaders(): Headers = Headers.Builder() + .add("User-Agent", config[userAgentKey]) + .build() + + /** + * Used as fallback if value of `order` passed to [getList] is null + */ + public open val defaultSortOrder: SortOrder + get() { + val supported = availableSortOrders + return SortOrder.entries.first { it in supported } + } + + final override val domain: String + get() = config[configKeyDomain] + + @JvmField + protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source) + + /** + * Search list of manga by specified searchQuery + * + * @param query searchQuery + */ + public override suspend fun getList(query: ContentSearchQuery): List = getList( + offset = query.offset, + order = query.order ?: defaultSortOrder, + filter = convertToContentListFilter(query), + ) + + /** + * Fetch direct link to the page image. + */ + public override suspend fun getPageUrl(page: ContentPage): String = page.url.toAbsoluteUrl(domain) + + protected open val faviconDomain: String + get() = domain + + /** + * Parse favicons from the main page of the source`s website + */ + public override suspend fun getFavicons(): Favicons { + return FaviconParser(webClient, faviconDomain).parseFavicons() + } + + @CallSuper + public override fun onCreateConfig(keys: MutableCollection>) { + keys.add(configKeyDomain) + } + + public override suspend fun getRelatedContent(seed: Content): List { + return RelatedContentFinder(listOf(this)).invoke(seed) + } + + /** + * Return [Content] object by web link to it + * @see [Content.publicUrl] + */ + override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Content? = null + + override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()) +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/ContentParserWrapper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/ContentParserWrapper.kt new file mode 100644 index 0000000000..cc3687696b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/ContentParserWrapper.kt @@ -0,0 +1,88 @@ +package io.github.landwarderer.futon.mihon.parsers.core + +import io.github.landwarderer.futon.mihon.parsers.ContentParser +import io.github.landwarderer.futon.mihon.parsers.ContentParserAuthProvider +import io.github.landwarderer.futon.mihon.parsers.FavoritesProvider +import io.github.landwarderer.futon.mihon.parsers.FavoritesSyncProvider +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +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.ContentPage +import io.github.landwarderer.futon.mihon.parsers.model.Favicons +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQuery +import io.github.landwarderer.futon.mihon.parsers.util.mergeWith +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +internal class ContentParserWrapper( + private val delegate: ContentParser, +) : ContentParser by delegate { + + internal val favoritesProvider: FavoritesProvider? = delegate as? FavoritesProvider + internal val favoritesSyncProvider: FavoritesSyncProvider? = delegate as? FavoritesSyncProvider + + override val authorizationProvider: ContentParserAuthProvider? + get() = delegate as? ContentParserAuthProvider + + @Deprecated("Too complex. Use getList with filter instead") + override suspend fun getList(query: ContentSearchQuery): List = withContext(Dispatchers.Default) { + if (!query.skipValidation) { + searchQueryCapabilities.validate(query) + } + delegate.getList(query) + } + + override suspend fun getList( + offset: Int, + order: SortOrder, + filter: ContentListFilter, + ): List = withContext(Dispatchers.Default) { + delegate.getList(offset, order, filter) + } + + override suspend fun getDetails(manga: Content): Content = withContext(Dispatchers.Default) { + delegate.getDetails(manga) + } + + override suspend fun getPages(chapter: ContentChapter): List = withContext(Dispatchers.Default) { + delegate.getPages(chapter) + } + + override suspend fun getPageUrl(page: ContentPage): String = withContext(Dispatchers.Default) { + delegate.getPageUrl(page) + } + + override suspend fun getFilterOptions(): ContentListFilterOptions = withContext(Dispatchers.Default) { + delegate.getFilterOptions() + } + + override suspend fun getFavicons(): Favicons = withContext(Dispatchers.Default) { + delegate.getFavicons() + } + + override suspend fun getRelatedContent(seed: Content): List = withContext(Dispatchers.Default) { + delegate.getRelatedContent(seed) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val headers = request.headers.newBuilder() + .mergeWith(delegate.getRequestHeaders(), replaceExisting = false) + .build() + val newRequest = request.newBuilder().headers(headers).build() + return delegate.intercept(ProxyChain(chain, newRequest)) + } + + private class ProxyChain( + private val delegate: Interceptor.Chain, + private val request: Request, + ) : Interceptor.Chain by delegate { + + override fun request(): Request = request + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/FlexiblePagedContentParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/FlexiblePagedContentParser.kt new file mode 100644 index 0000000000..e79a991e87 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/FlexiblePagedContentParser.kt @@ -0,0 +1,63 @@ +package io.github.landwarderer.futon.mihon.parsers.core + +import androidx.annotation.VisibleForTesting +import io.github.landwarderer.futon.mihon.parsers.ContentLoaderContext +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQuery +import io.github.landwarderer.futon.mihon.parsers.model.search.SearchableField +import io.github.landwarderer.futon.mihon.parsers.util.Paginator + +@OptIn(InternalParsersApi::class) +@Deprecated("Too complex. Use PagedContentParser instead") +internal abstract class FlexiblePagedContentParser( + context: ContentLoaderContext, + source: ContentSource, + @get:VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) @JvmField public val pageSize: Int, + searchPageSize: Int = pageSize, +) : AbstractContentParser(context, source) { + + @JvmField + protected val paginator: Paginator = Paginator(pageSize) + + @JvmField + protected val searchPaginator: Paginator = Paginator(searchPageSize) + + final override suspend fun getList(query: ContentSearchQuery): List { + var containTitleNameCriteria = false + query.criteria.forEach { + if (it.field == SearchableField.TITLE_NAME) { + containTitleNameCriteria = true + } + } + + return searchContent( + paginator = if (containTitleNameCriteria) { + paginator + } else { + searchPaginator + }, + query = query, + ) + } + + public abstract suspend fun getListPage(query: ContentSearchQuery, page: Int): List + + protected fun setFirstPage(firstPage: Int, firstPageForSearch: Int = firstPage) { + paginator.firstPage = firstPage + searchPaginator.firstPage = firstPageForSearch + } + + private suspend fun searchContent( + paginator: Paginator, + query: ContentSearchQuery, + ): List { + val offset: Int = query.offset + val page = paginator.getPage(offset) + val list = getListPage(query, page) + paginator.onListReceived(offset, page, list.size) + return list + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/PagedContentParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/PagedContentParser.kt new file mode 100644 index 0000000000..be50d92164 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/PagedContentParser.kt @@ -0,0 +1,58 @@ +package io.github.landwarderer.futon.mihon.parsers.core + +import androidx.annotation.VisibleForTesting +import io.github.landwarderer.futon.mihon.parsers.ContentLoaderContext +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder +import io.github.landwarderer.futon.mihon.parsers.util.Paginator + +@InternalParsersApi +public abstract class PagedContentParser( + context: ContentLoaderContext, + source: ContentSource, + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) @JvmField public val pageSize: Int, + searchPageSize: Int = pageSize, +) : AbstractContentParser(context, source) { + + @JvmField + protected val paginator: Paginator = Paginator(pageSize) + + @JvmField + protected val searchPaginator: Paginator = Paginator(searchPageSize) + + final override suspend fun getList(offset: Int, order: SortOrder, filter: ContentListFilter): List { + return getList( + paginator = if (filter.query.isNullOrEmpty()) { + paginator + } else { + searchPaginator + }, + offset = offset, + order = order, + filter = filter, + ) + } + + public abstract suspend fun getListPage(page: Int, order: SortOrder, filter: ContentListFilter): List + + protected fun setFirstPage(firstPage: Int, firstPageForSearch: Int = firstPage) { + paginator.firstPage = firstPage + searchPaginator.firstPage = firstPageForSearch + } + + private suspend fun getList( + paginator: Paginator, + offset: Int, + order: SortOrder, + filter: ContentListFilter, + ): List { + val page = paginator.getPage(offset) + val list = getListPage(page, order, filter) + paginator.onListReceived(offset, page, list.size) + return list + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/SinglePageContentParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/SinglePageContentParser.kt new file mode 100644 index 0000000000..a605bd168d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/core/SinglePageContentParser.kt @@ -0,0 +1,25 @@ +package io.github.landwarderer.futon.mihon.parsers.core + +import io.github.landwarderer.futon.mihon.parsers.ContentLoaderContext +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder + +@InternalParsersApi +public abstract class SinglePageContentParser( + context: ContentLoaderContext, + source: ContentSource, +) : AbstractContentParser(context, source) { + + final override suspend fun getList(offset: Int, order: SortOrder, filter: ContentListFilter): List { + if (offset > 0) { + return emptyList() + } + return getList(order, filter) + } + + public abstract suspend fun getList(order: SortOrder, filter: ContentListFilter): List +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/AuthRequiredException.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/AuthRequiredException.kt new file mode 100644 index 0000000000..9a87c43328 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/AuthRequiredException.kt @@ -0,0 +1,14 @@ +package io.github.landwarderer.futon.mihon.parsers.exception + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import okio.IOException + +/** + * Authorization is required for access to the requested content + */ +public class AuthRequiredException @InternalParsersApi @JvmOverloads constructor( + public val source: ContentSource, + cause: Throwable? = null, +) : IOException("Authorization required", cause) + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ContentUnavailableException.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ContentUnavailableException.kt new file mode 100644 index 0000000000..b2009d229a --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ContentUnavailableException.kt @@ -0,0 +1,3 @@ +package io.github.landwarderer.futon.mihon.parsers.exception + +public class ContentUnavailableException(message: String) : RuntimeException(message) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/GraphQLException.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/GraphQLException.kt new file mode 100644 index 0000000000..e9f069eebc --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/GraphQLException.kt @@ -0,0 +1,17 @@ +package io.github.landwarderer.futon.mihon.parsers.exception + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.util.json.mapJSONNotNull +import okio.IOException +import org.json.JSONArray + +public class GraphQLException @InternalParsersApi constructor(errors: JSONArray) : IOException() { + + public val messages: List = errors.mapJSONNotNull { + it.getString("message") + } + + override val message: String + get() = messages.joinToString("\n") +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/NotFoundException.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/NotFoundException.kt new file mode 100644 index 0000000000..cf4e24d670 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/NotFoundException.kt @@ -0,0 +1,9 @@ +package io.github.landwarderer.futon.mihon.parsers.exception + +import org.jsoup.HttpStatusException +import java.net.HttpURLConnection + +public class NotFoundException( + message: String, + url: String, +) : HttpStatusException(message, HttpURLConnection.HTTP_NOT_FOUND, url) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ParseException.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ParseException.kt new file mode 100644 index 0000000000..2d6753b37c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/ParseException.kt @@ -0,0 +1,10 @@ +package io.github.landwarderer.futon.mihon.parsers.exception + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi + +public class ParseException @InternalParsersApi @JvmOverloads constructor( + public val shortMessage: String?, + public val url: String, + cause: Throwable? = null, +) : RuntimeException("$shortMessage at $url", cause) + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/TooManyRequestExceptions.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/TooManyRequestExceptions.kt new file mode 100644 index 0000000000..18a470a7b4 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/exception/TooManyRequestExceptions.kt @@ -0,0 +1,31 @@ +package io.github.landwarderer.futon.mihon.parsers.exception + +import okio.IOException +import java.time.Instant +import java.time.temporal.ChronoUnit + +public class TooManyRequestExceptions( + public val url: String, + retryAfter: Long, +) : IOException("Too man requests") { + + public val retryAt: Instant? = if (retryAfter > 0 && retryAfter < Long.MAX_VALUE) { + Instant.now().plusMillis(retryAfter) + } else { + null + } + + public fun getRetryDelay(): Long { + if (retryAt == null) { + return -1L + } + return Instant.now().until(retryAt, ChronoUnit.MILLIS).coerceAtLeast(0L) + } + + override val message: String? + get() = if (retryAt != null) { + "${super.message}, retry at $retryAt" + } else { + super.message + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Constants.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Constants.kt new file mode 100644 index 0000000000..c0f857e932 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Constants.kt @@ -0,0 +1,11 @@ +@file:JvmName("Constants") + +package io.github.landwarderer.futon.mihon.parsers.model + +public const val RATING_UNKNOWN: Float = -1f + +public const val YEAR_UNKNOWN: Int = 0 + +public const val YEAR_MIN: Int = 1900 + +public const val YEAR_MAX: Int = 2099 diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Content.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Content.kt new file mode 100644 index 0000000000..608f0a08e2 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Content.kt @@ -0,0 +1,238 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import androidx.collection.ArrayMap +import io.github.landwarderer.futon.mihon.parsers.util.findById +import io.github.landwarderer.futon.mihon.parsers.util.nullIfEmpty + +data class Content( + /** + * Unique identifier for manga + */ + @JvmField val id: Long, + /** + * Content title, human-readable + */ + @JvmField val title: String, + /** + * Alternative titles (for example on other language), may be empty + */ + @JvmField val altTitles: Set, + /** + * Relative url to manga (**without** a domain) or any other uri. + * Used principally in parsers + */ + @JvmField val url: String, + /** + * Absolute url to manga, must be ready to open in browser + */ + @JvmField val Url: String, + /** + * Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown + * @see hasRating + */ + @JvmField val rating: Float, + /** + * Indicates that manga may contain sensitive information (18+, NSFW) + */ + @JvmField val contentRating: ContentRating?, + /** + * Absolute link to the cover + * @see largeCoverUrl + */ + @JvmField val coverUrl: String?, + /** + * Tags (genres) of the manga + */ + @JvmField val tags: Set, + /** + * Content status (ongoing, finished) or null if unknown + */ + @JvmField val state: ContentState?, + /** + * Authors of the manga + */ + @JvmField val authors: Set, + /** + * Large cover url (absolute), null if is no large cover + * @see coverUrl + */ + @JvmField val largeCoverUrl: String? = null, + /** + * Content description, may be html or null + */ + @JvmField val description: String? = null, + /** + * List of chapters + */ + @JvmField val chapters: List? = null, + /** + * Content source + */ + @JvmField val source: ContentSource, +) { + + @Deprecated("Accepts rating as Int; use Float in range 0..1 instead") + constructor( + id: Long, + title: String, + altTitles: Set, + url: String, + Url: String, + rating: Int, + contentRating: ContentRating?, + coverUrl: String?, + tags: Set, + state: ContentState?, + authors: Set, + largeCoverUrl: String? = null, + description: String? = null, + chapters: List? = null, + source: ContentSource, + ) : this( + id = id, + title = title, + altTitles = altTitles, + url = url, + Url = Url, + rating = rating.toFloat(), + contentRating = contentRating, + coverUrl = coverUrl?.nullIfEmpty(), + tags = tags, + state = state, + authors = authors, + largeCoverUrl = largeCoverUrl?.nullIfEmpty(), + description = description?.nullIfEmpty(), + chapters = chapters, + source = source, + ) + + @Deprecated("Use other constructor") + constructor( + /** + * Unique identifier for manga + */ + id: Long, + /** + * Content title, human-readable + */ + title: String, + /** + * Alternative title (for example on other language), may be null + */ + altTitle: String?, + /** + * Relative url to manga (**without** a domain) or any other uri. + * Used principally in parsers + */ + url: String, + /** + * Absolute url to manga, must be ready to open in browser + */ + Url: String, + /** + * Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown + * @see hasRating + */ + rating: Float, + /** + * Indicates that manga may contain sensitive information (18+, NSFW) + */ + isNsfw: Boolean, + /** + * Absolute link to the cover + * @see largeCoverUrl + */ + coverUrl: String?, + /** + * Tags (genres) of the manga + */ + tags: Set, + /** + * Content status (ongoing, finished) or null if unknown + */ + state: ContentState?, + /** + * Authors of the manga + */ + author: String?, + /** + * Large cover url (absolute), null if is no large cover + * @see coverUrl + */ + largeCoverUrl: String? = null, + /** + * Content description, may be html or null + */ + description: String? = null, + /** + * List of chapters + */ + chapters: List? = null, + /** + * Content source + */ + source: ContentSource, + ) : this( + id = id, + title = title, + altTitles = setOfNotNull(altTitle?.nullIfEmpty()), + url = url, + Url = Url, + rating = rating, + contentRating = if (isNsfw) ContentRating.ADULT else null, + coverUrl = coverUrl?.nullIfEmpty(), + tags = tags, + state = state, + authors = setOfNotNull(author), + largeCoverUrl = largeCoverUrl?.nullIfEmpty(), + description = description?.nullIfEmpty(), + chapters = chapters, + source = source, + ) + + /** + * Author of the manga, may be null + */ + @Deprecated("Please use authors") + val author: String? + get() = authors.firstOrNull() + + /** + * Alternative title (for example on other language), may be null + */ + @Deprecated("Please use altTitles") + val altTitle: String? + get() = altTitles.firstOrNull() + + /** + * Return if manga has a specified rating + * @see rating + */ + val hasRating: Boolean + get() = rating > 0f && rating <= 1f + + @Deprecated("Use contentRating instead", ReplaceWith("contentRating == ContentRating.ADULT")) + val isNsfw: Boolean + get() = contentRating == ContentRating.ADULT + + fun getChapters(branch: String?): List { + return chapters?.filter { x -> x.branch == branch }.orEmpty() + } + + fun findChapterById(id: Long): ContentChapter? = chapters?.findById(id) + + fun requireChapterById(id: Long): ContentChapter = findChapterById(id) + ?: throw NoSuchElementException("Chapter with id $id not found") + + fun getBranches(): Map { + if (chapters.isNullOrEmpty()) { + return emptyMap() + } + val result = ArrayMap() + chapters.forEach { + val key = it.branch + result[key] = result.getOrDefault(key, 0) + 1 + } + return result + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentChapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentChapter.kt new file mode 100644 index 0000000000..d0c51060e4 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentChapter.kt @@ -0,0 +1,90 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import io.github.landwarderer.futon.mihon.parsers.util.formatSimple +import io.github.landwarderer.futon.mihon.parsers.util.ifNullOrEmpty + +public data class ContentChapter( + /** + * An unique id of chapter + */ + @JvmField public val id: Long, + /** + * User-readable name of chapter if provided by parser or null instead + * Do not pass manga title or chapter number here + */ + @JvmField public val title: String?, + /** + * Chapter number starting from 1, 0 if unknown + */ + @JvmField public val number: Float, + /** + * Volume number starting from 1, 0 if unknown + */ + @JvmField public val volume: Int, + /** + * Relative url to chapter (**without** a domain) or any other uri. + * Used principally in parsers + */ + @JvmField public val url: String, + /** + * User-readable name of scanlator (releaser) or null if unknown + */ + @JvmField public val scanlator: String?, + /** + * Chapter upload date in milliseconds + */ + @JvmField public val uploadDate: Long, + /** + * User-readable name of branch. + * A branch is a group of chapters that overlap (e.g. different languages) + */ + @JvmField public val branch: String?, + @JvmField public val source: ContentSource, +) { + + @Deprecated("Use title instead of name", ReplaceWith("ContentChapter(id, title, number, volume, url, scanlator, uploadDate, branch, source)")) + public constructor( + id: Long, + name: String?, + number: Float, + volume: Int, + url: String, + scanlator: String?, + uploadDate: Long, + branch: String?, + source: ContentSource, + @Suppress("UNUSED_PARAMETER") dummy: Boolean = false, + ) : this( + id = id, + title = name, + number = number, + volume = volume, + url = url, + scanlator = scanlator, + uploadDate = uploadDate, + branch = branch, + source = source, + ) + + @Deprecated("Use title instead", ReplaceWith("title")) + val name: String + get() = title.ifNullOrEmpty { + buildString { + if (volume > 0) append("Vol ").append(volume).append(' ') + if (number > 0) append("Chapter ").append(number.formatSimple()) else append("Unnamed") + } + } + + public fun numberString(): String? = if (number > 0f) { + number.formatSimple() + } else { + null + } + + public fun volumeString(): String? = if (volume > 0) { + volume.toString() + } else { + null + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilter.kt new file mode 100644 index 0000000000..f5e5cb5af6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilter.kt @@ -0,0 +1,88 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import java.util.Locale + +public data class ContentListFilter( + @JvmField val query: String? = null, + @JvmField val tags: Set = emptySet(), + @JvmField val tagsExclude: Set = emptySet(), + @JvmField val locale: Locale? = null, + @JvmField val originalLocale: Locale? = null, + @JvmField val states: Set = emptySet(), + @JvmField val contentRating: Set = emptySet(), + @JvmField val types: Set = emptySet(), + @JvmField val demographics: Set = emptySet(), + @JvmField val year: Int = YEAR_UNKNOWN, + @JvmField val yearFrom: Int = YEAR_UNKNOWN, + @JvmField val yearTo: Int = YEAR_UNKNOWN, + @JvmField val author: String? = null, +) { + + private fun isNonSearchOptionsEmpty(): Boolean = tags.isEmpty() && + tagsExclude.isEmpty() && + locale == null && + originalLocale == null && + states.isEmpty() && + contentRating.isEmpty() && + year == YEAR_UNKNOWN && + yearFrom == YEAR_UNKNOWN && + yearTo == YEAR_UNKNOWN && + types.isEmpty() && + demographics.isEmpty() && + author.isNullOrEmpty() + + public fun isEmpty(): Boolean = isNonSearchOptionsEmpty() && query.isNullOrEmpty() + + public fun isNotEmpty(): Boolean = !isEmpty() + + public fun hasNonSearchOptions(): Boolean = !isNonSearchOptionsEmpty() + + public companion object { + + @JvmStatic + public val EMPTY: ContentListFilter = ContentListFilter() + } + + internal class Builder { + private var query: String? = null + private val tags: MutableSet = mutableSetOf() + private val tagsExclude: MutableSet = mutableSetOf() + private var locale: Locale? = null + private var originalLocale: Locale? = null + private val states: MutableSet = mutableSetOf() + private val contentRating: MutableSet = mutableSetOf() + private val types: MutableSet = mutableSetOf() + private val demographics: MutableSet = mutableSetOf() + private var year: Int = YEAR_UNKNOWN + private var yearFrom: Int = YEAR_UNKNOWN + private var yearTo: Int = YEAR_UNKNOWN + + fun query(query: String?): Builder = apply { this.query = query } + fun addTag(tag: ContentTag): Builder = apply { tags.add(tag) } + fun addTags(tags: Collection): Builder = apply { this.tags.addAll(tags) } + fun excludeTag(tag: ContentTag): Builder = apply { tagsExclude.add(tag) } + fun excludeTags(tags: Collection): Builder = apply { this.tagsExclude.addAll(tags) } + fun locale(locale: Locale?): Builder = apply { this.locale = locale } + fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale } + fun addState(state: ContentState): Builder = apply { states.add(state) } + fun addStates(states: Collection): Builder = apply { this.states.addAll(states) } + fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) } + fun addContentRatings(ratings: Collection): Builder = + apply { this.contentRating.addAll(ratings) } + + fun addType(type: ContentType): Builder = apply { types.add(type) } + fun addTypes(types: Collection): Builder = apply { this.types.addAll(types) } + fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) } + fun addDemographics(demographics: Collection): Builder = + apply { this.demographics.addAll(demographics) } + + fun year(year: Int): Builder = apply { this.year = year } + fun yearFrom(year: Int): Builder = apply { this.yearFrom = year } + fun yearTo(year: Int): Builder = apply { this.yearTo = year } + + fun build(): ContentListFilter = ContentListFilter( + query, tags, tagsExclude, locale, originalLocale, states, + contentRating, types, demographics, year, yearFrom, yearTo, + ) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterCapabilities.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterCapabilities.kt new file mode 100644 index 0000000000..e4945a8aec --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterCapabilities.kt @@ -0,0 +1,57 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi + +public data class ContentListFilterCapabilities @InternalParsersApi constructor( + + /** + * Whether parser supports filtering by more than one tag + * @see [ContentListFilter.tags] + * @see [ContentListFilterOptions.availableTags] + */ + val isMultipleTagsSupported: Boolean = false, + + /** + * Whether parser supports tagsExclude field in filter + * @see [ContentListFilter.tagsExclude] + * @see [ContentListFilterOptions.availableTags] + */ + val isTagsExclusionSupported: Boolean = false, + + /** + * Whether parser supports searching by string query + * @see [ContentListFilter.query] + */ + val isSearchSupported: Boolean = false, + + /** + * Whether parser supports searching by string query combined within other filters + */ + val isSearchWithFiltersSupported: Boolean = false, + + /** + * Whether parser supports searching/filtering by year + * @see [ContentListFilter.year] + */ + val isYearSupported: Boolean = false, + + /** + * Whether parser supports searching by year range + * @see [ContentListFilter.yearFrom] and [ContentListFilter.yearTo] + */ + val isYearRangeSupported: Boolean = false, + + /** + * Whether parser supports searching Original Languages + * @see [ContentListFilter.originalLocale] + * @see [ContentListFilterOptions.availableLocales] + */ + val isOriginalLocaleSupported: Boolean = false, + + /** + * Whether parser supports searching by author name + * @see [ContentListFilter.author] + */ + val isAuthorSearchSupported: Boolean = false, +) + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterOptions.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterOptions.kt new file mode 100644 index 0000000000..625b9a4899 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentListFilterOptions.kt @@ -0,0 +1,62 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import java.util.EnumSet +import java.util.Locale + +public data class ContentListFilterOptions @InternalParsersApi constructor( + + /** + * Available tags (genres) + */ + public val availableTags: Set = emptySet(), + + /** + * Optional grouped tags for better UI presentation. + * Client should prefer groups if not empty, otherwise fallback to [availableTags]. + */ + public val tagGroups: List = emptyList(), + + /** + * Effective tag groups: use [tagGroups] if provided, otherwise wrap [availableTags] as a single group. + */ + public val effectiveTagGroups: List = when { + tagGroups.isNotEmpty() -> tagGroups + availableTags.isNotEmpty() -> listOf(ContentTagGroup("Tags", availableTags)) + else -> emptyList() + }, + + /** + * Supported [ContentState] variants for filtering. May be empty. + * + * For better performance use [EnumSet] for more than one item. + */ + public val availableStates: Set = emptySet(), + + /** + * Supported [ContentRating] variants for filtering. May be empty. + * + * For better performance use [EnumSet] for more than one item. + */ + public val availableContentRating: Set = emptySet(), + + /** + * Supported [ContentType] variants for filtering. May be empty. + * + * For better performance use [EnumSet] for more than one item. + */ + public val availableContentTypes: Set = emptySet(), + + /** + * Supported [Demographic] variants for filtering. May be empty. + * + * For better performance use [EnumSet] for more than one item. + */ + public val availableDemographics: Set = emptySet(), + + /** + * Supported content locales for multilingual sources + */ + public val availableLocales: Set = emptySet(), +) + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentPage.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentPage.kt new file mode 100644 index 0000000000..4fc0b2f29c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentPage.kt @@ -0,0 +1,33 @@ +package io.github.landwarderer.futon.mihon.parsers.model + + +data class ContentPage( + /** + * Unique identifier for page + */ + @JvmField val id: Long, + /** + * Relative url to page (**without** a domain) or any other uri. + * Used principally in parsers. + * May contain link to image or html page. + * @see ContentParser.getPageUrl + */ + @JvmField val url: String, + /** + * Absolute url of the small page image if exists, null otherwise + */ + @JvmField val preview: String?, + /** + * Optional per-page request headers (e.g., Referer) to be applied when fetching the page/image. + */ + @JvmField val headers: Map? = null, + @JvmField val source: ContentSource, +) + +@Deprecated("Use id instead of index", ReplaceWith("ContentPage(index.toLong(), url, previewUrl, source)")) +fun ContentPage(index: Int, url: String, previewUrl: String?, source: ContentSource): ContentPage = ContentPage( + id = index.toLong(), + url = url, + preview = previewUrl, + source = source, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentRating.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentRating.kt new file mode 100644 index 0000000000..fbc7e78208 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentRating.kt @@ -0,0 +1,7 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +public enum class ContentRating { + SAFE, + SUGGESTIVE, + ADULT +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentSource.kt new file mode 100644 index 0000000000..c7318ad51b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentSource.kt @@ -0,0 +1,10 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import org.koitharu.kotatsu.parsers.model.MangaSource + +interface ContentSource : MangaSource { + + override val name: String + val locale: String + val contentType: ContentType +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentState.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentState.kt new file mode 100644 index 0000000000..3a7ca70b8b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentState.kt @@ -0,0 +1,5 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +public enum class ContentState { + ONGOING, FINISHED, ABANDONED, PAUSED, UPCOMING, RESTRICTED +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTag.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTag.kt new file mode 100644 index 0000000000..3bbfd9fbac --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTag.kt @@ -0,0 +1,17 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import io.github.landwarderer.futon.mihon.parsers.ContentParser + +public data class ContentTag( + /** + * User-readable tag title, should be in Title case + */ + @JvmField public val title: String, + /** + * Identifier of a tag, must be unique among the source. + * @see ContentParser.getList + */ + @JvmField public val key: String, + @JvmField public val source: ContentSource, +) + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTagGroup.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTagGroup.kt new file mode 100644 index 0000000000..f76018b8f1 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentTagGroup.kt @@ -0,0 +1,11 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +/** + * Group of tags for UI presentation. + * Clients may fall back to [ContentListFilterOptions.availableTags] if not supported. + */ +public data class ContentTagGroup( + @JvmField val title: String, + @JvmField val tags: Set, + @JvmField val isExclusive: Boolean = false, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentType.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentType.kt new file mode 100644 index 0000000000..67fd3c8ad6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/ContentType.kt @@ -0,0 +1,51 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +public enum class ContentType { + + /** + * Standard manga, manhua, webtoons, etc + */ + MANGA, + + MANHWA, + + MANHUA, + + /** + * Adult manga (explicit). + */ + HENTAI_MANGA, + + /** + * Adult novels (explicit). + */ + HENTAI_NOVEL, + + /** + * Adult videos (explicit). + */ + HENTAI_VIDEO, + + /** + * Western comics + */ + COMICS, + + /** + * Video content (e.g., anime clips or full videos) + */ + VIDEO, + + NOVEL, + + /** + * Use this type if no other suits your needs. For example, for an indie manga + */ + + ONE_SHOT, + DOUJINSHI, + IMAGE_SET, + ARTIST_CG, + GAME_CG, + OTHER, +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Demographic.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Demographic.kt new file mode 100644 index 0000000000..245548d928 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Demographic.kt @@ -0,0 +1,10 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +public enum class Demographic { + SHOUNEN, + SHOUJO, + SEINEN, + JOSEI, + KODOMO, + NONE, +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicon.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicon.kt new file mode 100644 index 0000000000..489195b4c6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicon.kt @@ -0,0 +1,28 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import okhttp3.HttpUrl.Companion.toHttpUrl + +public data class Favicon( + @JvmField public val url: String, + @JvmField public val size: Int, + @JvmField internal val rel: String?, +) : Comparable { + + @JvmField + public val type: String = url.toHttpUrl().pathSegments.last() + .substringAfterLast('.', "").lowercase() + + override fun compareTo(other: Favicon): Int { + val res = size.compareTo(other.size) + if (res != 0) { + return res + } + return relWeightOf(rel).compareTo(relWeightOf(other.rel)) + } + + private fun relWeightOf(rel: String?) = when (rel) { + "apple-touch-icon" -> 1 // Prefer apple-touch-icon because it has a better quality + "mask-icon" -> -1 + else -> 0 + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicons.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicons.kt new file mode 100644 index 0000000000..9c5efe6a55 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/Favicons.kt @@ -0,0 +1,59 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +public class Favicons( + favicons: Collection, + @JvmField public val referer: String?, +) : Collection { + + private val icons = favicons.sortedDescending() + + override val size: Int + get() = icons.size + + override fun contains(element: Favicon): Boolean = icons.contains(element) + + override fun containsAll(elements: Collection): Boolean = icons.containsAll(elements) + + override fun isEmpty(): Boolean = icons.isEmpty() + + override fun iterator(): Iterator = icons.iterator() + + public operator fun minus(victim: Favicon): Favicons = Favicons( + favicons = icons.filterNot { it == victim }, + referer = referer, + ) + + /** + * Finds a favicon whose size in pixels is greater than or equal to the specified size. + * If such icon is not available returns the largest icon + * @param size in pixels + * @param types supported file types, e.g. png, svg, ico. May be null but not empty + */ + @JvmOverloads + public fun find(size: Int, types: Set? = null): Favicon? { + if (icons.isEmpty()) { + return null + } + var result: Favicon? = null + for (icon in icons) { + if (types != null && icon.type !in types) { + continue + } + if (result == null || icon.size >= size) { + result = icon + } else { + break + } + } + return result + } + + public companion object { + + @JvmStatic + public val EMPTY: Favicons = Favicons(emptySet(), null) + + @JvmStatic + public fun single(url: String): Favicons = Favicons(setOf(Favicon(url, 0, null)), null) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/NovelChapterContent.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/NovelChapterContent.kt new file mode 100644 index 0000000000..4dca418583 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/NovelChapterContent.kt @@ -0,0 +1,16 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +/** + * 小说章节的完整内容,用于离线下载与本地渲染。 + * - html: 渲染用的完整 HTML(img 可为原始 URL,调用方可替换为本地路径) + * - images: 章节中涉及的图片资源(包含必要的请求头信息) + */ +public data class NovelChapterContent( + val html: String, + val images: List = emptyList(), +) { + public data class NovelImage( + val url: String, + val headers: Map = emptyMap(), + ) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/SortOrder.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/SortOrder.kt new file mode 100644 index 0000000000..cfa5306e2e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/SortOrder.kt @@ -0,0 +1,22 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +public enum class SortOrder { + UPDATED, + UPDATED_ASC, + POPULARITY, + POPULARITY_ASC, + RATING, + RATING_ASC, + NEWEST, + NEWEST_ASC, + ALPHABETICAL, + ALPHABETICAL_DESC, + ADDED, + ADDED_ASC, + RELEVANCE, + POPULARITY_HOUR, + POPULARITY_TODAY, + POPULARITY_WEEK, + POPULARITY_MONTH, + POPULARITY_YEAR, +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/WordSet.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/WordSet.kt new file mode 100644 index 0000000000..7cf9556e7b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/WordSet.kt @@ -0,0 +1,12 @@ +package io.github.landwarderer.futon.mihon.parsers.model + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi + +@InternalParsersApi +public class WordSet(private vararg val words: String) { + + public fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) } + public fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) } + public fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQuery.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQuery.kt new file mode 100644 index 0000000000..2ee4c2378f --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQuery.kt @@ -0,0 +1,91 @@ +package io.github.landwarderer.futon.mihon.parsers.model.search + +import androidx.collection.ArrayMap +import androidx.collection.ArraySet +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder + +/** + * Represents a search query for filtering and sorting manga search results. + * This class is immutable and must be constructed using the [Builder]. + * + * @property criteria The set of search criteria applied to the query. + * @property order The sorting order for the results (optional). + * @property offset The offset number for paginated search results (optional). + */ + +@Deprecated("Too complex. Use ContentListFilter instead") +@ConsistentCopyVisibility +public data class ContentSearchQuery private constructor( + @JvmField public val criteria: Set>, + @JvmField public val order: SortOrder?, + @JvmField public val offset: Int, + @JvmField public val skipValidation: Boolean, +) { + + public fun newBuilder(): Builder = Builder(this) + + public class Builder { + + private val criteria = ArraySet>() + private var order: SortOrder? = null + private var offset: Int = 0 + private var skipValidation: Boolean = false + + public constructor() + + public constructor(query: ContentSearchQuery) : this() { + criteria.addAll(query.criteria) + order = query.order + offset = query.offset + } + + public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) } + + public fun order(order: SortOrder?): Builder = apply { this.order = order } + + public fun offset(offset: Int): Builder = apply { this.offset = offset } + + public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip } + + @Throws(IllegalArgumentException::class) + public fun build(): ContentSearchQuery { + return ContentSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation) + } + + private fun deduplicateCriteria(criteria: Set>): Set> { + val uniqueCriteria = + ArrayMap>>, QueryCriteria<*>>(criteria.size) + + for (criterion in criteria) { + val key = criterion.field to criterion::class.java + val existing = uniqueCriteria[key] + + when { + existing == null -> uniqueCriteria[key] = criterion + + existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> { + uniqueCriteria[key] = + QueryCriteria.Include(criterion.field, existing.values union criterion.values) + } + + existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> { + uniqueCriteria[key] = + QueryCriteria.Exclude(criterion.field, existing.values union criterion.values) + } + + else -> throw IllegalArgumentException( + "Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}", + ) + } + } + + return uniqueCriteria.values.toSet() + } + } + + public companion object { + + public val EMPTY: ContentSearchQuery = ContentSearchQuery(emptySet(), null, 0, false) + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQueryCapabilities.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQueryCapabilities.kt new file mode 100644 index 0000000000..b1dc3240e7 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/ContentSearchQueryCapabilities.kt @@ -0,0 +1,52 @@ +package io.github.landwarderer.futon.mihon.parsers.model.search + +import androidx.collection.ArraySet +import io.github.landwarderer.futon.mihon.parsers.model.search.QueryCriteria.Exclude +import io.github.landwarderer.futon.mihon.parsers.model.search.QueryCriteria.Include +import io.github.landwarderer.futon.mihon.parsers.model.search.QueryCriteria.Match +import io.github.landwarderer.futon.mihon.parsers.model.search.QueryCriteria.Range +import io.github.landwarderer.futon.mihon.parsers.util.mapToSet + +@Deprecated("Too complex. Use ContentListFilterCapabilities instead") +@ExposedCopyVisibility +public data class ContentSearchQueryCapabilities internal constructor( + public val capabilities: Set, +) { + + public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities)) + + internal fun validate(query: ContentSearchQuery) { + val strictFields = capabilities.filter { it.isExclusive }.mapToSet { it.field } + val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields) + + require(usedStrictFields.isEmpty() || query.criteria.size <= 1) { + "Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria." + } + for (criterion in query.criteria) { + val capability = requireNotNull(capabilities.find { it.field == criterion.field }) { + "Unsupported search field: ${criterion.field}" + } + + require(criterion::class in capability.criteriaTypes) { + "Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}" + } + + // Ensure single value per criterion if supportMultiValue is false + if (!capability.isMultiple) { + when (criterion) { + is Include<*> -> require(criterion.values.size <= 1) { + "Multiple values are not allowed for field ${criterion.field}" + } + + is Exclude<*> -> require(criterion.values.size <= 1) { + "Multiple values are not allowed for field ${criterion.field}" + } + + is Range<*> -> Unit // Range is always valid (from, to) + is Match<*> -> Unit // Match always has a single value + } + } + } + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/QueryCriteria.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/QueryCriteria.kt new file mode 100644 index 0000000000..b1cda26ae9 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/QueryCriteria.kt @@ -0,0 +1,106 @@ +package io.github.landwarderer.futon.mihon.parsers.model.search + +/** + * Represents a generic search criterion used for filtering manga search results. + * Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T]. + * + * @param T The type of value associated with the search criterion. + * @property field The field to which this search criterion applies. + */ +@Deprecated("Too complex") +public sealed interface QueryCriteria { + + public val field: SearchableField + + override fun equals(other: Any?): Boolean + + override fun hashCode(): Int + + /** + * Represents an inclusion criterion that allows search results based on a set of allowed values. + * + * @param T The type of value being included in the search. + * @property values The set of values that should be included in the search results. + * + * ### Example Usage: + * ```kotlin + * val genreFilter = QueryCriteria.Include(SearchableField.STATE, setOf(ContentState.ONGOING, ContentState.FINISHED)) + * ``` + */ + public data class Include( + public override val field: SearchableField, + @JvmField public val values: Set, + ) : QueryCriteria { + + init { + check(values.all { x -> field.type.isInstance(x) }) + } + } + + /** + * Represents an exclusion criterion that exclude results containing certain values. + * + * @param T The type of value being excluded from the search. + * @property values The set of values that should be excluded from the search results. + * + * ### Example Usage: + * ```kotlin + * val excludeTag = QueryCriteria.Exclude(SearchableField.TAG, setOf(ContentTag(key, title, source))) + * ``` + */ + public data class Exclude( + public override val field: SearchableField, + @JvmField public val values: Set, + ) : QueryCriteria { + + init { + check(values.all { x -> field.type.isInstance(x) }) + } + } + + /** + * Represents a range criterion that allows search based on a range of values. + * + * @param T The type of value used in the range (must be comparable). + * @property from The starting value of the range (inclusive). + * @property to The ending value of the range (inclusive). + * + * ### Example Usage: + * ```kotlin + * val yearRange = QueryCriteria.Range(SearchableField.PUBLICATION_YEAR, 2000, 2020) + * ``` + */ + public data class Range>( + public override val field: SearchableField, + @JvmField public val from: T, + @JvmField public val to: T, + ) : QueryCriteria { + + init { + check(field.type.isInstance(from)) + check(field.type.isInstance(to)) + } + } + + + /** + * Represents a match criterion that search results based on an exact match of a value. + * + * @param T The type of value being matched. + * @property value The exact value that must be matched. + * + * ### Example Usage: + * ```kotlin + * val titleMatch = QueryCriteria.Match(SearchableField.TITLE, "manga title") + * ``` + */ + public data class Match( + public override val field: SearchableField, + @JvmField public val value: T, + ) : QueryCriteria { + + init { + check(field.type.isInstance(value)) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchCapability.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchCapability.kt new file mode 100644 index 0000000000..aadd69dff5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchCapability.kt @@ -0,0 +1,34 @@ +package io.github.landwarderer.futon.mihon.parsers.model.search + +import kotlin.reflect.KClass + +/** + * Defines the search capabilities of a given field in the manga search query. + * + * @property field The searchable field that this capability applies to. + * Example values: + * - `SearchableField.TITLE_NAME` for searching by title. + * - `SearchableField.AUTHOR` for searching by author names. + * - `SearchableField.TAG` for filtering by tags. + * @property criteriaTypes The set of supported criteria types for the field. + * Example values: + * - `setOf(Include::class, Exclude::class)` selected field supports inclusion/exclusion criteria. + * - `setOf(Range::class)` selected field support numerical range criteria. + * @property isMultiValue Indicates whether the field supports multiple values. + * - `true` if multiple values can be provided (e.g., multiple tags or authors). + * - `false` if only a single value is allowed (e.g., only one tag or author). + * @property isExclusive Specifies whether the field can be used alongside other criteria. + * - `true` if this field can be used with other search criteria. + * - `false` if using this field requires it to be the only criterion in query. + */ +@Deprecated("Too complex") +public data class SearchCapability( + /** The searchable field that this capability applies to. */ + @JvmField public val field: SearchableField, + /** The set of supported criteria types for this field. */ + @JvmField public val criteriaTypes: Set>>, + /** Indicates whether the field supports multiple values. */ + @JvmField public val isMultiple: Boolean, + /** Specifies whether the field can be used alongside other criteria. */ + @JvmField public val isExclusive: Boolean = false, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchableField.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchableField.kt new file mode 100644 index 0000000000..7bb0f36813 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/model/search/SearchableField.kt @@ -0,0 +1,29 @@ +package io.github.landwarderer.futon.mihon.parsers.model.search + +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentState +import io.github.landwarderer.futon.mihon.parsers.model.ContentTag +import io.github.landwarderer.futon.mihon.parsers.model.ContentType +import io.github.landwarderer.futon.mihon.parsers.model.Demographic +import java.util.Locale + +/** + * Represents the various fields that can be used for searching manga. + * Each field is associated with a specific data type that defines its expected values. + * + * @property type The Java class representing the expected type of values for this field. + */ +@Deprecated("Too complex") +public enum class SearchableField(public val type: Class<*>) { + TITLE_NAME(String::class.java), + TAG(ContentTag::class.java), + AUTHOR(ContentTag::class.java), + LANGUAGE(Locale::class.java), + ORIGINAL_LANGUAGE(Locale::class.java), + STATE(ContentState::class.java), + CONTENT_TYPE(ContentType::class.java), + CONTENT_RATING(ContentRating::class.java), + DEMOGRAPHIC(Demographic::class.java), + PUBLICATION_YEAR(Int::class.javaObjectType); +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/CloudFlareHelper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/CloudFlareHelper.kt new file mode 100644 index 0000000000..d8277cac73 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/CloudFlareHelper.kt @@ -0,0 +1,73 @@ +package io.github.landwarderer.futon.mihon.parsers.network + +import okhttp3.CookieJar +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Response +import org.jsoup.Jsoup +import java.net.HttpURLConnection.HTTP_FORBIDDEN +import java.net.HttpURLConnection.HTTP_UNAVAILABLE + +public object CloudFlareHelper { + + public const val PROTECTION_NOT_DETECTED: Int = 0 + public const val PROTECTION_CAPTCHA: Int = 1 + public const val PROTECTION_BLOCKED: Int = 2 + + private const val CF_CLEARANCE = "cf_clearance" + + public fun checkResponseForProtection(response: Response): Int { + if (response.code != HTTP_FORBIDDEN && response.code != HTTP_UNAVAILABLE) { + return PROTECTION_NOT_DETECTED + } + + // Check headers for CloudFlare indicators first + val cfRay = response.header("cf-ray") + val server = response.header("server") + val cfMitigated = response.header("cf-mitigated") + val isCloudFlareServer = cfRay != null || server?.contains("cloudflare", ignoreCase = true) == true + + // If no CloudFlare headers, it's likely not CloudFlare protection + if (!isCloudFlareServer) { + return PROTECTION_NOT_DETECTED + } + + // If cf-mitigated header is present with "challenge", it's definitely a CloudFlare challenge + if (cfMitigated?.contains("challenge", ignoreCase = true) == true) { + return PROTECTION_CAPTCHA + } + + val content = try { + response.peekBody(Long.MAX_VALUE).use { + Jsoup.parse(it.byteStream(), Charsets.UTF_8.name(), response.request.url.toString()) + } + } catch (_: IllegalStateException) { + return PROTECTION_NOT_DETECTED + } + return when { + content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null -> PROTECTION_BLOCKED + // CloudFlare "Just a moment" challenge page + content.title().contains("Just a moment", ignoreCase = true) -> PROTECTION_CAPTCHA + // More specific CloudFlare challenge detection + (content.getElementById("challenge-error-title") != null || + content.getElementById("challenge-error-text") != null) && + (content.selectFirst("script[src*=\"/cdn-cgi/\"]") != null || + content.html().contains("cf-browser-verification") || + content.html().contains("__cf_chl_opt")) -> PROTECTION_CAPTCHA + + else -> PROTECTION_NOT_DETECTED + } + } + + + + public fun getClearanceCookie(cookieJar: CookieJar, url: String): String? { + return cookieJar.loadForRequest(url.toHttpUrl()).find { it.name == CF_CLEARANCE }?.value + } + + public fun isCloudFlareCookie(name: String): Boolean { + return name.startsWith("cf_") + || name.startsWith("_cf") + || name.startsWith("__cf") + || name == "csrftoken" + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/GZipOptions.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/GZipOptions.kt new file mode 100644 index 0000000000..5c196b4bb4 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/GZipOptions.kt @@ -0,0 +1,5 @@ +package io.github.landwarderer.futon.mihon.parsers.network + +public data class GZipOptions( + public val skip: Boolean = false +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/NoCookiesCookieJar.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/NoCookiesCookieJar.kt new file mode 100644 index 0000000000..14e58909d2 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/NoCookiesCookieJar.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.mihon.parsers.network + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +/** + * A CookieJar implementation that never loads or saves cookies. + * Useful for performing requests that must not carry session state, + * such as credential login where existing cookies can interfere. + */ +public class NoCookiesCookieJar : CookieJar { + override fun loadForRequest(url: HttpUrl): List = emptyList() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + // no-op + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/OkHttpWebClient.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/OkHttpWebClient.kt new file mode 100644 index 0000000000..ec6cea66ee --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/OkHttpWebClient.kt @@ -0,0 +1,155 @@ +package io.github.landwarderer.futon.mihon.parsers.network + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.exception.AuthRequiredException +import io.github.landwarderer.futon.mihon.parsers.exception.GraphQLException +import io.github.landwarderer.futon.mihon.parsers.exception.NotFoundException +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource +import io.github.landwarderer.futon.mihon.parsers.util.await +import io.github.landwarderer.futon.mihon.parsers.util.parseJson +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONObject +import org.jsoup.HttpStatusException +import java.net.HttpURLConnection + +public class OkHttpWebClient( + private val httpClient: OkHttpClient, + private val mangaSource: ContentSource, +) : WebClient { + + override suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response { + val request = Request.Builder() + .get() + .url(url) + .addTags() + .addExtraHeaders(extraHeaders) + return httpClient.newCall(request.build()).await().ensureSuccess() + } + + override suspend fun httpHead(url: HttpUrl): Response { + val request = Request.Builder() + .head() + .url(url) + .addTags() + return httpClient.newCall(request.build()).await().ensureSuccess() + } + + override suspend fun httpPost(url: HttpUrl, form: Map, extraHeaders: Headers?): Response { + val body = FormBody.Builder() + form.forEach { (k, v) -> + body.addEncoded(k, v) + } + val request = Request.Builder() + .post(body.build()) + .url(url) + .addTags() + .addExtraHeaders(extraHeaders) + return httpClient.newCall(request.build()).await().ensureSuccess() + } + + override suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: Headers?): Response { + val body = FormBody.Builder() + payload.split('&').forEach { + val pos = it.indexOf('=') + if (pos != -1) { + val k = it.substring(0, pos) + val v = it.substring(pos + 1) + body.addEncoded(k, v) + } + } + val request = Request.Builder() + .post(body.build()) + .url(url) + .addTags() + .addExtraHeaders(extraHeaders) + return httpClient.newCall(request.build()).await().ensureSuccess() + } + + override suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = body.toString().toRequestBody(mediaType) + val request = Request.Builder() + .post(requestBody) + .url(url) + .addTags() + .addExtraHeaders(extraHeaders) + return httpClient.newCall(request.build()).await().ensureSuccess() + } + + @OptIn(InternalParsersApi::class) + override suspend fun graphQLQuery(endpoint: String, query: String): JSONObject { + val body = JSONObject() + body.put("operationName", null as Any?) + body.put("variables", JSONObject()) + body.put("query", "{$query}") + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = body.toString().toRequestBody(mediaType) + val request = Request.Builder() + .post(requestBody) + .url(endpoint) + .addTags() + val json = httpClient.newCall(request.build()).await().parseJson() + json.optJSONArray("errors")?.let { + if (it.length() != 0) { + throw GraphQLException(it) + } + } + return json + } + + private fun Request.Builder.addTags(): Request.Builder { + tag(ContentSource::class.java, mangaSource) + return this + } + + private fun Request.Builder.addExtraHeaders(headers: Headers?): Request.Builder { + if (headers != null) { + headers(headers) + } + return this + } + + @OptIn(InternalParsersApi::class) + private fun Response.ensureSuccess(): Response { + fun peekErrorBody(): String { + return runCatching { + val peek = peekBody(1024) + val bytes = peek.bytes() + val s = try { + String(bytes, Charsets.UTF_8) + } catch (_: Exception) { + bytes.joinToString(limit = 64, truncated = "…") { it.toUByte().toString() } + } + s + }.getOrDefault("") + } + + val exception: Exception? = when (code) { // Catch some error codes, not all + HttpURLConnection.HTTP_NOT_FOUND -> NotFoundException(message, request.url.toString()) + HttpURLConnection.HTTP_UNAUTHORIZED -> request.tag(ContentSource::class.java)?.let { + AuthRequiredException(it) + } ?: HttpStatusException(message, code, request.url.toString()) + + in 400..599 -> { + val bodyText = peekErrorBody() + val msg = if (bodyText.isNotBlank()) "${message}: ${bodyText}" else message + HttpStatusException(msg, code, request.url.toString()) + } + else -> null + } + if (exception != null) { + runCatching { close() }.onFailure { exception.addSuppressed(it) } + throw exception + } + return this + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/UserAgents.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/UserAgents.kt new file mode 100644 index 0000000000..71f83df701 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/UserAgents.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.mihon.parsers.network + +public object UserAgents { + + public const val CHROME_MOBILE: String = + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.203 Mobile Safari/537.36" + + public const val JM_WEBVIEW: String = + "Mozilla/5.0 (Linux; Android 10; K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/130.0.0.0 Mobile Safari/537.36" + + public const val FIREFOX_MOBILE: String = + "Mozilla/5.0 (Android 14; Mobile; LG-M255; rv:123.0) Gecko/123.0 Firefox/123.0" + + public const val CHROME_DESKTOP: String = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + + public const val FIREFOX_DESKTOP: String = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/116.0" +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/WebClient.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/WebClient.kt new file mode 100644 index 0000000000..206ced7fe3 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/network/WebClient.kt @@ -0,0 +1,117 @@ +package io.github.landwarderer.futon.mihon.parsers.network + +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Response +import org.json.JSONObject + +public interface WebClient { + + /** + * Do a GET http request to specific url + * @param url + */ + public suspend fun httpGet(url: String): Response = httpGet(url.toHttpUrl()) + + public suspend fun httpGet(url: String, extraHeaders: Headers?): Response = httpGet(url.toHttpUrl(), extraHeaders) + + /** + * Do a GET http request to specific url + * @param url + */ + public suspend fun httpGet(url: HttpUrl): Response = httpGet(url, null) + + /** + * Do a GET http request to specific url + * @param url + * @param extraHeaders additional HTTP headers for request + */ + public suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response + + /** + * Do a HEAD http request to specific url + * @param url + */ + public suspend fun httpHead(url: String): Response = httpHead(url.toHttpUrl()) + + /** + * Do a HEAD http request to specific url + * @param url + */ + public suspend fun httpHead(url: HttpUrl): Response + + /** + * Do a POST http request to specific url with `multipart/form-data` payload + * @param url + * @param form payload as key=>value map + */ + public suspend fun httpPost(url: String, form: Map): Response = + httpPost(url.toHttpUrl(), form, null) + + /** + * Do a POST http request to specific url with `multipart/form-data` payload + * @param url + * @param form payload as key=>value map + */ + public suspend fun httpPost(url: HttpUrl, form: Map): Response = httpPost(url, form, null) + + /** + * Do a POST http request to specific url with `multipart/form-data` payload + * @param url + * @param form payload as key=>value map + * @param extraHeaders additional HTTP headers for request + */ + public suspend fun httpPost(url: HttpUrl, form: Map, extraHeaders: Headers?): Response + + /** + * Do a POST http request to specific url with `multipart/form-data` payload + * @param url + * @param payload payload as `key=value` string with `&` separator + */ + public suspend fun httpPost(url: String, payload: String): Response = httpPost(url.toHttpUrl(), payload, null) + + /** + * Do a POST http request to specific url with `multipart/form-data` payload + * @param url + * @param payload payload as `key=value` string with `&` separator + */ + public suspend fun httpPost(url: HttpUrl, payload: String): Response = httpPost(url, payload, null) + + /** + * Do a POST http request to specific url with `multipart/form-data` payload + * @param url + * @param payload payload as `key=value` string with `&` separator + * @param extraHeaders additional HTTP headers for request + */ + public suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: Headers?): Response + + /** + * Do a POST http request to specific url with json payload + * @param url + * @param body + */ + public suspend fun httpPost(url: String, body: JSONObject): Response = httpPost(url.toHttpUrl(), body, null) + + /** + * Do a POST http request to specific url with json payload + * @param url + * @param body + */ + public suspend fun httpPost(url: HttpUrl, body: JSONObject): Response = httpPost(url, body, null) + + /** + * Do a POST http request to specific url with json payload + * @param url + * @param body + * @param extraHeaders additional HTTP headers for request + */ + public suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response + + /** + * Do a GraphQL request to specific url + * @param endpoint an url + * @param query GraphQL request payload + */ + public suspend fun graphQLQuery(endpoint: String, query: String): JSONObject +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Assert.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Assert.kt new file mode 100644 index 0000000000..b580e40178 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Assert.kt @@ -0,0 +1,8 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +internal fun T?.assertNotNull(name: String): T? { + assert(this != null) { + "Value $name is null" + } + return this +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CSSBackground.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CSSBackground.kt new file mode 100644 index 0000000000..8702f07b39 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CSSBackground.kt @@ -0,0 +1,56 @@ +package io.github.landwarderer.futon.core.parser + +import androidx.annotation.Keep +import io.github.landwarderer.futon.mihon.parsers.util.attrOrNull +import io.github.landwarderer.futon.mihon.parsers.util.nullIfEmpty +import io.github.landwarderer.futon.mihon.parsers.util.splitByWhitespace +import org.jsoup.nodes.Element + +/** + * Utility class for parsing the `background` property of css + */ +@Keep +public class CSSBackground private constructor( + public val url: String, + public val left: Int, + public val top: Int, + public val width: Int, + public val height: Int, +) { + + public val right: Int + get() = left + width + + public val bottom: Int + get() = top + height + + @Keep + public companion object { + + fun parse(element: Element): CSSBackground? { + val style = element.attrOrNull("style") ?: return null + val attrs = style.split(';').associate { + val trimmed = it.trim() + trimmed.substringBefore(':') to trimmed.substringAfter(':', "") + } + val width = attrs["width"]?.toPx() ?: return null + val height = attrs["height"]?.toPx() ?: return null + val bg = attrs["background"]?.substringAfter("url")?.splitByWhitespace() ?: return null + val url = bg.firstOrNull()?.removeSurrounding("(", ")")?.nullIfEmpty() ?: return null + val x = bg.getOrNull(1)?.toPx() ?: 0 + val y = bg.getOrNull(2)?.toPx() ?: 0 + return CSSBackground( + url = url, + left = -x, + top = y, + width = width, + height = height, + ) + } + + private fun String.toPx(): Int? { + return removeSuffix("px").toIntOrNull() + } + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Chapters.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Chapters.kt new file mode 100644 index 0000000000..6f21ffe45d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Chapters.kt @@ -0,0 +1,76 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import io.github.landwarderer.futon.mihon.parsers.util.json.asTypedList +import org.json.JSONArray +import org.json.JSONObject + +@InternalParsersApi +public inline fun List.mapChapters( + reversed: Boolean = false, + transform: (index: Int, T) -> ContentChapter?, +): List { + val builder = ChaptersListBuilder(collectionSize()) + var index = 0 + val elements = if (reversed) this.asReversed() else this + for (item in elements) { + if (builder.add(transform(index, item))) { + index++ + } + } + return builder.toList() +} + +@InternalParsersApi +public inline fun JSONArray.mapChapters( + reversed: Boolean = false, + transform: (index: Int, JSONObject) -> ContentChapter?, +): List = asTypedList().mapChapters(reversed, transform) + +@InternalParsersApi +public inline fun List.flatMapChapters( + reversed: Boolean = false, + transform: (T) -> Iterable, +): List { + val builder = ChaptersListBuilder(collectionSize()) + val elements = if (reversed) this.asReversed() else this + for (item in elements) { + builder.addAll(transform(item)) + } + return builder.toList() +} + +@PublishedApi +internal fun Iterable.collectionSize(): Int { + return if (this is Collection<*>) this.size else 10 +} + +@PublishedApi +internal class ChaptersListBuilder(initialSize: Int) { + + private val ids = HashSet(initialSize) + private val list = ArrayList(initialSize) + + fun add(chapter: ContentChapter?): Boolean { + return chapter != null && ids.add(chapter.id) && list.add(chapter) + } + + fun addAll(chapters: Iterable) { + if (chapters is Collection<*>) { + list.ensureCapacity(list.size + chapters.size) + } + chapters.forEach { add(it) } + } + + operator fun plusAssign(chapter: ContentChapter?) { + add(chapter) + } + + fun reverse() { + list.reverse() + } + + fun toList(): List = list +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Collection.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Collection.kt new file mode 100644 index 0000000000..78cb45ee3c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Collection.kt @@ -0,0 +1,101 @@ +@file:JvmName("CollectionUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import androidx.collection.ArrayMap +import androidx.collection.ArraySet +import java.util.Collections + +public fun Collection<*>?.sizeOrZero(): Int = this?.size ?: 0 + +public fun MutableCollection.replaceWith(subject: Iterable) { + clear() + addAll(subject) +} + +public fun > Iterable>.flattenTo(destination: C): C { + for (element in this) { + destination.addAll(element) + } + return destination +} + +public fun List.medianOrNull(): T? = when { + isEmpty() -> null + else -> get((size / 2).coerceIn(indices)) +} + +public inline fun Collection.mapToSet(transform: (T) -> R): Set { + return mapTo(ArraySet(size), transform) +} + +public inline fun Collection.mapNotNullToSet(transform: (T) -> R?): Set { + val destination = ArraySet(size) + for (item in this) { + destination.add(transform(item) ?: continue) + } + return destination +} + +public inline fun Array.mapToArray(transform: (T) -> R): Array = Array(size) { i -> + transform(get(i)) +} + +public fun List>.toMutableMap(): MutableMap = toMap(ArrayMap(size)) + +public fun MutableList.move(sourceIndex: Int, targetIndex: Int) { + if (sourceIndex <= targetIndex) { + Collections.rotate(subList(sourceIndex, targetIndex + 1), -1) + } else { + Collections.rotate(subList(targetIndex, sourceIndex + 1), 1) + } +} + +public fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null + +public inline fun Collection.associateGrouping(transform: (T) -> Pair): Map> { + val result = LinkedHashMap>(size) + for (item in this) { + val (k, v) = transform(item) + result.getOrPut(k) { LinkedHashSet() }.add(v) + } + return result +} + +public fun MutableMap.incrementAndGet(key: K): Int { + var value = get(key) ?: 0 + value++ + put(key, value) + return value +} + +public inline fun MutableSet(size: Int, init: (index: Int) -> T): MutableSet { + val set = ArraySet(size) + repeat(size) { index -> set.add(init(index)) } + return set +} + +public inline fun Set(size: Int, init: (index: Int) -> T): Set = when (size) { + 0 -> emptySet() + 1 -> Collections.singleton(init(0)) + else -> MutableSet(size, init) +} + +@Suppress("UNCHECKED_CAST") +public inline fun Collection.mapToArray(transform: (T) -> R): Array { + val result = arrayOfNulls(size) + forEachIndexed { index, t -> result[index] = transform(t) } + return result as Array +} + +public fun Array.toArraySet(): Set = when (size) { + 0 -> emptySet() + 1 -> setOf(first()) + else -> ArraySet(this) +} + +public fun Collection.toArraySet(): Set = when (size) { + 0 -> emptySet() + 1 -> setOf(first()) + else -> ArraySet(this) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParserEnv.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParserEnv.kt new file mode 100644 index 0000000000..11084c61f7 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParserEnv.kt @@ -0,0 +1,104 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import io.github.landwarderer.futon.mihon.parsers.ContentParser +import io.github.landwarderer.futon.mihon.parsers.ErrorMessages +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.core.AbstractContentParser +import io.github.landwarderer.futon.mihon.parsers.exception.ParseException +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import io.github.landwarderer.futon.mihon.parsers.model.ContentPage +import io.github.landwarderer.futon.mihon.parsers.model.ContentRating +import io.github.landwarderer.futon.mihon.parsers.model.ContentState +import io.github.landwarderer.futon.mihon.parsers.model.ContentTag +import io.github.landwarderer.futon.mihon.parsers.model.ContentType +import io.github.landwarderer.futon.mihon.parsers.model.Demographic +import okhttp3.HttpUrl +import org.jsoup.nodes.Element + + +/** + * Create a unique id for [Content]/[ContentChapter]/[ContentPage]. + * @param url must be relative url, without a domain + * @see [Content.id] + * @see [ContentChapter.id] + * @see [ContentPage.id] + */ +@InternalParsersApi +public fun ContentParser.generateUid(url: String): Long { + var h = LONG_HASH_SEED + source.name.forEach { c -> + h = 31 * h + c.code + } + url.forEach { c -> + h = 31 * h + c.code + } + return h +} + +/** + * Create a unique id for [Content]/[ContentChapter]/[ContentPage]. + * @param id an internal identifier + * @see [Content.id] + * @see [ContentChapter.id] + * @see [ContentPage.id] + */ +@InternalParsersApi +public fun ContentParser.generateUid(id: Long): Long { + var h = LONG_HASH_SEED + source.name.forEach { c -> + h = 31 * h + c.code + } + h = 31 * h + id + return h +} + +@InternalParsersApi +public fun Element.parseFailed(message: String? = null): Nothing { + throw ParseException(message, ownerDocument()?.location() ?: baseUri(), null) +} + +@InternalParsersApi +public fun Set?.oneOrThrowIfMany(): ContentTag? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED, +) + +@InternalParsersApi +public fun Set?.oneOrThrowIfMany(): ContentState? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED, +) + +@InternalParsersApi +public fun Set?.oneOrThrowIfMany(): ContentType? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_CONTENT_TYPES_NOT_SUPPORTED, +) + +@InternalParsersApi +public fun Set?.oneOrThrowIfMany(): Demographic? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_DEMOGRAPHICS_NOT_SUPPORTED, +) + +@InternalParsersApi +public fun Set?.oneOrThrowIfMany(): ContentRating? = oneOrThrowIfMany( + ErrorMessages.FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED, +) + +private fun Set?.oneOrThrowIfMany(msg: String): T? = when { + isNullOrEmpty() -> null + size == 1 -> first() + else -> throw IllegalArgumentException(msg) +} + +@InternalParsersApi +public fun AbstractContentParser.getDomain(subdomain: String): String { + val domain = domain + return subdomain + "." + domain.removePrefix("www.") +} + +@InternalParsersApi +public fun ContentParser.urlBuilder(subdomain: String? = null): HttpUrl.Builder { + return HttpUrl.Builder() + .scheme(SCHEME_HTTPS) + .host(if (subdomain == null) domain else "$subdomain.$domain") +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParsersUtils.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParsersUtils.kt new file mode 100644 index 0000000000..8f6795ce35 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContentParsersUtils.kt @@ -0,0 +1,18 @@ +@file:JvmName("ContentParsersUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import io.github.landwarderer.futon.mihon.parsers.model.ContentChapter +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter +import kotlin.contracts.contract + +fun ContentListFilter?.isNullOrEmpty(): Boolean { + contract { + returns(false) implies (this@isNullOrEmpty != null) + } + return this == null || this.isEmpty() +} + +fun Collection.findById(chapterId: Long): ContentChapter? = find { x -> + x.id == chapterId +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContinuationCallCallback.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContinuationCallCallback.kt new file mode 100644 index 0000000000..e5ab0745cf --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/ContinuationCallCallback.kt @@ -0,0 +1,34 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CompletionHandler +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import okhttp3.internal.closeQuietly +import java.io.IOException +import kotlin.coroutines.resumeWithException + +internal class ContinuationCallCallback( + private val call: Call, + private val continuation: CancellableContinuation, +) : Callback, CompletionHandler { + + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { _, value, _ -> + value.closeQuietly() + } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun invoke(cause: Throwable?) { + runCatching { + call.cancel() + }.onFailure { e -> + cause?.addSuppressed(e) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CookieJar.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CookieJar.kt new file mode 100644 index 0000000000..c56f5094cb --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CookieJar.kt @@ -0,0 +1,48 @@ +@file:JvmName("CookieJarUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +public fun CookieJar.insertCookies(domain: String, vararg cookies: String) { + val url = safeUrlOf(domain) ?: return + saveFromResponse( + url, + cookies.mapNotNull { + Cookie.parse(url, it) + }, + ) +} + +public fun CookieJar.insertCookie(domain: String, cookie: Cookie) { + val url = safeUrlOf(domain) ?: return + saveFromResponse(url, listOf(cookie)) +} + +public fun CookieJar.getCookies(domain: String): List { + val url = safeUrlOf(domain) ?: return emptyList() + return loadForRequest(url) +} + +public fun CookieJar.copyCookies(oldDomain: String, newDomain: String, names: Array? = null) { + val url = HttpUrl.Builder() + .scheme(SCHEME_HTTPS) + .host(oldDomain) + var cookies = loadForRequest(url.build()) + if (names != null) { + cookies = cookies.filter { c -> c.name in names } + } + url.host(newDomain) + saveFromResponse(url.build(), cookies) +} + +private fun safeUrlOf(domain: String): HttpUrl? = try { + HttpUrl.Builder() + .scheme(SCHEME_HTTPS) + .host(domain) + .build() +} catch (_: IllegalArgumentException) { + null +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Coroutines.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Coroutines.kt new file mode 100644 index 0000000000..401b53956b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Coroutines.kt @@ -0,0 +1,35 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +public fun Iterable.cancelAll(cause: CancellationException? = null) { + forEach { it.cancel(cause) } +} + +public suspend fun Iterable>.awaitFirst(): T { + return channelFlow { + for (deferred in this@awaitFirst) { + launch { + send(deferred.await()) + } + } + }.first().also { this@awaitFirst.cancelAll() } +} + +public suspend fun Collection>.awaitFirst(condition: (T) -> Boolean): T { + return channelFlow { + for (deferred in this@awaitFirst) { + launch { + val result = deferred.await() + if (condition(result)) { + send(result) + } + } + } + }.first().also { this@awaitFirst.cancelAll() } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CryptoAES.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CryptoAES.kt new file mode 100644 index 0000000000..6112198f43 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/CryptoAES.kt @@ -0,0 +1,128 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import io.github.landwarderer.futon.mihon.parsers.ContentLoaderContext +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +private const val HASH_CIPHER = "AES/CBC/PKCS7PADDING" +private const val AES = "AES" +private const val KDF_DIGEST = "MD5" + +/** + * Conforming with CryptoJS AES method + */ +@InternalParsersApi +public class CryptoAES( + private val context: ContentLoaderContext, +) { + + /** + * Decrypt using CryptoJS defaults compatible method. + * Uses KDF equivalent to OpenSSL's EVP_BytesToKey function + * + * http://stackoverflow.com/a/29152379/4405051 + * @param cipherText base64 encoded ciphertext + * @param password passphrase + */ + @Throws(Exception::class) + public fun decrypt(cipherText: String, password: String): String { + val ctBytes = context.decodeBase64(cipherText) + val saltBytes = ctBytes.copyOfRange(8, 16) + val cipherTextBytes = ctBytes.copyOfRange(16, ctBytes.size) + val md5: MessageDigest = MessageDigest.getInstance(KDF_DIGEST) + val keyAndIV = generateKeyAndIV(32, 16, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5) + return decryptAES( + cipherTextBytes, + keyAndIV.getOrNull(0) ?: ByteArray(32), + keyAndIV.getOrNull(1) ?: ByteArray(16), + ) + } + + /** + * Decrypt using CryptoJS defaults compatible method. + * + * @param cipherText base64 encoded ciphertext + * @param keyBytes key as a bytearray + * @param ivBytes iv as a bytearray + */ + @Throws(Exception::class) + public fun decrypt(cipherText: String, keyBytes: ByteArray, ivBytes: ByteArray): String { + val cipherTextBytes = context.decodeBase64(cipherText) + return decryptAES(cipherTextBytes, keyBytes, ivBytes) + } + + /** + * Decrypt using CryptoJS defaults compatible method. + * + * @param cipherTextBytes encrypted text as a bytearray + * @param keyBytes key as a bytearray + * @param ivBytes iv as a bytearray + */ + @Throws(Exception::class) + private fun decryptAES(cipherTextBytes: ByteArray, keyBytes: ByteArray, ivBytes: ByteArray): String { + val cipher = Cipher.getInstance(HASH_CIPHER) + val keyS = SecretKeySpec(keyBytes, AES) + cipher.init(Cipher.DECRYPT_MODE, keyS, IvParameterSpec(ivBytes)) + return cipher.doFinal(cipherTextBytes).toString(Charsets.UTF_8) + } + + /** + * Generates a key and an initialization vector (IV) with the given salt and password. + * + * https://stackoverflow.com/a/41434590 + * This method is equivalent to OpenSSL's EVP_BytesToKey function + * (see https://github.com/openssl/openssl/blob/master/crypto/evp/evp_key.c). + * By default, OpenSSL uses a single iteration, MD5 as the algorithm and UTF-8 encoded password data. + * + * @param keyLength the length of the generated key (in bytes) + * @param ivLength the length of the generated IV (in bytes) + * @param iterations the number of digestion rounds + * @param salt the salt data (8 bytes of data or `null`) + * @param password the password data (optional) + * @param md the message digest algorithm to use + * @return an two-element array with the generated key and IV + */ + @Suppress("SameParameterValue") + @Throws(Exception::class) + private fun generateKeyAndIV( + keyLength: Int, + ivLength: Int, + iterations: Int, + salt: ByteArray?, + password: ByteArray, + md: MessageDigest, + ): Array { + val digestLength = md.digestLength + val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength + val generatedData = ByteArray(requiredLength) + var generatedLength = 0 + md.reset() + + // Repeat process until sufficient data has been generated + while (generatedLength < keyLength + ivLength) { + + // Digest data (last digest if available, password data, salt if available) + if (generatedLength > 0) md.update(generatedData, generatedLength - digestLength, digestLength) + md.update(password) + if (salt != null) md.update(salt, 0, 8) + md.digest(generatedData, generatedLength, digestLength) + + // additional rounds + for (i in 1 until iterations) { + md.update(generatedData, generatedLength, digestLength) + md.digest(generatedData, generatedLength, digestLength) + } + generatedLength += digestLength + } + + // Copy key and IV into separate byte arrays + val result = arrayOfNulls(2) + result[0] = generatedData.copyOfRange(0, keyLength) + if (ivLength > 0) result[1] = generatedData.copyOfRange(keyLength, keyLength + ivLength) + return result + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Enum.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Enum.kt new file mode 100644 index 0000000000..cbc2a400a0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Enum.kt @@ -0,0 +1,13 @@ +@file:JvmName("EnumUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import kotlin.enums.EnumEntries + +public fun > EnumEntries.names(): Array = Array(size) { i -> + get(i).name +} + +public fun > EnumEntries.find(name: String): E? { + return find { x -> x.name == name } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/FaviconParser.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/FaviconParser.kt new file mode 100644 index 0000000000..fa5d678a9a --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/FaviconParser.kt @@ -0,0 +1,93 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import io.github.landwarderer.futon.mihon.parsers.model.Favicon +import io.github.landwarderer.futon.mihon.parsers.model.Favicons +import io.github.landwarderer.futon.mihon.parsers.network.WebClient +import io.github.landwarderer.futon.mihon.parsers.util.json.mapJSON +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.nodes.Element + +public class FaviconParser( + private val webClient: WebClient, + private val domain: String, +) { + + public suspend fun parseFavicons(): Favicons = withContext(Dispatchers.Default) { + val url = "https://$domain" + val doc = webClient.httpGet(url).parseHtml() + val result = HashSet() + val manifestLink = doc.getElementsByAttributeValue("rel", "manifest").firstOrNull() + ?.attrAsAbsoluteUrlOrNull("href") + if (manifestLink != null) { + runCatchingCancellable { + parseManifest(manifestLink) + }.onSuccess { manifest -> + result += manifest + } + } + val links = doc.getElementsByAttributeValueContaining("rel", "icon") + links.mapNotNullTo(result) { link -> + parseLink(link) + } + val touchIcons = doc.getElementsByAttributeValue("rel", "apple-touch-icon") + touchIcons.mapNotNullTo(result) { link -> + parseLink(link) + } + if (result.isEmpty()) { + result.add(createFallback()) + } + Favicons(result, url) + } + + private fun parseLink(link: Element): Favicon? { + val href = link.attrAsAbsoluteUrlOrNull("href") + if (href == null || href.endsWith('/')) { + return null + } + val sizes = link.attr("sizes") + return Favicon( + url = href, + size = parseSize(sizes), + rel = link.attrOrNull("rel"), + ) + } + + private fun parseSize(sizes: String): Int { + if (sizes.isEmpty() || sizes == "any") { + return 0 + } + return sizes.substringBefore(' ') + .split('x', 'X', '*') + .firstNotNullOfOrNull { it.toIntOrNull() } + ?: 0 + } + + private suspend fun parseManifest(url: String): List { + val json = webClient.httpGet(url).parseJson() + val icons = json.optJSONArray("icons") ?: return emptyList() + return icons.mapJSON { jo -> + Favicon( + url = jo.getString("src").resolveLink(), + size = parseSize(jo.getString("sizes")), + rel = null, + ) + } + } + + private fun createFallback(): Favicon { + val href = "https://$domain/favicon.ico" + return Favicon( + url = href, + size = 0, + rel = null, + ) + } + + private fun String.resolveLink(): String = when { + startsWith("http:") || startsWith("https:") -> this + startsWith('/') -> "https://$domain$this" + else -> "https://$domain/$this" + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Jsoup.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Jsoup.kt new file mode 100644 index 0000000000..ae420969b5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Jsoup.kt @@ -0,0 +1,228 @@ +@file:JvmName("JsoupUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import io.github.landwarderer.futon.core.parser.CSSBackground +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import io.github.landwarderer.futon.mihon.parsers.exception.ParseException +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import org.jsoup.select.QueryParser +import org.jsoup.select.Selector +import kotlin.contracts.contract + +public val Element.host: String? + get() { + val uri = baseUri() + return if (uri.isEmpty()) { + null + } else { + uri.toHttpUrlOrNull()?.host + } + } + +/** + * Return an attribute value or null if it is missing or empty + * @see [Element.attr] which returns empty string instead of null + */ +public fun Element.attrOrNull(attributeKey: String): String? = attr(attributeKey).takeUnless { it.isBlank() }?.trim() + +/** + * Return an attribute value or throw an exception if it is missing + * @see [Element.attr] which returns empty string instead + */ +@OptIn(InternalParsersApi::class) +public fun Element.attrOrThrow(attributeKey: String): String = if (hasAttr(attributeKey)) { + attr(attributeKey) +} else { + throw ParseException("Attribute \"$attributeKey\" is missing at element \"$this\"", baseUri()) +} + +/** + * Return an attribute value as relative url or null if it is missing or empty + * @see attrAsRelativeUrl + * @see attrAsAbsoluteUrlOrNull + * @see attrAsAbsoluteUrl + */ +public fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? { + val attr = attrOrNull(attributeKey) ?: return null + if (attr.isEmpty() || attr.startsWith("data:")) { + return null + } + if (attr.startsWith('/')) { + return attr + } + val host = baseUri().toHttpUrlOrNull()?.host ?: return null + return attr.substringAfter(host) +} + +/** + * Return an attribute value as relative url or throw an exception if it is missing or empty + * @throws IllegalArgumentException if attribute value is missing or empty + * @see attrAsRelativeUrlOrNull + * @see attrAsAbsoluteUrlOrNull + * @see attrAsAbsoluteUrl + */ +public fun Element.attrAsRelativeUrl(attributeKey: String): String { + return requireNotNull(attrAsRelativeUrlOrNull(attributeKey)) { + "Cannot get relative url for $attributeKey: \"${attr(attributeKey)}\"" + } +} + +/** + * Return an attribute value as absolute url or null if it is missing or empty + * @see attrAsAbsoluteUrl + * @see attrAsRelativeUrl + * @see attrAsRelativeUrlOrNull + */ +public fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? { + val attr = attrOrNull(attributeKey) ?: return null + if (attr.isEmpty() || attr.startsWith("data:")) { + return null + } + return (baseUri().toHttpUrlOrNull()?.resolve(attr) ?: return null).toString() +} + +/** + * Return an attribute value as absolute url or throw an exception if it is missing or empty + * @throws IllegalArgumentException if attribute value is missing or empty + * @see attrAsAbsoluteUrlOrNull + * @see attrAsRelativeUrl + * @see attrAsRelativeUrlOrNull + */ +public fun Element.attrAsAbsoluteUrl(attributeKey: String): String { + return parseNotNull(attrAsAbsoluteUrlOrNull(attributeKey)) { + "Cannot get absolute url for $attributeKey: \"${attr(attributeKey)}\"" + } +} + +/** + * Return css value from `style` attribute or null if it is missing + */ +public fun Element.styleValueOrNull(property: String): String? { + val regex = Regex("${Regex.escape(property)}\\s*:\\s*[^;]+") + val css = attr("style").find(regex) ?: return null + return css.substringAfter(':').removeSuffix(';').trim() +} + +/** + * Like a `expectFirst` but with detailed error message + */ +public fun Element.selectFirstOrThrow(cssQuery: String): Element = parseNotNull(Selector.selectFirst(cssQuery, this)) { + "Cannot find \"$cssQuery\"" +} + +@OptIn(InternalParsersApi::class) +public fun Element.selectOrThrow(cssQuery: String): Elements { + return Selector.select(cssQuery, this).ifEmpty { + throw ParseException("Empty result for \"$cssQuery\"", baseUri()) + } +} + +public fun Element.requireElementById(id: String): Element = parseNotNull(getElementById(id)) { + "Cannot find \"#$id\"" +} + +public fun Element.selectLast(cssQuery: String): Element? = select(cssQuery).lastOrNull() + +public fun Element.selectLastOrThrow(cssQuery: String): Element = parseNotNull(selectLast(cssQuery)) { + "Cannot find \"$cssQuery\"" +} + +public fun Element.textOrNull(): String? = text().nullIfEmpty() + +public fun Elements.textOrNull(): String? = text().nullIfEmpty() + +public fun Element.ownTextOrNull(): String? = ownText().nullIfEmpty() + +public fun Element.selectFirstParent(query: String): Element? { + val selector = QueryParser.parse(query) + val parents = parents() + val root = parents.lastOrNull() ?: return null + return parents.firstOrNull { + selector.matches(root, it) + } +} + +public fun Element.selectFirstParentOrThrow(query: String): Element = parseNotNull(selectFirstParent(query)) { + "Cannot find parent \"$query\"" +} + +/** + * Return a first non-empty attribute value of [names] or null if it is missing or empty + */ +public fun Element.attrOrNull(vararg names: String): String? { + for (name in names) { + val value = attr(name) + if (value.isNotEmpty()) { + return value.trim() + } + } + return null +} + +@InternalParsersApi +@JvmOverloads +public fun Element.src( + names: Array = arrayOf( + "data-src", + "data-cfsrc", + "data-original", + "data-cdn", + "data-sizes", + "data-lazy-src", + "data-srcset", + "original-src", + "data-wpfc-original-src", + "src", + ), +): String? { + for (name in names) { + val value = attrAsAbsoluteUrlOrNull(name) + if (value != null) { + return value + } + } + return null +} + +@InternalParsersApi +public fun Element.requireSrc(): String = parseNotNull(src()) { + "Image src not found" +} + +public fun Element.backgroundOrNull(): CSSBackground? = CSSBackground.parse(this) + +public fun Element.metaValue(itemprop: String): String? = getElementsByAttributeValue("itemprop", itemprop) + .firstNotNullOfOrNull { element -> + element.attrOrNull("content") + } + +public fun String.cssUrl(): String? { + val fromIndex = indexOf("url(") + if (fromIndex == -1) { + return null + } + val toIndex = indexOf(')', startIndex = fromIndex) + return if (toIndex == -1) { + null + } else { + substring(fromIndex + 4, toIndex).trim() + } +} + +@OptIn(InternalParsersApi::class) +internal inline fun Element.parseNotNull(value: T?, lazyMessage: () -> String): T { + contract { + returns() implies (value != null) + } + + if (value == null) { + val message = lazyMessage() + throw ParseException(message, baseUri()) + } else { + return value + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/LinkResolver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/LinkResolver.kt new file mode 100644 index 0000000000..6902c41e42 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/LinkResolver.kt @@ -0,0 +1,12 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import okhttp3.HttpUrl +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentSource + +public interface LinkResolver { + public val link: HttpUrl + public suspend fun getSource(): ContentSource? + public suspend fun getContent(): Content? +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Number.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Number.kt new file mode 100644 index 0000000000..aa35da5cae --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Number.kt @@ -0,0 +1,85 @@ +@file:JvmName("NumberUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import java.text.DecimalFormat +import java.text.NumberFormat +import java.util.* +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.math.absoluteValue + +public fun Number.format(decimals: Int = 0, decPoint: Char = '.', thousandsSep: Char? = ' '): String { + val formatter = NumberFormat.getInstance(Locale.US) as DecimalFormat + val symbols = formatter.decimalFormatSymbols + if (thousandsSep != null) { + symbols.groupingSeparator = thousandsSep + formatter.isGroupingUsed = true + } else { + formatter.isGroupingUsed = false + } + symbols.decimalSeparator = decPoint + formatter.decimalFormatSymbols = symbols + formatter.minimumFractionDigits = decimals + formatter.maximumFractionDigits = decimals + return when (this) { + is Float, + is Double, + -> formatter.format(this.toDouble()) + + else -> formatter.format(this.toLong()) + } +} + +public fun Float.toIntUp(): Int { + val intValue = toInt() + return if ((this - intValue.toFloat()).absoluteValue <= 0.00001) { + intValue + } else { + intValue + 1 + } +} + +public infix fun Int.upBy(step: Int): Int { + val mod = this % step + return if (mod == 0) { + this + } else { + this - mod + step + } +} + +public fun Number.formatSimple(): String { + val raw = toString() + return if (raw.endsWith(".0") || raw.endsWith(",0")) { + raw.dropLast(2) + } else { + raw + } +} + +public inline fun Int.ifZero(defaultValue: () -> Int): Int { + contract { + callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) + } + return if (this == 0) { + defaultValue() + } else { + this + } +} + +public inline fun Long.ifZero(defaultValue: () -> Long): Long { + contract { + callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) + } + return if (this == 0L) { + defaultValue() + } else { + this + } +} + +public fun longOf(a: Int, b: Int): Long { + return a.toLong() shl 32 or (b.toLong() and 0xffffffffL) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/OkHttp.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/OkHttp.kt new file mode 100644 index 0000000000..51c0326ab5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/OkHttp.kt @@ -0,0 +1,55 @@ +@file:JvmName("OkHttpUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.* +import okhttp3.internal.toLongOrDefault +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +public suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + val callback = ContinuationCallCallback(this, continuation) + continuation.invokeOnCancellation(callback) + enqueue(callback) +} + +public val Response.mimeType: String? + get() = header("content-type")?.substringBefore(';')?.trim()?.nullIfEmpty()?.lowercase() + +public val HttpUrl.isHttpOrHttps: Boolean + get() = scheme.equals("https", ignoreCase = true) || scheme.equals("http", ignoreCase = true) + +public fun Headers.Builder.mergeWith(other: Headers, replaceExisting: Boolean): Headers.Builder { + for ((name, value) in other) { + if (replaceExisting || this[name] == null) { + this[name] = value + } + } + return this +} + +public fun Response.copy(): Response = newBuilder() + .body(peekBody(Long.MAX_VALUE)) + .build() + +public fun Response.Builder.setHeader(name: String, value: String?): Response.Builder = if (value == null) { + removeHeader(name) +} else { + header(name, value) +} + +public inline fun Response.map(mapper: (ResponseBody) -> ResponseBody): Response { + contract { + callsInPlace(mapper, InvocationKind.AT_MOST_ONCE) + } + return body.use { responseBody -> + newBuilder() + .body(mapper(responseBody)) + .build() + } +} + +public fun Response.headersContentLength( + defaultValue: Long = -1, +): Long = headers["Content-Length"]?.toLongOrDefault(defaultValue) ?: defaultValue diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Paginator.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Paginator.kt new file mode 100644 index 0000000000..b3d5c7c8c5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Paginator.kt @@ -0,0 +1,22 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +public class Paginator internal constructor(private val initialPageSize: Int) { + + public var firstPage: Int = 1 + private var pages = HashMap() + + internal fun getPage(offset: Int): Int { + if (offset == 0) { // just an optimization + return firstPage + } + pages[offset]?.let { return it } + val pageSize = initialPageSize + val intPage = offset / pageSize + val tail = offset % pageSize + return intPage + firstPage + if (tail == 0) 0 else 1 + } + + internal fun onListReceived(offset: Int, page: Int, count: Int) { + pages[offset + count] = if (count > 0) page + 1 else page + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Parse.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Parse.kt new file mode 100644 index 0000000000..8874116ac6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Parse.kt @@ -0,0 +1,114 @@ +@file:JvmName("ParseUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import okhttp3.Response +import okhttp3.ResponseBody +import org.json.JSONArray +import org.json.JSONObject +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import io.github.landwarderer.futon.mihon.parsers.InternalParsersApi +import java.text.DateFormat + +private val REGEX_SCHEME_PREFIX = Regex("^\\w{2,6}://", RegexOption.IGNORE_CASE) +internal const val SCHEME_HTTPS = "https" + +/** + * Parse [Response] body as html document using Jsoup + * @see [parseJson] + * @see [parseJsonArray] + */ +// TODO suspend +public fun Response.parseHtml(): Document = use { response -> + val body = response.body + val charset = body.contentType()?.charset()?.name() + Jsoup.parse(body.byteStream(), charset, response.request.url.toString()) +} + +/** + * Parse [Response] body as [JSONObject] + * @see [parseJsonArray] + * @see [parseHtml] + */ +public fun Response.parseJson(): JSONObject = use { response -> + JSONObject(response.body.string()) +} + +/** + * Backward-compatible alias for [parseJson]. + */ +public fun Response.parseJsonObject(): JSONObject = parseJson() + +/** + * Parse [Response] body as [JSONArray] + * @see [parseJson] + * @see [parseHtml] + */ +public fun Response.parseJsonArray(): JSONArray = use { response -> + JSONArray(response.body.string()) +} + +public fun Response.parseRaw(): String = use { response -> + response.body.string() +} + +public fun Response.parseBytes(): ByteArray = use { response -> + response.body.bytes() +} + +/** + * Convert url to relative if it is on [domain] + * @return an url relative to the [domain] or absolute, if domain is mismatching + */ +public fun String.toRelativeUrl(domain: String): String { + if (isEmpty() || startsWith("/")) { + return this + } + return replace(Regex("^[^/]{2,6}://${Regex.escape(domain)}+/", RegexOption.IGNORE_CASE), "/") +} + +/** + * Convert url to absolute with specified domain + * @return an absolute url with [domain] if this is relative + */ +public fun String.toAbsoluteUrl(domain: String): String = when { + startsWith("//") -> "$SCHEME_HTTPS:$this" + startsWith('/') -> "$SCHEME_HTTPS://$domain$this" + REGEX_SCHEME_PREFIX.containsMatchIn(this) -> this + else -> "$SCHEME_HTTPS://$domain/$this" +} + +/** + * Safe variant of [toAbsoluteUrl] that returns null when input is blank. + */ +public fun String.toAbsoluteUrlOrNull(domain: String): String? = + if (isBlank()) null else toAbsoluteUrl(domain) + +public fun concatUrl(host: String, path: String): String { + val hostWithSlash = host.endsWith('/') + val pathWithSlash = path.startsWith('/') + val hostWithScheme = if (host.startsWith("//")) "https:$host" else host + return when { + hostWithSlash && pathWithSlash -> hostWithScheme + path.drop(1) + !hostWithSlash && !pathWithSlash -> "$hostWithScheme/$path" + else -> hostWithScheme + path + } +} + +@InternalParsersApi +public fun DateFormat.parseSafe(str: String?): Long = if (str.isNullOrEmpty()) { + 0L +} else { + runCatching { + parse(str)?.time ?: 0L + }.onFailure { + if (javaClass.desiredAssertionStatus()) { + throw AssertionError("Cannot parse date $str", it) + } + }.getOrDefault(0L) +} + +@Deprecated("Useless since OkHttp 5.0", replaceWith = ReplaceWith("body")) +public fun Response.requireBody(): ResponseBody = body + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/RelatedContentFinder.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/RelatedContentFinder.kt new file mode 100644 index 0000000000..8d1a77d6a5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/RelatedContentFinder.kt @@ -0,0 +1,74 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import kotlinx.coroutines.* +import io.github.landwarderer.futon.mihon.parsers.ContentParser +import io.github.landwarderer.futon.mihon.parsers.model.Content +import io.github.landwarderer.futon.mihon.parsers.model.ContentListFilter +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder + +public class RelatedContentFinder( + private val parsers: Collection, +) { + + public suspend operator fun invoke(seed: Content): List = withContext(Dispatchers.Default) { + coroutineScope { + parsers.singleOrNull()?.let { parser -> + findRelatedImpl(this, parser, seed) + } ?: parsers.map { parser -> + async { + findRelatedImpl(this, parser, seed) + } + }.awaitAll().flatten() + } + } + + private suspend fun findRelatedImpl(scope: CoroutineScope, parser: ContentParser, seed: Content): List { + val words = HashSet() + words += seed.title.splitByWhitespace() + seed.altTitles.forEach { + words += it.splitByWhitespace() + } + if (words.isEmpty()) { + return emptyList() + } + + // 日志:记录 seed çš„ ID å’Œ URL + println("[RelatedContentFinder] seed.id=${seed.id} seed.url='${seed.url}' seed.title='${seed.title}'") + + val results = words.map { keyword -> + scope.async { + try { + val result = parser.getList( + 0, + if (SortOrder.RELEVANCE in parser.availableSortOrders) { + SortOrder.RELEVANCE + } else { + parser.availableSortOrders.first() + }, + ContentListFilter( + query = keyword, + ), + ) + + // 日志:记录搜索结果和过滤情况 + result.forEach { manga -> + val willFilter = manga.id == seed.id + println("[RelatedContentFinder] result: id=${manga.id} url='${manga.url}' title='${manga.title}' willFilterAsSeed=$willFilter") + } + + result.filter { it.id != seed.id && it.containKeyword(keyword) } + } catch (e: Exception) { + emptyList() + } + } + }.awaitAll() + return results.minBy { if (it.isEmpty()) Int.MAX_VALUE else it.size } + } + + + private fun Content.containKeyword(keyword: String): Boolean { + return title.contains(keyword, ignoreCase = true) + || altTitles.any { it.contains(keyword, ignoreCase = true) } + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Result.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Result.kt new file mode 100644 index 0000000000..c98d6a8214 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/Result.kt @@ -0,0 +1,41 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import kotlinx.coroutines.CancellationException +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +@Suppress("WRONG_INVOCATION_KIND") // https://youtrack.jetbrains.com/issue/KT-70714 +public inline fun T.runCatchingCancellable(block: T.() -> R): Result { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return try { + Result.success(block()) + } catch (e: InterruptedException) { + throw e + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } +} + +public inline fun Result.recoverCatchingCancellable(transform: (exception: Throwable) -> R): Result { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } + return when (val exception = exceptionOrNull()) { + null -> this + else -> runCatchingCancellable { transform(exception) } + } +} + +public inline fun Result.recoverNotNull(transform: (exception: Throwable) -> R?): Result { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } + return when (val exception = exceptionOrNull()) { + null -> this + else -> transform(exception)?.let(Result.Companion::success) ?: this + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/SearchQueryConverter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/SearchQueryConverter.kt new file mode 100644 index 0000000000..3d810dadb1 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/SearchQueryConverter.kt @@ -0,0 +1,251 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +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.ContentListFilterCapabilities +import io.github.landwarderer.futon.mihon.parsers.model.SortOrder +import io.github.landwarderer.futon.mihon.parsers.model.YEAR_UNKNOWN +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQuery +import io.github.landwarderer.futon.mihon.parsers.model.search.ContentSearchQueryCapabilities +import io.github.landwarderer.futon.mihon.parsers.model.search.QueryCriteria +import io.github.landwarderer.futon.mihon.parsers.model.search.QueryCriteria.* +import io.github.landwarderer.futon.mihon.parsers.model.search.SearchCapability +import io.github.landwarderer.futon.mihon.parsers.model.search.SearchableField.* + +/** + * Converts a [ContentListFilter] into a [ContentSearchQuery]. + * + * This function iterates through the filter attributes in [ContentListFilter] and creates corresponding + * search criteria in a [ContentSearchQuery.Builder]. + * + * @param filter The [ContentListFilter] to convert. + * @return A [ContentSearchQuery] constructed based on the given [filter]. + */ +internal fun convertToContentSearchQuery(offset: Int, sortOrder: SortOrder, filter: ContentListFilter): ContentSearchQuery { + return ContentSearchQuery.Builder().apply { + offset(offset) + order(sortOrder) + if (filter.tags.isNotEmpty()) criterion(Include(TAG, filter.tags)) + if (filter.tagsExclude.isNotEmpty()) criterion(Exclude(TAG, filter.tagsExclude)) + if (filter.states.isNotEmpty()) criterion(Include(STATE, filter.states)) + if (filter.types.isNotEmpty()) criterion(Include(CONTENT_TYPE, filter.types)) + if (filter.contentRating.isNotEmpty()) criterion(Include(CONTENT_RATING, filter.contentRating)) + if (filter.demographics.isNotEmpty()) criterion(Include(DEMOGRAPHIC, filter.demographics)) + if (validateYear(filter.yearFrom) || validateYear(filter.yearTo)) { + criterion(QueryCriteria.Range(PUBLICATION_YEAR, filter.yearFrom, filter.yearTo)) + } + if (validateYear(filter.year)) { + criterion(Match(PUBLICATION_YEAR, filter.year)) + } + filter.locale?.let { + criterion(Include(LANGUAGE, setOf(it))) + } + filter.originalLocale?.let { + criterion(Include(ORIGINAL_LANGUAGE, setOf(it))) + } + filter.query?.takeIf { it.isNotBlank() }?.let { + criterion(Match(TITLE_NAME, it)) + } + }.build() +} + +/** + * Converts a {@link ContentSearchQuery} into a {@link ContentListFilter}. + *

+ * This method iterates through the search criteria defined in the provided {@code searchQuery} + * and applies them to a {@link ContentListFilter.Builder}. The criteria are processed based on + * their types, such as inclusion, exclusion, equality checks, range filtering, and pattern matching. + *

+ *

+ * Supported criteria: + *

    + *
  • {@link QueryCriteria.Include} - Adds tags, states, content types, content ratings, demographics, and languages.
  • + *
  • {@link QueryCriteria.Exclude} - Excludes tags.
  • + *
  • {@link QueryCriteria.Equals} - Sets specific values like publication year.
  • + *
  • {@link QueryCriteria.Between} - Sets a range of values like publication year range.
  • + *
  • {@link QueryCriteria.Match} - Adds a search pattern for the title name.
  • + *
+ *

+ *

+ * If an unsupported field is encountered, an {@link UnsupportedOperationException} is thrown. + *

+ * + * @param searchQuery The {@link ContentSearchQuery} to convert. + * @return A {@link ContentListFilter} constructed based on the given {@code searchQuery}. + * @throws UnsupportedOperationException If the search criteria contain unsupported fields. + */ +internal fun convertToContentListFilter(searchQuery: ContentSearchQuery): ContentListFilter { + return ContentListFilter.Builder().apply { + for (criterion in searchQuery.criteria) { + when (criterion) { + is Include<*> -> handleInclude(this, criterion) + is Exclude<*> -> handleExclude(this, criterion) + is Range<*> -> handleBetween(this, criterion) + is Match<*> -> handleMatch(this, criterion) + } + } + }.build() +} + +@OptIn(InternalParsersApi::class) +internal fun ContentSearchQueryCapabilities.toContentListFilterCapabilities() = ContentListFilterCapabilities( + isMultipleTagsSupported = capabilities.any { x -> x.field == TAG && x.isMultiple }, + isTagsExclusionSupported = capabilities.any { x -> x.field == TAG && x.criteriaTypes.contains(Exclude::class) }, + isSearchSupported = capabilities.any { x -> x.field == TITLE_NAME }, + isSearchWithFiltersSupported = capabilities.any { x -> x.field == TITLE_NAME && !x.isExclusive }, + isYearSupported = capabilities.any { x -> x.field == PUBLICATION_YEAR && x.criteriaTypes.contains(Match::class) }, + isYearRangeSupported = capabilities.any { x -> x.field == PUBLICATION_YEAR && x.criteriaTypes.contains(Range::class) }, + isOriginalLocaleSupported = capabilities.any { x -> x.field == ORIGINAL_LANGUAGE }, + isAuthorSearchSupported = capabilities.any { x -> x.field == AUTHOR }, +) + +internal fun ContentListFilterCapabilities.toContentSearchQueryCapabilities(): ContentSearchQueryCapabilities = + ContentSearchQueryCapabilities( + capabilities = setOfNotNull( + isMultipleTagsSupported.takeIf { it }?.let { + SearchCapability( + field = TAG, + criteriaTypes = setOf(Include::class), + isMultiple = true, + ) + }, + isTagsExclusionSupported.takeIf { it }?.let { + SearchCapability( + field = TAG, + criteriaTypes = setOf(Exclude::class), + isMultiple = true, + ) + }, + isSearchSupported.takeIf { it }?.let { + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + isMultiple = false, + isExclusive = true, + ) + }, + isSearchWithFiltersSupported.takeIf { it }?.let { + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + isMultiple = false, + ) + }, + isYearSupported.takeIf { it }?.let { + SearchCapability( + field = PUBLICATION_YEAR, + criteriaTypes = setOf(Match::class), + isMultiple = false, + ) + }, + isYearRangeSupported.takeIf { it }?.let { + SearchCapability( + field = PUBLICATION_YEAR, + criteriaTypes = setOf(Range::class), + isMultiple = false, + ) + }, + isOriginalLocaleSupported.takeIf { it }?.let { + SearchCapability( + field = ORIGINAL_LANGUAGE, + criteriaTypes = setOf(Include::class), + isMultiple = true, + ) + }, + SearchCapability( + field = LANGUAGE, + criteriaTypes = setOf(Include::class), + isMultiple = true, + ), + SearchCapability( + field = STATE, criteriaTypes = setOf(Include::class), isMultiple = true, + ), + SearchCapability( + field = CONTENT_TYPE, + criteriaTypes = setOf(Include::class), + isMultiple = true, + ), + SearchCapability( + field = CONTENT_RATING, + criteriaTypes = setOf(Include::class), + isMultiple = true, + ), + SearchCapability( + field = DEMOGRAPHIC, + criteriaTypes = setOf(Include::class), + isMultiple = true, + ), + ), + ) + +private fun handleInclude(builder: ContentListFilter.Builder, criterion: Include<*>) { + val type = criterion.field.type + + when (criterion.field) { + TAG -> builder.addTags(filterValues(criterion, type)) + STATE -> builder.addStates(filterValues(criterion, type)) + CONTENT_TYPE -> builder.addTypes(filterValues(criterion, type)) + CONTENT_RATING -> builder.addContentRatings(filterValues(criterion, type)) + DEMOGRAPHIC -> builder.addDemographics(filterValues(criterion, type)) + LANGUAGE -> builder.locale(getFirstValue(criterion, type)) + ORIGINAL_LANGUAGE -> builder.originalLocale(getFirstValue(criterion, type)) + else -> throw IllegalArgumentException("Unsupported field for Include criterion: ${criterion.field}") + } +} + +private fun handleExclude(builder: ContentListFilter.Builder, criterion: Exclude<*>) { + val type = criterion.field.type + + when (criterion.field) { + TAG -> builder.excludeTags(filterValues(criterion, type)) + else -> throw IllegalArgumentException("Unsupported field for Exclude criterion: ${criterion.field}") + } +} + +private fun handleBetween(builder: ContentListFilter.Builder, criterion: Range<*>) { + val type = criterion.field.type + + when (criterion.field) { + PUBLICATION_YEAR -> { + builder.yearFrom(getValue(criterion.from, type, YEAR_UNKNOWN)) + builder.yearTo(getValue(criterion.to, type, YEAR_UNKNOWN)) + } + + else -> throw IllegalArgumentException("Unsupported field for Between criterion: ${criterion.field}") + } +} + +private fun handleMatch(builder: ContentListFilter.Builder, criterion: Match<*>) { + val type = criterion.field.type + + when (criterion.field) { + TITLE_NAME -> builder.query(getValue(criterion.value, type, "")) + PUBLICATION_YEAR -> builder.year(getValue(criterion.value, type, YEAR_UNKNOWN)) + else -> throw IllegalArgumentException("Unsupported field for Match criterion: ${criterion.field}") + } +} + +@Suppress("UNCHECKED_CAST") +private fun filterValues(criterion: Include<*>, type: Class<*>): List { + return criterion.values.filter { type.isInstance(it) } as List +} + +@Suppress("UNCHECKED_CAST") +private fun filterValues(criterion: Exclude<*>, type: Class<*>): List { + return criterion.values.filter { type.isInstance(it) } as List +} + +@Suppress("UNCHECKED_CAST") +private fun getFirstValue(criterion: Include<*>, type: Class<*>): T? { + return criterion.values.firstOrNull { type.isInstance(it) } as? T +} + +@Suppress("UNCHECKED_CAST") +private fun getValue(value: Any?, type: Class<*>, default: T): T { + val isCompatibleIntType = (type == Int::class.java && Integer::class.isInstance(value)) + + return if (type.isInstance(value) || isCompatibleIntType) value as T else default +} + +private fun validateYear(year: Int) = year != YEAR_UNKNOWN + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/String.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/String.kt new file mode 100644 index 0000000000..4ad88c765e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/String.kt @@ -0,0 +1,237 @@ +@file:JvmName("StringUtils") + +package io.github.landwarderer.futon.mihon.parsers.util + +import androidx.collection.MutableIntList +import java.math.BigInteger +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import java.util.* +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.math.min + +private val REGEX_WHITESPACE = Regex("\\s+") +internal const val LONG_HASH_SEED = 1125899906842597L + +public fun String.removeSurrounding(vararg chars: Char): String { + if (isEmpty()) { + return this + } + for (c in chars) { + if (first() == c && last() == c) { + return substring(1, length - 1) + } + } + return this +} + +public inline fun C?.ifNullOrEmpty(defaultValue: () -> R): R { + contract { + callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) + } + return if (this.isNullOrEmpty()) defaultValue() else this +} + +public fun String.longHashCode(): Long { + var h = LONG_HASH_SEED + val len: Int = this.length + for (i in 0 until len) { + h = 31 * h + this[i].code + } + return h +} + +public fun String.toCamelCase(): String { + if (isEmpty()) { + return this + } + val result = StringBuilder(length) + var capitalize = true + for (char in this) { + result.append( + if (capitalize) { + char.uppercase() + } else { + char.lowercase() + }, + ) + capitalize = char.isWhitespace() + } + return result.toString() +} + +public fun String.digits(): String = filter { it.isDigit() } + +public fun String.toTitleCase(): String { + return replaceFirstChar { x -> x.uppercase() } +} + +public fun String.toTitleCase(locale: Locale): String { + return replaceFirstChar { x -> x.uppercase(locale) } +} + +public fun String.ellipsize(maxLength: Int): String = if (this.length > maxLength) { + this.take(maxLength - 1) + Typography.ellipsis +} else this + +public fun String.splitTwoParts(delimiter: Char): Pair? { + val indices = MutableIntList(4) + for ((i, c) in this.withIndex()) { + if (c == delimiter) { + indices += i + } + } + if (indices.isEmpty() || indices.size and 1 == 0) { + return null + } + val index = indices[indices.size / 2] + return substring(0, index) to substring(index + 1) +} + +public fun String.urlEncoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name()) + +public fun String.urlDecode(): String = URLDecoder.decode(this, Charsets.UTF_8.name()) + +public fun String.nl2br(): String = replace("\n", "
") + +public fun String.splitByWhitespace(): List = trim().split(REGEX_WHITESPACE) + +public fun T.nullIfEmpty(): T? = takeUnless { it.isEmpty() } + +public fun ByteArray.byte2HexFormatted(): String { + val str = StringBuilder(size * 2) + for (i in indices) { + var h = Integer.toHexString(this[i].toInt()) + val l = h.length + if (l == 1) { + h = "0$h" + } + if (l > 2) { + h = h.substring(l - 2, l) + } + str.append(h.uppercase(Locale.ROOT)) + if (i < size - 1) { + str.append(':') + } + } + return str.toString() +} + +public fun String.md5(): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(toByteArray())) + .toString(16) + .padStart(32, '0') +} + +public fun String.substringBetween(from: String, to: String, fallbackValue: String = this): String { + val fromIndex = indexOf(from) + if (fromIndex == -1) { + return fallbackValue + } + val toIndex = lastIndexOf(to) + return if (toIndex == -1) { + fallbackValue + } else { + substring(fromIndex + from.length, toIndex) + } +} + +public fun String.substringBetweenFirst(from: String, to: String): String? { + val fromIndex = indexOf(from) + if (fromIndex == -1) { + return null + } + val toIndex = indexOf(to, fromIndex) + return if (toIndex == -1) { + null + } else { + substring(fromIndex + from.length, toIndex) + } +} + +public fun String.substringBetweenLast(from: String, to: String, fallbackValue: String = this): String { + val fromIndex = lastIndexOf(from) + if (fromIndex == -1) { + return fallbackValue + } + val toIndex = lastIndexOf(to) + return if (toIndex == -1) { + fallbackValue + } else { + substring(fromIndex + from.length, toIndex) + } +} + +public fun String.find(regex: Regex): String? = regex.find(this)?.value + +public fun String.findGroupValue(regex: Regex): String? = regex.find(this)?.groupValues?.getOrNull(1) + +public fun String.removeSuffix(suffix: Char): String { + if (lastOrNull() == suffix) { + return substring(0, length - 1) + } + return this +} + +public fun String.levenshteinDistance(other: String): Int { + if (this == other) { + return 0 + } + if (this.isEmpty()) { + return other.length + } + if (other.isEmpty()) { + return this.length + } + + val lhsLength = this.length + 1 + val rhsLength = other.length + 1 + + var cost = Array(lhsLength) { it } + var newCost = Array(lhsLength) { 0 } + + for (i in 1 until rhsLength) { + newCost[0] = i + + for (j in 1 until lhsLength) { + val match = if (this[j - 1] == other[i - 1]) 0 else 1 + + val costReplace = cost[j - 1] + match + val costInsert = cost[j] + 1 + val costDelete = newCost[j - 1] + 1 + + newCost[j] = min(min(costInsert, costDelete), costReplace) + } + + val swap = cost + cost = newCost + newCost = swap + } + + return cost[lhsLength - 1] +} + +/** + * @param threshold 0 = exact match + */ +public fun String.almostEquals(other: String, threshold: Float): Boolean { + if (threshold <= 0f) { + return equals(other, ignoreCase = true) + } + val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) + return diff < threshold +} + +public fun String.isNumeric(): Boolean = all { c -> c.isDigit() } + +internal fun StringBuilder.removeTrailingZero() { + if (length > 2 && get(length - 1) == '0') { + val dot = get(length - 2) + if (dot == ',' || dot == '.') { + delete(length - 2, length) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/WebViewHelper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/WebViewHelper.kt new file mode 100644 index 0000000000..a4734f4269 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/WebViewHelper.kt @@ -0,0 +1,13 @@ +package io.github.landwarderer.futon.mihon.parsers.util + +import io.github.landwarderer.futon.mihon.parsers.ContentLoaderContext + +public class WebViewHelper( + private val context: ContentLoaderContext, +) { + + public suspend fun getLocalStorageValue(domain: String, key: String): String? { + return context.evaluateJs("$SCHEME_HTTPS://$domain/", "window.localStorage.getItem(\"$key\")") + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/EscapeUtils.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/EscapeUtils.kt new file mode 100644 index 0000000000..9b52a34121 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/EscapeUtils.kt @@ -0,0 +1,38 @@ +package io.github.landwarderer.futon.mihon.parsers.util.json + +public fun String.unescapeJson(): String { + val builder = StringBuilder() + var i = 0 + while (i < length) { + val delimiter = this[i] + i++ // consume letter or backslash + if (delimiter == '\\' && i < length) { + val ch = this[i] + i++ + + when (ch) { + '\\', '/', '"', '\'' -> builder.append(ch) + 'n' -> builder.append('\n') + 'r' -> builder.append('\r') + 't' -> builder.append('\t') + 'b' -> builder.append('\b') + 'u' -> { + val hex = StringBuilder(4) + require(i + 4 <= length) { "Not enough unicode digits!" } + for (x in substring(i, i + 4)) { + require(x.isLetterOrDigit()) { "Bad character in unicode escape" } + hex.append(x.lowercase()) + } + i += 4 // consume those four digits. + val code = hex.toString().toInt(16) + builder.append(code.toChar()) + } + + else -> throw IllegalArgumentException("Illegal escape sequence: \\$ch") + } + } else { + builder.append(delimiter) + } + } + return builder.toString() +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedIterator.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedIterator.kt new file mode 100644 index 0000000000..91a49d0218 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedIterator.kt @@ -0,0 +1,33 @@ +package io.github.landwarderer.futon.mihon.parsers.util.json + +import org.json.JSONArray + +internal class JSONArrayTypedIterator( + private val array: JSONArray, + private val typeClass: Class, + startIndex: Int, +) : ListIterator { + + private val total = array.length() + private var index = startIndex + + override fun hasNext() = index < total + + override fun next(): T { + if (!hasNext()) throw NoSuchElementException() + return get(index++) + } + + override fun hasPrevious(): Boolean = index > 0 + + override fun nextIndex(): Int = index + + override fun previous(): T { + if (!hasPrevious()) throw NoSuchElementException() + return get(--index) + } + + override fun previousIndex(): Int = index - 1 + + private fun get(i: Int): T = typeClass.cast(array[i]) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedListWrapper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedListWrapper.kt new file mode 100644 index 0000000000..a0b89124cf --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONArrayTypedListWrapper.kt @@ -0,0 +1,53 @@ +package io.github.landwarderer.futon.mihon.parsers.util.json + +import org.json.JSONArray + +internal class JSONArrayTypedListWrapper( + private val jsonArray: JSONArray, + private val typeClass: Class, +) : List { + + override fun contains(element: T): Boolean = indexOf(element) != -1 + + override fun containsAll(elements: Collection): Boolean = elements.all { contains(it) } + + override fun get(index: Int): T = typeClass.cast(jsonArray[index]) + + override fun indexOf(element: T): Int { + repeat(jsonArray.length()) { i -> + if (jsonArray[i] == element) { + return i + } + } + return -1 + } + + override fun isEmpty(): Boolean = jsonArray.length() == 0 + + override fun iterator(): Iterator = listIterator(0) + + override fun lastIndexOf(element: T): Int { + val total = jsonArray.length() + repeat(total) { i -> + if (jsonArray[total - i - 1] == element) { + return i + } + } + return -1 + } + + override fun listIterator(): ListIterator = listIterator(0) + + override fun listIterator(index: Int): ListIterator = JSONArrayTypedIterator(jsonArray, typeClass, index) + + override fun subList(fromIndex: Int, toIndex: Int): List { + val result = ArrayList(toIndex - fromIndex + 1) + for (i in fromIndex..toIndex) { + result.add(get(i)) + } + return result + } + + override val size: Int + get() = jsonArray.length() +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONObjectTypedIterableWrapper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONObjectTypedIterableWrapper.kt new file mode 100644 index 0000000000..2ba7299cc3 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JSONObjectTypedIterableWrapper.kt @@ -0,0 +1,24 @@ +package io.github.landwarderer.futon.mihon.parsers.util.json + +import org.json.JSONObject + +internal class JSONObjectTypedIterableWrapper( + private val json: JSONObject, + private val typeClass: Class, +) : Iterable> { + + override fun iterator(): Iterator> = IteratorImpl() + + private inner class IteratorImpl : Iterator> { + + private val keyIterator = json.keys().iterator() + + override fun hasNext(): Boolean = keyIterator.hasNext() + + override fun next(): Map.Entry = keyIterator.next().let { key -> + JSONEntry(key, typeClass.cast(json.get(key))) + } + } + + private class JSONEntry(override val key: String, override val value: T) : Map.Entry +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JsonExt.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JsonExt.kt new file mode 100644 index 0000000000..3017099710 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/json/JsonExt.kt @@ -0,0 +1,168 @@ +package io.github.landwarderer.futon.mihon.parsers.util.json + +import androidx.collection.ArraySet +import io.github.landwarderer.futon.mihon.parsers.util.nullIfEmpty +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.util.Locale +import kotlin.contracts.contract + +public fun String.toJSONObjectOrNull(): JSONObject? = try { + JSONObject(this) +} catch (_: JSONException) { + null +} + +public fun String.toJSONArrayOrNull(): JSONArray? = try { + JSONArray(this) +} catch (_: JSONException) { + null +} + +public inline fun > JSONArray.mapJSONTo( + destination: C, + block: (JSONObject) -> R, +): C { + val len = length() + for (i in 0 until len) { + val jo = getJSONObject(i) + destination.add(block(jo)) + } + return destination +} + +public inline fun > JSONArray.mapJSONNotNullTo( + destination: C, + block: (JSONObject) -> R?, +): C { + val len = length() + for (i in 0 until len) { + val jo = getJSONObject(i) + destination.add(block(jo) ?: continue) + } + return destination +} + +public inline fun JSONArray.mapJSON(block: (JSONObject) -> T): List { + return mapJSONTo(ArrayList(length()), block) +} + +public inline fun JSONArray.mapJSONNotNull(block: (JSONObject) -> T?): List { + return mapJSONNotNullTo(ArrayList(length()), block) +} + +public inline fun JSONArray.mapJSONToSet(mapper: (JSONObject) -> T): Set { + return mapJSONTo(ArraySet(length()), mapper) +} + +public inline fun JSONArray.mapJSONNotNullToSet(mapper: (JSONObject) -> T?): Set { + return mapJSONNotNullTo(ArraySet(length()), mapper) +} + +public fun JSONArray.mapJSONIndexed(block: (Int, JSONObject) -> T): List { + val len = length() + val result = ArrayList(len) + for (i in 0 until len) { + val jo = getJSONObject(i) + result.add(block(i, jo)) + } + return result +} + +public fun JSONObject.getStringOrNull(name: String): String? = opt(name)?.takeUnless { + it === JSONObject.NULL +}?.toString()?.nullIfEmpty() + +public fun JSONObject.getBooleanOrDefault(name: String, defaultValue: Boolean): Boolean { + return when (val rawValue = opt(name)) { + null, JSONObject.NULL -> defaultValue + is Boolean -> rawValue + is Number -> rawValue.toInt() != 0 + is String -> rawValue.lowercase(Locale.ROOT).toBooleanStrictOrNull() ?: defaultValue + else -> defaultValue + } +} + +public fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long { + return when (val rawValue = opt(name)) { + null, JSONObject.NULL -> defaultValue + is Long -> rawValue + is Number -> rawValue.toLong() + is String -> rawValue.toLongOrNull() ?: defaultValue + else -> defaultValue + } +} + +public fun JSONObject.getIntOrDefault(name: String, defaultValue: Int): Int { + return when (val rawValue = opt(name)) { + null, JSONObject.NULL -> defaultValue + is Int -> rawValue + is Number -> rawValue.toInt() + is String -> rawValue.toIntOrNull() ?: defaultValue + else -> defaultValue + } +} + +public fun JSONObject.getDoubleOrDefault(name: String, defaultValue: Double): Double { + return when (val rawValue = opt(name)) { + null, JSONObject.NULL -> defaultValue + is Double -> rawValue + is Number -> rawValue.toDouble() + is String -> rawValue.toDoubleOrNull() ?: defaultValue + else -> defaultValue + } +} + +public fun JSONObject.getFloatOrDefault(name: String, defaultValue: Float): Float { + return when (val rawValue = opt(name)) { + null, JSONObject.NULL -> defaultValue + is Float -> rawValue + is Number -> rawValue.toFloat() + is String -> rawValue.toFloatOrNull() ?: defaultValue + else -> defaultValue + } +} + +public fun > JSONObject.getEnumValueOrNull(name: String, enumClass: Class): E? { + val enumName = getStringOrNull(name) ?: return null + return enumClass.enumConstants?.find { x -> + enumName.equals(x.name, ignoreCase = true) + } +} + +public fun > JSONObject.getEnumValueOrDefault(name: String, defaultValue: E): E { + return getEnumValueOrNull(name, defaultValue.javaClass) ?: defaultValue +} + +public fun JSONArray?.isNullOrEmpty(): Boolean { + contract { + returns(false) implies (this@isNullOrEmpty != null) + } + + return this == null || this.length() == 0 +} + +public fun JSONArray.asTypedList(typeClass: Class): List { + return JSONArrayTypedListWrapper(this, typeClass) +} + +public inline fun JSONArray.asTypedList(): List = asTypedList(T::class.java) + +public fun JSONObject.entries(typeClass: Class): Iterable> { + return JSONObjectTypedIterableWrapper(this, typeClass) +} + +public inline fun JSONObject.entries(): Iterable> = entries(T::class.java) + +public fun JSONArray.toStringSet(): Set { + val set = ArraySet(length()) + repeat(length()) { i -> + val str = optString(i) + if (!str.isNullOrEmpty()) { + set.add(str) + } + } + return set +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SoftSuspendLazyImpl.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SoftSuspendLazyImpl.kt new file mode 100644 index 0000000000..02f6c63140 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SoftSuspendLazyImpl.kt @@ -0,0 +1,41 @@ +package io.github.landwarderer.futon.mihon.parsers.util.suspendlazy + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.lang.ref.SoftReference +import kotlin.coroutines.CoroutineContext + +/** + * Like a [SuspendLazy] but with [SoftReference] under the hood + */ +internal class SoftSuspendLazyImpl( + private val coroutineContext: CoroutineContext, + private val initializer: SuspendLazyInitializer, +) : SuspendLazy { + + private val mutex: Mutex = Mutex() + private var cachedValue: SoftReference? = null + + override val isInitialized: Boolean + get() = cachedValue?.get() != null + + override suspend fun get(): T { + // fast way + cachedValue?.get()?.let { + return it + } + return mutex.withLock { + cachedValue?.get()?.let { + return it + } + val result = withContext(coroutineContext) { + initializer() + } + cachedValue = SoftReference(result) + result + } + } + + override fun peek(): T? = cachedValue?.get() +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazy.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazy.kt new file mode 100644 index 0000000000..7abffa4243 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazy.kt @@ -0,0 +1,40 @@ +package io.github.landwarderer.futon.mihon.parsers.util.suspendlazy + +import io.github.landwarderer.futon.mihon.parsers.util.runCatchingCancellable +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +internal typealias SuspendLazyInitializer = suspend () -> T + +public interface SuspendLazy { + + public val isInitialized: Boolean + + public suspend fun get(): T + + public fun peek(): T? +} + +public suspend fun SuspendLazy.getOrNull(): T? = runCatchingCancellable { + get() +}.getOrNull() + +public suspend fun SuspendLazy.getOrDefault(defaultValue: R): R = runCatchingCancellable { + get() +}.getOrDefault(defaultValue) + +public fun suspendLazy( + context: CoroutineContext = EmptyCoroutineContext, + initializer: SuspendLazyInitializer, +): SuspendLazy = SuspendLazyImpl(context, initializer) + +public fun suspendLazy( + context: CoroutineContext = EmptyCoroutineContext, + soft: Boolean, + initializer: SuspendLazyInitializer, +): SuspendLazy = if (soft) { + SoftSuspendLazyImpl(context, initializer) +} else { + SuspendLazyImpl(context, initializer) +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazyImpl.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazyImpl.kt new file mode 100644 index 0000000000..9c44c576b7 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/parsers/util/suspendlazy/SuspendLazyImpl.kt @@ -0,0 +1,47 @@ +package io.github.landwarderer.futon.mihon.parsers.util.suspendlazy + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +internal class SuspendLazyImpl( + private val coroutineContext: CoroutineContext, + private val initializer: SuspendLazyInitializer, +) : SuspendLazy { + + private val mutex: Mutex = Mutex() + private var cachedValue: Any? = Uninitialized + + override val isInitialized: Boolean + get() = cachedValue !== Uninitialized + + @Suppress("UNCHECKED_CAST") + override suspend fun get(): T { + // fast way + cachedValue.let { + if (it !== Uninitialized) { + return it as T + } + } + return mutex.withLock { + cachedValue.let { + if (it !== Uninitialized) { + return it as T + } + } + val result = withContext(coroutineContext) { + initializer() + } + cachedValue = result + result + } + } + + @Suppress("UNCHECKED_CAST") + override fun peek(): T? { + return cachedValue?.takeUnless { it === Uninitialized } as T? + } + + private object Uninitialized +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt index 9f130d4152..565b63a6cd 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -4,13 +4,6 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.viewModelScope import androidx.room.invalidationTrackerFlow 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.plus import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.db.MangaDatabase import io.github.landwarderer.futon.core.db.TABLE_SOURCES @@ -19,11 +12,18 @@ import io.github.landwarderer.futon.core.ui.BaseViewModel 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.core.util.ext.mapSortedByCount import io.github.landwarderer.futon.explore.data.MangaSourcesRepository import io.github.landwarderer.futon.explore.data.SourcesSortOrder import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingState +import io.github.landwarderer.futon.mihon.MihonExtensionManager +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.plus import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import java.util.EnumSet @@ -33,13 +33,16 @@ import javax.inject.Inject @HiltViewModel class SourcesCatalogViewModel @Inject constructor( private val repository: MangaSourcesRepository, + private val mihonExtensionManager: MihonExtensionManager, db: MangaDatabase, settings: AppSettings, ) : BaseViewModel() { val onActionDone = MutableEventFlow() - val locales: Set = repository.allMangaSources.mapTo(HashSet()) { it.locale }.also { - it.add(null) + val locales: Set = buildSet { + repository.allMangaSources.forEach { add(it.locale) } + mihonExtensionManager.getMihonMangaSources().forEach { add(it.locale) } + add(null) } private val searchQuery = MutableStateFlow(null) @@ -60,7 +63,8 @@ class SourcesCatalogViewModel @Inject constructor( searchQuery, appliedFilter, db.invalidationTrackerFlow(TABLE_SOURCES), - ) { q, f, _ -> + mihonExtensionManager.installedExtensions, + ) { q, f, _, _ -> buildSourcesList(f, q) }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) @@ -137,7 +141,44 @@ class SourcesCatalogViewModel @Inject constructor( @WorkerThread private fun getContentTypes(isNsfwDisabled: Boolean): List { - val result = repository.allMangaSources.mapSortedByCount { it.contentType } + val result = buildSet { + repository.allMangaSources.forEach { add(it.contentType) } + mihonExtensionManager.getMihonMangaSources().forEach { + when(it.contentType) { + io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANGA -> add(ContentType.MANGA) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.HENTAI_MANGA -> add(ContentType.HENTAI) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.COMICS -> add(ContentType.COMICS) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANHWA -> add(ContentType.MANHWA) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANHUA -> add(ContentType.MANHUA) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.NOVEL -> add(ContentType.NOVEL) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.ONE_SHOT -> add(ContentType.ONE_SHOT) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.DOUJINSHI -> add(ContentType.DOUJINSHI) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.IMAGE_SET -> add(ContentType.IMAGE_SET) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.ARTIST_CG -> add(ContentType.ARTIST_CG) + io.github.landwarderer.futon.mihon.parsers.model.ContentType.GAME_CG -> add(ContentType.GAME_CG) + else -> {} + } + } + }.toList().sortedByDescending { type -> + val kotatsuCount = repository.allMangaSources.count { it.contentType == type } + val mihonCount = mihonExtensionManager.getMihonMangaSources().count { + when(it.contentType) { + io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANGA -> type == ContentType.MANGA + io.github.landwarderer.futon.mihon.parsers.model.ContentType.HENTAI_MANGA -> type == ContentType.HENTAI + io.github.landwarderer.futon.mihon.parsers.model.ContentType.COMICS -> type == ContentType.COMICS + io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANHWA -> type == ContentType.MANHWA + io.github.landwarderer.futon.mihon.parsers.model.ContentType.MANHUA -> type == ContentType.MANHUA + io.github.landwarderer.futon.mihon.parsers.model.ContentType.NOVEL -> type == ContentType.NOVEL + io.github.landwarderer.futon.mihon.parsers.model.ContentType.ONE_SHOT -> type == ContentType.ONE_SHOT + io.github.landwarderer.futon.mihon.parsers.model.ContentType.DOUJINSHI -> type == ContentType.DOUJINSHI + io.github.landwarderer.futon.mihon.parsers.model.ContentType.IMAGE_SET -> type == ContentType.IMAGE_SET + io.github.landwarderer.futon.mihon.parsers.model.ContentType.ARTIST_CG -> type == ContentType.ARTIST_CG + io.github.landwarderer.futon.mihon.parsers.model.ContentType.GAME_CG -> type == ContentType.GAME_CG + else -> false + } + } + kotatsuCount + mihonCount + } return if (isNsfwDisabled) { result.filterNot { it == ContentType.HENTAI } } else { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt index 9dc2343dda..778b7837c5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/manage/SourcesListProducer.kt @@ -13,10 +13,12 @@ import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.util.ext.lifecycleScope import io.github.landwarderer.futon.explore.data.MangaSourcesRepository import io.github.landwarderer.futon.explore.data.SourcesSortOrder +import io.github.landwarderer.futon.mihon.MihonExtensionManager import io.github.landwarderer.futon.settings.sources.model.SourceConfigItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -32,6 +34,7 @@ class SourcesListProducer @Inject constructor( @LocalizedAppContext private val context: Context, private val repository: MangaSourcesRepository, private val settings: AppSettings, + private val mihonExtensionManager: MihonExtensionManager, ) : InvalidationTracker.Observer(TABLE_SOURCES) { private val scope = lifecycle.lifecycleScope @@ -43,8 +46,12 @@ class SourcesListProducer @Inject constructor( } init { - settings.observeChanges() - .filter { it == AppSettings.KEY_TIPS_CLOSED || it == AppSettings.KEY_DISABLE_NSFW } + combine( + settings.observeChanges() + .filter { it == AppSettings.KEY_TIPS_CLOSED || it == AppSettings.KEY_DISABLE_NSFW }, + mihonExtensionManager.installedExtensions, + mihonExtensionManager.failedExtensions, + ) { _, _, _ -> } .flowOn(Dispatchers.IO) .onEach { onInvalidated(emptySet()) } .launchIn(scope) diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index a938c39f74..68b6b5e8e8 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -144,4 +144,16 @@ @string/ask_every_time @string/disable + + @string/github_mirror_native + @string/github_mirror_kkgithub + @string/github_mirror_ghproxy + @string/github_mirror_ghproxy_net + + + NATIVE + KKGITHUB + GHPROXY + GHPROXY_NET + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61055e288b..f5193e08b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -908,4 +908,13 @@ %1$s available Crash Reporting Send anonymous crash logs to help improve the app. Takes effect after restart. + GitHub mirror + Native + KKGitHub (raw.kkgithub.com) + GHProxy (mirror.ghproxy.com) + GHProxy.net (ghproxy.net) + Enable manga source + Manga source domain + Whole manga + recommended manga diff --git a/app/src/main/res/xml/pref_network_storage.xml b/app/src/main/res/xml/pref_network_storage.xml index 600f8efc9a..ecc2bec2c6 100644 --- a/app/src/main/res/xml/pref_network_storage.xml +++ b/app/src/main/res/xml/pref_network_storage.xml @@ -46,6 +46,14 @@ android:title="@string/dns_over_https" app:useSimpleSummaryProvider="true" /> + + Date: Tue, 21 Apr 2026 19:16:19 -0300 Subject: [PATCH 04/13] fix: manga titles not showing --- .../futon/core/parser/favicon/FaviconFetcher.kt | 8 +++++++- .../futon/explore/data/MangaSourcesRepository.kt | 4 ++++ .../landwarderer/futon/mihon/MihonExtensionLoader.kt | 6 +++--- .../landwarderer/futon/mihon/MihonMangaRepository.kt | 8 +++++++- .../landwarderer/futon/mihon/model/MihonDataConverters.kt | 4 ++-- 5 files changed, 23 insertions(+), 7 deletions(-) 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..9ba94a2e86 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,11 @@ class FaviconFetcher( ) is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options) + is MihonMangaRepository -> ImageFetchResult( + image = ColorImage(Color.WHITE), + isSampled = false, + dataSource = DataSource.MEMORY, + ) else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}") } 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 2840e3b954..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 @@ -13,6 +13,7 @@ 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 @@ -426,6 +427,9 @@ class MangaSourcesRepository @Inject constructor( if (skipNsfwSources && source.isNsfw()) { continue } + if (source.isBroken) { + continue + } if (source is MangaParserSource || source.name.startsWith("mihon:") || source.name.startsWith("MIHON_")) { result.add( MangaSourceInfo( 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 097e3d5959..7a16079b9e 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 @@ -278,10 +278,10 @@ class MihonExtensionLoader @Inject constructor( } // Get app name and language - val appName = ExternalExtensionLoaderSupport.getAppLabel(context, appInfo) + 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)") + Log.d(TAG, "Loading extension: $pkgName (lib $libVersion, $lang) - Name: $appName") // Create ClassLoader for this extension val classLoader = try { @@ -313,7 +313,7 @@ class MihonExtensionLoader @Inject constructor( return MihonLoadResult.Success( pkgName = pkgName, - appName = appName, + appName = appName ?: "Unknown", versionCode = versionCode, versionName = versionName, libVersion = libVersion, 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 index 826b87a27f..1af8906431 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt @@ -121,7 +121,9 @@ class MihonMangaRepository( sContent.toDomainContent( source = source, publicUrl = (mihonSource as? HttpSource)?.getPublicContentUrl(sContent) ?: "", - ).toManga() + ).also { + android.util.Log.d(TAG, "Mapped to Domain Content: ${it.title}") + }.toManga() } } @@ -348,4 +350,8 @@ class MihonMangaRepository( } 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/model/MihonDataConverters.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt index 246b922ace..bcba6340f9 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt @@ -29,7 +29,7 @@ fun SManga.toDomainContent( val absolutePublicUrl = resolveUrl(baseUrl, safeUrl) ?: safeUrl // Safely access lateinit properties - val safeTitle = try { title } catch (e: UninitializedPropertyAccessException) { "Unknown" } + val safeTitle = try { title } catch (e: UninitializedPropertyAccessException) { null } val safeGenres = try { getGenres() } catch (e: UninitializedPropertyAccessException) { null } val safeAuthor = try { author } catch (e: UninitializedPropertyAccessException) { null } val safeArtist = try { artist } catch (e: UninitializedPropertyAccessException) { null } @@ -40,7 +40,7 @@ fun SManga.toDomainContent( return Content( id = generateContentId(safeUrl, source.name), - title = safeTitle.ifBlank { "Unknown" }, + title = safeTitle ?: "Unknown", altTitles = emptySet(), url = safeUrl, Url = publicUrl.ifBlank { absolutePublicUrl }, From 3c752e113247627123c7a126b0594205a3899637 Mon Sep 17 00:00:00 2001 From: Ash Date: Wed, 22 Apr 2026 09:44:28 -0300 Subject: [PATCH 05/13] feat: add extension downloader activity --- app/src/main/AndroidManifest.xml | 3 + .../core/db/DatabasePrePopulateCallback.kt | 16 +++ .../landwarderer/futon/core/nav/AppRouter.kt | 21 ++-- .../core/network/CommonHeadersInterceptor.kt | 13 +-- .../futon/core/network/NetworkModule.kt | 8 ++ .../futon/core/prefs/AppSettings.kt | 3 +- .../futon/core/prefs/GitHubMirror.kt | 2 +- .../futon/list/ui/adapter/ListItemType.kt | 1 + .../ui/adapter/TypedListSpacingDecoration.kt | 2 +- .../install/ExtensionInstallService.kt | 1 + .../extensions/repo/ExtensionRepoService.kt | 9 +- .../repo/ExternalExtensionRepoRepository.kt | 27 ++++- .../runtime/ExternalExtensionLoaderSupport.kt | 5 - .../runtime/ExternalExtensionManagerFacade.kt | 2 - .../ExternalExtensionManagerRuntime.kt | 2 - .../futon/mihon/model/ContentSource.kt | 1 + .../mihon/model/ContentSourceSerializer.kt | 20 ---- .../futon/mihon/model/QuickFilter.kt | 14 --- .../futon/mihon/model/SortDirection.kt | 6 - .../futon/mihon/model/ZoomMode.kt | 6 - .../model/jsonsource/LegadoBookSource.kt | 109 ------------------ .../futon/mihon/model/jsonsource/README.md | 108 ----------------- .../model/parcelable/ContentSourceParceler.kt | 16 --- .../sources/SourcesSettingsFragment.kt | 5 + .../extension/ExtensionDownloaderActivity.kt | 49 ++++++++ .../extension/ExtensionDownloaderAdapter.kt | 52 +++++++++ .../extension/ExtensionDownloaderViewModel.kt | 108 +++++++++++++++++ .../layout/activity_extension_downloader.xml | 51 ++++++++ app/src/main/res/layout/item_extension.xml | 65 +++++++++++ app/src/main/res/values-ab/arrays.xml | 4 + app/src/main/res/values-ar/arrays.xml | 4 + app/src/main/res/values-arq/arrays.xml | 4 + app/src/main/res/values-arz/arrays.xml | 4 + app/src/main/res/values-as/arrays.xml | 4 + app/src/main/res/values-b+yue+Hant/arrays.xml | 4 + app/src/main/res/values-bci/arrays.xml | 4 + app/src/main/res/values-be/arrays.xml | 4 + app/src/main/res/values-bn/arrays.xml | 4 + app/src/main/res/values-ca/arrays.xml | 4 + app/src/main/res/values-ckb/arrays.xml | 4 + app/src/main/res/values-cs/arrays.xml | 4 + app/src/main/res/values-de/arrays.xml | 4 + app/src/main/res/values-el/arrays.xml | 4 + app/src/main/res/values-en-rGB/arrays.xml | 4 + app/src/main/res/values-enm/arrays.xml | 4 + app/src/main/res/values-es/arrays.xml | 4 + app/src/main/res/values-et/arrays.xml | 4 + app/src/main/res/values-eu/arrays.xml | 4 + app/src/main/res/values-fa/arrays.xml | 4 + app/src/main/res/values-fi/arrays.xml | 4 + app/src/main/res/values-fil/arrays.xml | 4 + app/src/main/res/values-fr/arrays.xml | 4 + app/src/main/res/values-frp/arrays.xml | 4 + app/src/main/res/values-got/arrays.xml | 4 + app/src/main/res/values-gu/arrays.xml | 4 + app/src/main/res/values-hi/arrays.xml | 4 + app/src/main/res/values-hr/arrays.xml | 4 + app/src/main/res/values-hu/arrays.xml | 4 + app/src/main/res/values-in/arrays.xml | 4 + app/src/main/res/values-it/arrays.xml | 4 + app/src/main/res/values-iw/arrays.xml | 4 + app/src/main/res/values-ja/arrays.xml | 4 + app/src/main/res/values-jv/arrays.xml | 4 + app/src/main/res/values-kk/arrays.xml | 4 + app/src/main/res/values-km/arrays.xml | 4 + app/src/main/res/values-ko/arrays.xml | 4 + app/src/main/res/values-ldrtl/arrays.xml | 4 + app/src/main/res/values-lt/arrays.xml | 4 + app/src/main/res/values-lv/arrays.xml | 4 + app/src/main/res/values-lzh/arrays.xml | 4 + app/src/main/res/values-ml/arrays.xml | 4 + app/src/main/res/values-ms/arrays.xml | 4 + app/src/main/res/values-my/arrays.xml | 4 + app/src/main/res/values-nb-rNO/arrays.xml | 4 + app/src/main/res/values-ne/arrays.xml | 4 + app/src/main/res/values-night-v31/arrays.xml | 4 + app/src/main/res/values-night/arrays.xml | 4 + app/src/main/res/values-nl/arrays.xml | 4 + app/src/main/res/values-nn/arrays.xml | 4 + app/src/main/res/values-or/arrays.xml | 4 + app/src/main/res/values-pa-rPK/arrays.xml | 4 + app/src/main/res/values-pa/arrays.xml | 4 + app/src/main/res/values-pl/arrays.xml | 4 + app/src/main/res/values-pt-rBR/arrays.xml | 4 + app/src/main/res/values-pt/arrays.xml | 4 + app/src/main/res/values-ro/arrays.xml | 4 + app/src/main/res/values-ru/arrays.xml | 4 + app/src/main/res/values-si/arrays.xml | 4 + app/src/main/res/values-sr/arrays.xml | 4 + app/src/main/res/values-sv/arrays.xml | 4 + app/src/main/res/values-sw360dp/arrays.xml | 4 + app/src/main/res/values-ta/arrays.xml | 4 + app/src/main/res/values-te/arrays.xml | 4 + app/src/main/res/values-th/arrays.xml | 4 + app/src/main/res/values-tr/arrays.xml | 4 + app/src/main/res/values-uk/arrays.xml | 4 + app/src/main/res/values-v27/arrays.xml | 4 + app/src/main/res/values-v31/arrays.xml | 4 + app/src/main/res/values-v33/arrays.xml | 4 + app/src/main/res/values-vi/arrays.xml | 4 + .../main/res/values-w600dp-land/arrays.xml | 4 + app/src/main/res/values-zh-rCN/arrays.xml | 4 + app/src/main/res/values-zh-rTW/arrays.xml | 4 + app/src/main/res/values/arrays.xml | 10 +- app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/pref_network_storage.xml | 4 +- app/src/main/res/xml/pref_sources.xml | 5 + 107 files changed, 722 insertions(+), 322 deletions(-) delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderActivity.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderAdapter.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderViewModel.kt create mode 100644 app/src/main/res/layout/activity_extension_downloader.xml create mode 100644 app/src/main/res/layout/item_extension.xml create mode 100644 app/src/main/res/values-ab/arrays.xml create mode 100644 app/src/main/res/values-ar/arrays.xml create mode 100644 app/src/main/res/values-arq/arrays.xml create mode 100644 app/src/main/res/values-arz/arrays.xml create mode 100644 app/src/main/res/values-as/arrays.xml create mode 100644 app/src/main/res/values-b+yue+Hant/arrays.xml create mode 100644 app/src/main/res/values-bci/arrays.xml create mode 100644 app/src/main/res/values-be/arrays.xml create mode 100644 app/src/main/res/values-bn/arrays.xml create mode 100644 app/src/main/res/values-ca/arrays.xml create mode 100644 app/src/main/res/values-ckb/arrays.xml create mode 100644 app/src/main/res/values-cs/arrays.xml create mode 100644 app/src/main/res/values-de/arrays.xml create mode 100644 app/src/main/res/values-el/arrays.xml create mode 100644 app/src/main/res/values-en-rGB/arrays.xml create mode 100644 app/src/main/res/values-enm/arrays.xml create mode 100644 app/src/main/res/values-es/arrays.xml create mode 100644 app/src/main/res/values-et/arrays.xml create mode 100644 app/src/main/res/values-eu/arrays.xml create mode 100644 app/src/main/res/values-fa/arrays.xml create mode 100644 app/src/main/res/values-fi/arrays.xml create mode 100644 app/src/main/res/values-fil/arrays.xml create mode 100644 app/src/main/res/values-fr/arrays.xml create mode 100644 app/src/main/res/values-frp/arrays.xml create mode 100644 app/src/main/res/values-got/arrays.xml create mode 100644 app/src/main/res/values-gu/arrays.xml create mode 100644 app/src/main/res/values-hi/arrays.xml create mode 100644 app/src/main/res/values-hr/arrays.xml create mode 100644 app/src/main/res/values-hu/arrays.xml create mode 100644 app/src/main/res/values-in/arrays.xml create mode 100644 app/src/main/res/values-it/arrays.xml create mode 100644 app/src/main/res/values-iw/arrays.xml create mode 100644 app/src/main/res/values-ja/arrays.xml create mode 100644 app/src/main/res/values-jv/arrays.xml create mode 100644 app/src/main/res/values-kk/arrays.xml create mode 100644 app/src/main/res/values-km/arrays.xml create mode 100644 app/src/main/res/values-ko/arrays.xml create mode 100644 app/src/main/res/values-ldrtl/arrays.xml create mode 100644 app/src/main/res/values-lt/arrays.xml create mode 100644 app/src/main/res/values-lv/arrays.xml create mode 100644 app/src/main/res/values-lzh/arrays.xml create mode 100644 app/src/main/res/values-ml/arrays.xml create mode 100644 app/src/main/res/values-ms/arrays.xml create mode 100644 app/src/main/res/values-my/arrays.xml create mode 100644 app/src/main/res/values-nb-rNO/arrays.xml create mode 100644 app/src/main/res/values-ne/arrays.xml create mode 100644 app/src/main/res/values-night-v31/arrays.xml create mode 100644 app/src/main/res/values-night/arrays.xml create mode 100644 app/src/main/res/values-nl/arrays.xml create mode 100644 app/src/main/res/values-nn/arrays.xml create mode 100644 app/src/main/res/values-or/arrays.xml create mode 100644 app/src/main/res/values-pa-rPK/arrays.xml create mode 100644 app/src/main/res/values-pa/arrays.xml create mode 100644 app/src/main/res/values-pl/arrays.xml create mode 100644 app/src/main/res/values-pt-rBR/arrays.xml create mode 100644 app/src/main/res/values-pt/arrays.xml create mode 100644 app/src/main/res/values-ro/arrays.xml create mode 100644 app/src/main/res/values-ru/arrays.xml create mode 100644 app/src/main/res/values-si/arrays.xml create mode 100644 app/src/main/res/values-sr/arrays.xml create mode 100644 app/src/main/res/values-sv/arrays.xml create mode 100644 app/src/main/res/values-sw360dp/arrays.xml create mode 100644 app/src/main/res/values-ta/arrays.xml create mode 100644 app/src/main/res/values-te/arrays.xml create mode 100644 app/src/main/res/values-th/arrays.xml create mode 100644 app/src/main/res/values-tr/arrays.xml create mode 100644 app/src/main/res/values-uk/arrays.xml create mode 100644 app/src/main/res/values-v27/arrays.xml create mode 100644 app/src/main/res/values-v31/arrays.xml create mode 100644 app/src/main/res/values-v33/arrays.xml create mode 100644 app/src/main/res/values-vi/arrays.xml create mode 100644 app/src/main/res/values-w600dp-land/arrays.xml create mode 100644 app/src/main/res/values-zh-rCN/arrays.xml create mode 100644 app/src/main/res/values-zh-rTW/arrays.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2a4d82633b..c3a7cdf49e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -287,6 +287,9 @@ + 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/extensions/install/ExtensionInstallService.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/install/ExtensionInstallService.kt index e0ec5b6abd..8072c84b82 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 @@ -41,6 +41,7 @@ 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") } } return url 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 ae499629ac..63d48c0384 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 @@ -27,10 +27,17 @@ class ExtensionRepoService @Inject constructor( private fun applyMirror(url: String): String { if (url.startsWith("https://raw.githubusercontent.com/")) { return when (settings.gitHubMirror) { - GitHubMirror.NATIVE -> url + GitHubMirror.KEIYOUSHI -> { + if (url.contains("/keiyoushi/extensions/")) { + url.replace("raw.githubusercontent.com", "raw.github.com") + } else { + "https://raw.github.com/keiyoushi/extensions/refs/heads/repo/${url.substringAfter("raw.githubusercontent.com/")}" + } + } GitHubMirror.KKGITHUB -> url.replace("raw.githubusercontent.com", "raw.kkgithub.com") GitHubMirror.GHPROXY -> "https://mirror.ghproxy.com/$url" GitHubMirror.GHPROXY_NET -> "https://ghproxy.net/$url" + else -> url } } return url 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 4ebb2a94f9..903a6bee31 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 @@ -128,10 +128,33 @@ class ExternalExtensionRepoRepository @Inject constructor( } suspend fun getCatalogExtensions(type: ExternalExtensionType): List = coroutineScope { - getByType(type) + 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() - .flatten() + Log.d(TAG, "getCatalogExtensions:fetched count=${results.size} total_extensions=${results.sumOf { it.size }}") + results.flatten() .groupBy { it.pkgName } .map { (_, list) -> list.maxByOrNull { it.versionCode }!! } .sortedWith(compareBy { it.lang }.thenBy { it.name.lowercase() }) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt index cc263531cf..d2b069e3a4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionLoaderSupport.kt @@ -23,11 +23,6 @@ object ExternalExtensionLoaderSupport { packageName.startsWith("io.github.landwarderer.futon.extension.") } - fun looksLikeAniyomiPackage(packageName: String): Boolean { - return packageName.contains(".animeextension") || - packageName.startsWith("eu.kanade.tachiyomi.animeextension.") - } - fun getInstalledPackages(pkgManager: PackageManager): List { return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt index 8d7b7450a0..0cf483596d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerFacade.kt @@ -83,8 +83,6 @@ class ExternalExtensionManagerFacade = installedExtensions.value - fun getCatalogueSources(): List { return installedExtensions.value.flatMap(successCatalogueSources) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt index 8f84221bac..c7478b7427 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/extensions/runtime/ExternalExtensionManagerRuntime.kt @@ -52,8 +52,6 @@ class ExternalExtensionManagerRuntime = installedExtensions.value - fun getSourceById(sourceId: Long): SourceT? = sourceCache[sourceId] fun getWrappedSourceById(sourceId: Long): WrappedSourceT? = wrappedSourceCache[sourceId] diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt index 7e622bea58..ad2d48cf6d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSource.kt @@ -164,6 +164,7 @@ fun ContentSource.getLocale(): Locale? = unwrap().locale.takeIf { it.isNotEmpty( fun ContentSource.getContentType(): ContentType = unwrap().contentType +@RequiresApi(Build.VERSION_CODES.N) fun ContentSource.getSummary(context: Context, contentType: ContentType? = null): String? = when (val source = unwrap()) { is io.github.landwarderer.futon.mihon.model.MihonMangaSource -> { val resolvedContentType = contentType ?: getContentType() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt deleted file mode 100644 index cf3a7d9c71..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ContentSourceSerializer.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.github.landwarderer.futon.mihon.model - -import io.github.landwarderer.futon.mihon.parsers.model.ContentSource -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.serialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -object ContentSourceSerializer : KSerializer { - - override val descriptor: SerialDescriptor = serialDescriptor() - - override fun serialize( - encoder: Encoder, - value: ContentSource - ) = encoder.encodeString(value.name) - - override fun deserialize(decoder: Decoder): ContentSource = contentSource(decoder.decodeString()) -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt deleted file mode 100644 index 17beb93c77..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/QuickFilter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.landwarderer.futon.mihon.model - -import io.github.landwarderer.futon.core.ui.widgets.ChipsView -import io.github.landwarderer.futon.list.domain.ListFilterOption - -fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel( - title = titleText, - titleResId = titleResId, - icon = iconResId, - iconData = getIconData(), - isChecked = isChecked, - counter = if (this is ListFilterOption.Branch) chaptersCount else 0, - data = this, -) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt deleted file mode 100644 index 31c700abf2..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/SortDirection.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.landwarderer.futon.mihon.model - -enum class SortDirection { - - ASC, DESC; -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt deleted file mode 100644 index 3d6d03968c..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/ZoomMode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.landwarderer.futon.mihon.model - -enum class ZoomMode { - - FIT_CENTER, FIT_HEIGHT, FIT_WIDTH, KEEP_START -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt deleted file mode 100644 index 1b80568570..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/LegadoBookSource.kt +++ /dev/null @@ -1,109 +0,0 @@ -package io.github.landwarderer.futon.mihon.model.jsonsource - -import kotlinx.serialization.Serializable - -/** - * Legado book source configuration model - * Represents a complete book source configuration in Legado format - */ -@Serializable -data class LegadoBookSource( - val bookSourceName: String, - val bookSourceUrl: String, - val bookSourceType: Int = 0, // 0=文字, 1=音频, 2=图片 - val bookSourceGroup: String? = null, - val enabled: Boolean = true, - val searchUrl: String? = null, - val exploreUrl: String? = null, - val header: String? = null, // 请求头配置(JSON格式字符串) - val loginUrl: String? = null, // 登录地址 - val loginUi: String? = null, // 登录UI - val loginCheckJs: String? = null, // 登录检测js - val jsLib: String? = null, // js库 - val enabledCookieJar: Boolean? = true, // 启用cookieJar - val concurrentRate: String? = null, // 并发率 - val bookSourceComment: String? = null, // 注释 - val variableComment: String? = null, // 自定义变量说明 - val respondTime: Long = 180000L, // 响应时间 - val weight: Int = 0, // 权重 - val ruleExplore: SearchRule? = null, // 浏览规则 - val ruleSearch: SearchRule? = null, - val ruleBookInfo: BookInfoRule? = null, - val ruleToc: TocRule? = null, - val ruleContent: ContentRule? = null, -) - -/** - * Search rule for parsing search results - */ -@Serializable -data class SearchRule( - val bookList: String? = null, // 列表选择器 - val name: String? = null, // 名称规则 - val author: String? = null, // 作者规则 - val coverUrl: String? = null, // 封面规则 - val bookUrl: String? = null, // 链接规则 - val intro: String? = null, // 简介规则 - val lastChapter: String? = null, // 最新章节规则 - val updateTime: String? = null, // 更新时间规则 - val kind: String? = null, // 分类规则 - val wordCount: String? = null, // 字数规则 - val checkKeyWord: String? = null, // 校验关键字 - val init: String? = null, // 初始化脚本 - val webView: Boolean? = false, // 是否启用 WebView -) - -/** - * Book info rule for parsing book details - */ -@Serializable -data class BookInfoRule( - val name: String? = null, - val author: String? = null, - val coverUrl: String? = null, - val intro: String? = null, - val kind: String? = null, // 分类/标签 - val lastChapter: String? = null, - val updateTime: String? = null, // 更新时间 - val wordCount: String? = null, // 字数 - val tocUrl: String? = null, // 目录页链接规则 - val init: String? = null, // 初始化脚本 - val canReName: String? = null, - val downloadUrls: String? = null, - val webView: Boolean? = false, // 是否启用 WebView -) - -/** - * Table of contents rule for parsing chapter list - */ -@Serializable -data class TocRule( - val chapterList: String? = null, // 章节列表选择器 - val chapterName: String? = null, // 章节名称规则 - val chapterUrl: String? = null, // 章节链接规则 - val nextTocUrl: String? = null, // 下一页目录 - val isVolume: String? = null, // 卷标识 - val isVip: String? = null, // VIP 标识 - val isPay: String? = null, // 付费标识 - val updateTime: String? = null, // 更新时间 - val preUpdateJs: String? = null, // 刷新前JS - val formatJs: String? = null, // 格式化JS - val webView: Boolean? = false, // 是否启用 WebView -) - -/** - * Content rule for parsing chapter content - */ -@Serializable -data class ContentRule( - val content: String? = null, // 内容规则 - val title: String? = null, // 正文页内标题修正 - val nextContentUrl: String? = null,// 正文分页 - val webJs: String? = null, // 网页JS - val sourceRegex: String? = null, // 资源正则 - val replaceRegex: String? = null, // 正文替换 - val imageStyle: String? = null, // 图片样式 - val payAction: String? = null, // 支付操作 - val webView: String? = null, // 是否启用 WebView (Legado 规则中此处可能是字符串 "true" 或 JS 脚本) - val webViewDelayTime: Long? = null, // WebView 延迟时间 -) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md deleted file mode 100644 index 764bafe5f2..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/jsonsource/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# JSON Source Data Models - -This package contains data models for JSON-based source configurations. - -## Models - -### Legado Book Source (`LegadoBookSource.kt`) - -Models for Legado format book sources, which support novels and manga from various websites. - -**Main Model:** -- `LegadoBookSource`: Root configuration containing source metadata and parsing rules - -**Rule Models:** -- `SearchRule`: Defines how to parse search results -- `BookInfoRule`: Defines how to parse book/manga details -- `TocRule`: Defines how to parse table of contents (chapter list) -- `ContentRule`: Defines how to parse chapter content - -**Example JSON:** -```json -{ - "bookSourceName": "Example Source", - "bookSourceUrl": "https://example.com", - "bookSourceType": 0, - "enabled": true, - "ruleSearch": { - "bookList": "div.book-list", - "name": "h2@text", - "author": "span.author@text", - "bookUrl": "a@href" - }, - "ruleToc": { - "chapterList": "div.chapter-list li", - "chapterName": "a@text", - "chapterUrl": "a@href" - }, - "ruleContent": { - "content": "div.content@html" - } -} -``` - -### TVBox Configuration (`TVBoxConfig.kt`) - -Models for TVBox format video site configurations. - -**Main Models:** -- `TVBoxConfig`: Root configuration containing sites and settings -- `TVBoxSite`: Individual video site configuration - -**Supporting Models:** -- `TVBoxLive`: Live stream configuration -- `TVBoxParse`: Video parser configuration -- `TVBoxIjk`: IJK player settings -- `TVBoxIjkOption`: Individual IJK option - -**Example JSON:** -```json -{ - "sites": [ - { - "key": "example", - "name": "Example Video Site", - "type": 1, - "api": "https://example.com/api", - "searchable": 1, - "quickSearch": 1, - "filterable": 1 - } - ] -} -``` - -## Serialization - -All models use `kotlinx.serialization` with the `@Serializable` annotation. They support: - -- JSON deserialization from string -- JSON serialization to string -- Lenient parsing (ignores unknown keys) -- Optional fields with default values - -## Usage - -```kotlin -import kotlinx.serialization.json.Json -import kotlinx.serialization.decodeFromString - -val json = Json { - ignoreUnknownKeys = true - isLenient = true -} - -// Parse Legado source -val legadoSource = json.decodeFromString(jsonString) - -// Parse TVBox config -val tvboxConfig = json.decodeFromString(jsonString) -``` - -## Requirements - -These models satisfy requirement 4.1 from the design document: -- Define data structures for Legado and TVBox configurations -- Support JSON serialization/deserialization -- Provide default values for optional fields -- Handle nested rule structures diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt deleted file mode 100644 index 8939d55ff8..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/parcelable/ContentSourceParceler.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.landwarderer.futon.mihon.model.parcelable - -import android.os.Parcel -import io.github.landwarderer.futon.mihon.model.contentSource -import io.github.landwarderer.futon.mihon.parsers.model.ContentSource -import kotlinx.parcelize.Parceler - -class ContentSourceParceler : Parceler { - - override fun create(parcel: Parcel): ContentSource = contentSource(parcel.readString()) - - override fun ContentSource.write(parcel: Parcel, flags: Int) { - parcel.writeString(name) - } -} - diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt index 5e0b47a3bf..a83b1fbb0c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt @@ -78,6 +78,11 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources), true } + AppSettings.KEY_EXTENSION_DOWNLOADER -> { + router.openExtensionDownloader() + true + } + AppSettings.KEY_HANDLE_LINKS -> { viewModel.setLinksEnabled((preference as TwoStatePreference).isChecked) true 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 new file mode 100644 index 0000000000..8c4207f021 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderActivity.kt @@ -0,0 +1,49 @@ +package io.github.landwarderer.futon.settings.sources.extension + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +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 + +@AndroidEntryPoint +class ExtensionDownloaderActivity : BaseActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityExtensionDownloaderBinding.inflate(layoutInflater)) + + setTitle(R.string.extension_downloader) + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) + + val adapter = ExtensionDownloaderAdapter( + onInstallClick = { viewModel.installExtension(it.available) }, + onCancelClick = { viewModel.cancelDownload(it.available.pkgName) } + ) + + viewBinding.recyclerView.adapter = adapter + + viewModel.state.observe(this) { state -> + viewBinding.loadingState.root.isVisible = state.isLoading && state.items.isEmpty() + adapter.items = state.items + } + + viewModel.intentAction.observeEvent(this) { intent -> + startActivity(intent) + } + } + + override fun onApplyWindowInsets(v: android.view.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 new file mode 100644 index 0000000000..3b81b4ca34 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderAdapter.kt @@ -0,0 +1,52 @@ +package io.github.landwarderer.futon.settings.sources.extension + +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.list.ui.adapter.ListItemType +import io.github.landwarderer.futon.list.ui.model.ListModel + +class ExtensionDownloaderAdapter( + onInstallClick: (ExtensionItem) -> Unit, + onCancelClick: (ExtensionItem) -> Unit, +) : BaseListAdapter() { + + init { + addDelegate(ListItemType.EXTENSION, extensionItemAD(onInstallClick, onCancelClick)) + } +} + +private fun extensionItemAD( + onInstallClick: (ExtensionItem) -> Unit, + onCancelClick: (ExtensionItem) -> Unit, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExtensionBinding.inflate(layoutInflater, parent, false) } +) { + binding.buttonAction.setOnClickListener { + if (item.downloadState != null) { + onCancelClick(item) + } else { + onInstallClick(item) + } + } + + bind { + binding.textViewTitle.text = item.available.name + binding.textViewVersion.text = item.available.versionName + binding.imageViewIcon.setImageAsync(item.available.iconUrl) + + val downloadState = item.downloadState + if (downloadState != null) { + binding.buttonAction.text = context.getString(android.R.string.cancel) + // progress can be added here if needed + } else { + binding.buttonAction.text = when { + item.hasUpdate -> context.getString(R.string.update) + item.isInstalled -> context.getString(R.string.installed) + else -> context.getString(R.string.install) + } + binding.buttonAction.isEnabled = !item.isInstalled || item.hasUpdate + } + } +} 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 new file mode 100644 index 0000000000..e9b3411be3 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/extension/ExtensionDownloaderViewModel.kt @@ -0,0 +1,108 @@ +package io.github.landwarderer.futon.settings.sources.extension + +import android.util.Log +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +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.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.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 repoRepository: ExternalExtensionRepoRepository, + private val extensionManager: MihonExtensionManager, + private val installService: ExtensionInstallService, +) : BaseViewModel() { + + private val refreshing = MutableStateFlow(false) + private val catalogExtensions = MutableStateFlow>(emptyList()) + + private val _intentAction = MutableEventFlow() + val intentAction = _intentAction + + init { + viewModelScope.launch { + Log.d("ExtensionDownloaderViewModel", "fetching extensions") + catalogExtensions.value = repoRepository.getCatalogExtensions(ExternalExtensionType.MIHON) + } + refresh() + } + + val state: StateFlow = combine( + catalogExtensions, + extensionManager.installedExtensions, + installService.downloadStates, + refreshing + ) { available, installed, downloads, isRefreshing -> + val items = available.map { extension -> + val installedExtension = installed.find { it.pkgName == extension.pkgName } + ExtensionItem( + available = extension, + installed = installedExtension, + downloadState = downloads[extension.pkgName] + ) + } + ExtensionDownloaderState( + items = items, + isLoading = isRefreshing + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, ExtensionDownloaderState()) + + fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + refreshing.value = true + try { + repoRepository.refresh(ExternalExtensionType.MIHON) + catalogExtensions.value = repoRepository.getCatalogExtensions(ExternalExtensionType.MIHON) + } finally { + refreshing.value = false + } + } + } + + fun installExtension(extension: RepoAvailableExtension) { + viewModelScope.launch { + val intent = installService.createInstallIntent(extension) + if (intent != null) { + _intentAction.call(intent) + } + } + } + + fun cancelDownload(pkgName: String) { + installService.cancelDownload(pkgName) + } +} + +data class ExtensionDownloaderState( + val items: List = emptyList(), + val isLoading: Boolean = false, +) + +data class ExtensionItem( + val available: RepoAvailableExtension, + val installed: MihonLoadResult.Success?, + val downloadState: ExtensionInstallDownloadState?, +) : ListModel { + override fun areItemsTheSame(other: ListModel): Boolean { + return other is ExtensionItem && available.pkgName == other.available.pkgName + } + val isInstalled: Boolean get() = installed != null + val hasUpdate: Boolean get() = installed != null && available.versionCode > installed.versionCode +} diff --git a/app/src/main/res/layout/activity_extension_downloader.xml b/app/src/main/res/layout/activity_extension_downloader.xml new file mode 100644 index 0000000000..8992102e2a --- /dev/null +++ b/app/src/main/res/layout/activity_extension_downloader.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_extension.xml b/app/src/main/res/layout/item_extension.xml new file mode 100644 index 0000000000..bb7e4bc789 --- /dev/null +++ b/app/src/main/res/layout/item_extension.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-ab/arrays.xml b/app/src/main/res/values-ab/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ab/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ar/arrays.xml b/app/src/main/res/values-ar/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ar/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-arq/arrays.xml b/app/src/main/res/values-arq/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-arq/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-arz/arrays.xml b/app/src/main/res/values-arz/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-arz/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-as/arrays.xml b/app/src/main/res/values-as/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-as/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-b+yue+Hant/arrays.xml b/app/src/main/res/values-b+yue+Hant/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-b+yue+Hant/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-bci/arrays.xml b/app/src/main/res/values-bci/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-bci/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-be/arrays.xml b/app/src/main/res/values-be/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-be/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-bn/arrays.xml b/app/src/main/res/values-bn/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-bn/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ca/arrays.xml b/app/src/main/res/values-ca/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ca/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ckb/arrays.xml b/app/src/main/res/values-ckb/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ckb/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-cs/arrays.xml b/app/src/main/res/values-cs/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-cs/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-de/arrays.xml b/app/src/main/res/values-de/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-de/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-el/arrays.xml b/app/src/main/res/values-el/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-el/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-en-rGB/arrays.xml b/app/src/main/res/values-en-rGB/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-en-rGB/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-enm/arrays.xml b/app/src/main/res/values-enm/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-enm/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-es/arrays.xml b/app/src/main/res/values-es/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-es/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-et/arrays.xml b/app/src/main/res/values-et/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-et/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-eu/arrays.xml b/app/src/main/res/values-eu/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-eu/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-fa/arrays.xml b/app/src/main/res/values-fa/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-fa/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-fi/arrays.xml b/app/src/main/res/values-fi/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-fi/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-fil/arrays.xml b/app/src/main/res/values-fil/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-fil/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-fr/arrays.xml b/app/src/main/res/values-fr/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-fr/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-frp/arrays.xml b/app/src/main/res/values-frp/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-frp/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-got/arrays.xml b/app/src/main/res/values-got/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-got/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-gu/arrays.xml b/app/src/main/res/values-gu/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-gu/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-hi/arrays.xml b/app/src/main/res/values-hi/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-hi/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-hr/arrays.xml b/app/src/main/res/values-hr/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-hr/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-hu/arrays.xml b/app/src/main/res/values-hu/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-hu/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-in/arrays.xml b/app/src/main/res/values-in/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-in/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-it/arrays.xml b/app/src/main/res/values-it/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-it/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-iw/arrays.xml b/app/src/main/res/values-iw/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-iw/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ja/arrays.xml b/app/src/main/res/values-ja/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ja/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-jv/arrays.xml b/app/src/main/res/values-jv/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-jv/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-kk/arrays.xml b/app/src/main/res/values-kk/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-kk/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-km/arrays.xml b/app/src/main/res/values-km/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-km/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ko/arrays.xml b/app/src/main/res/values-ko/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ko/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ldrtl/arrays.xml b/app/src/main/res/values-ldrtl/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ldrtl/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-lt/arrays.xml b/app/src/main/res/values-lt/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-lt/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-lv/arrays.xml b/app/src/main/res/values-lv/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-lv/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-lzh/arrays.xml b/app/src/main/res/values-lzh/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-lzh/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ml/arrays.xml b/app/src/main/res/values-ml/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ml/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ms/arrays.xml b/app/src/main/res/values-ms/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ms/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-my/arrays.xml b/app/src/main/res/values-my/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-my/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-nb-rNO/arrays.xml b/app/src/main/res/values-nb-rNO/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-nb-rNO/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ne/arrays.xml b/app/src/main/res/values-ne/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ne/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-night-v31/arrays.xml b/app/src/main/res/values-night-v31/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-night-v31/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-night/arrays.xml b/app/src/main/res/values-night/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-night/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-nl/arrays.xml b/app/src/main/res/values-nl/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-nl/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-nn/arrays.xml b/app/src/main/res/values-nn/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-nn/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-or/arrays.xml b/app/src/main/res/values-or/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-or/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-pa-rPK/arrays.xml b/app/src/main/res/values-pa-rPK/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-pa-rPK/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-pa/arrays.xml b/app/src/main/res/values-pa/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-pa/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-pl/arrays.xml b/app/src/main/res/values-pl/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-pl/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-pt-rBR/arrays.xml b/app/src/main/res/values-pt-rBR/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-pt/arrays.xml b/app/src/main/res/values-pt/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-pt/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ro/arrays.xml b/app/src/main/res/values-ro/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ro/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ru/arrays.xml b/app/src/main/res/values-ru/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ru/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-si/arrays.xml b/app/src/main/res/values-si/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-si/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-sr/arrays.xml b/app/src/main/res/values-sr/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-sr/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-sv/arrays.xml b/app/src/main/res/values-sv/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-sv/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-sw360dp/arrays.xml b/app/src/main/res/values-sw360dp/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-sw360dp/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-ta/arrays.xml b/app/src/main/res/values-ta/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-ta/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-te/arrays.xml b/app/src/main/res/values-te/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-te/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-th/arrays.xml b/app/src/main/res/values-th/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-th/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-tr/arrays.xml b/app/src/main/res/values-tr/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-tr/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-uk/arrays.xml b/app/src/main/res/values-uk/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-uk/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-v27/arrays.xml b/app/src/main/res/values-v27/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-v27/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-v31/arrays.xml b/app/src/main/res/values-v31/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-v31/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-v33/arrays.xml b/app/src/main/res/values-v33/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-v33/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-vi/arrays.xml b/app/src/main/res/values-vi/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-vi/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-w600dp-land/arrays.xml b/app/src/main/res/values-w600dp-land/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-w600dp-land/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-zh-rCN/arrays.xml b/app/src/main/res/values-zh-rCN/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values-zh-rTW/arrays.xml b/app/src/main/res/values-zh-rTW/arrays.xml new file mode 100644 index 0000000000..1cbcf80830 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/arrays.xml @@ -0,0 +1,4 @@ + + + Keiyoushi + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 68b6b5e8e8..386b1fad96 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -145,15 +145,9 @@ @string/disable - @string/github_mirror_native - @string/github_mirror_kkgithub - @string/github_mirror_ghproxy - @string/github_mirror_ghproxy_net + @string/github_mirror_keiyoushi - NATIVE - KKGITHUB - GHPROXY - GHPROXY_NET + KEIYOUSHI diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5193e08b2..021f17fad2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -518,6 +518,7 @@ Other %1$s, %2$s Sources catalog + Extension downloader 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 @@ -910,6 +911,7 @@ Send anonymous crash logs to help improve the app. Takes effect after restart. GitHub mirror Native + Keiyoushi KKGitHub (raw.kkgithub.com) GHProxy (mirror.ghproxy.com) GHProxy.net (ghproxy.net) @@ -917,4 +919,6 @@ Manga source domain Whole manga recommended manga + Installed + Install diff --git a/app/src/main/res/xml/pref_network_storage.xml b/app/src/main/res/xml/pref_network_storage.xml index ecc2bec2c6..6c22f07daf 100644 --- a/app/src/main/res/xml/pref_network_storage.xml +++ b/app/src/main/res/xml/pref_network_storage.xml @@ -47,12 +47,12 @@ app:useSimpleSummaryProvider="true" /> + app:useSimpleSummaryProvider="true"/> + + Date: Wed, 22 Apr 2026 10:29:37 -0300 Subject: [PATCH 06/13] feat: hide button that opens extension downloader as it, will be enabled later --- app/src/main/res/xml/pref_sources.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/xml/pref_sources.xml b/app/src/main/res/xml/pref_sources.xml index 99195edb44..753acd1fa5 100644 --- a/app/src/main/res/xml/pref_sources.xml +++ b/app/src/main/res/xml/pref_sources.xml @@ -32,10 +32,12 @@ android:persistent="false" android:title="@string/sources_catalog" /> + + android:title="@string/extension_downloader" + app:isPreferenceVisible="false"/> Date: Thu, 23 Apr 2026 15:36:20 -0300 Subject: [PATCH 07/13] feat: add install and uninstall functionality for extensions --- app/src/main/AndroidManifest.xml | 3 +- .../install/ExtensionInstallService.kt | 14 ++++-- .../extension/ExtensionDownloaderActivity.kt | 5 +- .../extension/ExtensionDownloaderAdapter.kt | 47 ++++++++++++++++--- .../extension/ExtensionDownloaderViewModel.kt | 5 ++ app/src/main/res/layout/item_extension.xml | 21 ++++++++- app/src/main/res/values/strings.xml | 3 +- app/src/main/res/xml/pref_sources.xml | 4 +- 8 files changed, 84 insertions(+), 18 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3a7cdf49e..7651f0d9c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,7 @@ + + android:label="@string/extensions_manager" /> > = _downloadStates.asStateFlow() - suspend fun createInstallIntent(extension: RepoAvailableExtension): Intent? { + suspend fun createInstallIntent(extension: RepoAvailableExtension): Intent? = withContext(Dispatchers.IO) { val apkUrl = applyMirror("${extension.repoUrl}/apk/${extension.apkName}") val outputDir = File(context.cacheDir, "extension-installs").apply { mkdirs() } val outputFile = File(outputDir, "${extension.pkgName}-${extension.versionCode}.apk") @@ -89,11 +91,11 @@ class ExtensionInstallService @Inject constructor( .edit { putLong(extension.pkgName, extension.versionCode) } - return null + return@withContext null } val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", outputFile) - return Intent(Intent.ACTION_VIEW).apply { + Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) @@ -101,6 +103,12 @@ class ExtensionInstallService @Inject constructor( } } + fun getUninstallIntent(packageName: String): Intent { + return Intent(Intent.ACTION_DELETE).apply { + data = android.net.Uri.fromParts("package", packageName, null) + } + } + fun cancelDownload(packageName: String) { activeCalls[packageName]?.cancel() } 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 8c4207f021..8618a1c41d 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 @@ -21,12 +21,13 @@ class ExtensionDownloaderActivity : BaseActivity Unit, onCancelClick: (ExtensionItem) -> Unit, + onUninstallClick: (ExtensionItem) -> Unit, ) : BaseListAdapter() { init { - addDelegate(ListItemType.EXTENSION, extensionItemAD(onInstallClick, onCancelClick)) + addDelegate(ListItemType.EXTENSION, extensionItemAD(onInstallClick, onCancelClick, onUninstallClick)) } } private fun extensionItemAD( onInstallClick: (ExtensionItem) -> Unit, onCancelClick: (ExtensionItem) -> Unit, + onUninstallClick: (ExtensionItem) -> Unit, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemExtensionBinding.inflate(layoutInflater, parent, false) } ) { @@ -31,6 +34,19 @@ private fun extensionItemAD( } } + binding.buttonUninstall.setOnClickListener { + onUninstallClick(item) + } + + binding.root.setOnLongClickListener { + if (item.isInstalled) { + onUninstallClick(item) + true + } else { + false + } + } + bind { binding.textViewTitle.text = item.available.name binding.textViewVersion.text = item.available.versionName @@ -39,14 +55,31 @@ private fun extensionItemAD( val downloadState = item.downloadState if (downloadState != null) { binding.buttonAction.text = context.getString(android.R.string.cancel) - // progress can be added here if needed + binding.buttonAction.isVisible = true + binding.buttonAction.isEnabled = true + binding.buttonUninstall.isVisible = false + + binding.progressBar.isVisible = true + val progress = downloadState.progressPercent + if (progress != null) { + binding.progressBar.isIndeterminate = false + binding.progressBar.progress = progress + } else { + binding.progressBar.isIndeterminate = true + } } else { - binding.buttonAction.text = when { - item.hasUpdate -> context.getString(R.string.update) - item.isInstalled -> context.getString(R.string.installed) - else -> context.getString(R.string.install) + binding.progressBar.isVisible = false + + val hasUpdate = item.hasUpdate + val isInstalled = item.isInstalled + + binding.buttonAction.isVisible = !isInstalled || hasUpdate + if (binding.buttonAction.isVisible) { + binding.buttonAction.text = if (hasUpdate) context.getString(R.string.update) else context.getString(R.string.install) + binding.buttonAction.isEnabled = true } - binding.buttonAction.isEnabled = !item.isInstalled || item.hasUpdate + + binding.buttonUninstall.isVisible = isInstalled } } } 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 e9b3411be3..534a1f593f 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 @@ -85,6 +85,11 @@ class ExtensionDownloaderViewModel @Inject constructor( } } + fun uninstallExtension(pkgName: String) { + val intent = installService.getUninstallIntent(pkgName) + _intentAction.call(intent) + } + fun cancelDownload(pkgName: String) { installService.cancelDownload(pkgName) } diff --git a/app/src/main/res/layout/item_extension.xml b/app/src/main/res/layout/item_extension.xml index bb7e4bc789..55f40b471f 100644 --- a/app/src/main/res/layout/item_extension.xml +++ b/app/src/main/res/layout/item_extension.xml @@ -46,6 +46,15 @@ android:textAppearance="?attr/textAppearanceBodySmall" tools:text="1.4.1" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 021f17fad2..dd48d1a16e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -518,7 +518,7 @@ Other %1$s, %2$s Sources catalog - Extension downloader + Extensions manager 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 @@ -921,4 +921,5 @@ recommended manga Installed Install + Uninstall diff --git a/app/src/main/res/xml/pref_sources.xml b/app/src/main/res/xml/pref_sources.xml index 753acd1fa5..d9c2890d03 100644 --- a/app/src/main/res/xml/pref_sources.xml +++ b/app/src/main/res/xml/pref_sources.xml @@ -32,12 +32,10 @@ android:persistent="false" android:title="@string/sources_catalog" /> - + android:title="@string/extensions_manager" /> Date: Fri, 24 Apr 2026 14:52:09 +0530 Subject: [PATCH 08/13] Update gradle.properties --- gradle.properties | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/gradle.properties b/gradle.properties index 351d190f58..9e44c8cfca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,11 +7,13 @@ # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # # When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true -#Sat Sep 19 17:19:33 EEST 2020 +#Thu Mar 26 21:41:29 IST 2026 android.enableJetifier=false +android.enableR8.fullMode=true +android.nonFinalResIds=true android.nonTransitiveRClass=true android.useAndroidX=true kotlin.code.style=official @@ -21,3 +23,5 @@ android.nonFinalResIds=false org.gradle.parallel=true org.gradle.workers.max=8 org.gradle.caching=true +org.gradle.configuration-cache=true +kotlin.incremental=true From 417cd28aafe20cffb062f77180513103338084c7 Mon Sep 17 00:00:00 2001 From: Land Date: Fri, 24 Apr 2026 14:53:35 +0530 Subject: [PATCH 09/13] fix: corrected case mismatch base_url was converted to the correct case baseUrl --- .../landwarderer/futon/core/db/DatabasePrePopulateCallback.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4ae51f0425..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 @@ -24,7 +24,7 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba val now = System.currentTimeMillis() db.execSQL( - "INSERT INTO external_extension_repos (type, base_url, name, short_name, website, signing_key_fingerprint, created_at, updated_at, last_success_at) VALUES (?,?,?,?,?,?,?,?,?)", + "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", From 2f65f71e174af92bb63548fff01b6fdec9214ec2 Mon Sep 17 00:00:00 2001 From: Land Date: Fri, 24 Apr 2026 14:52:38 +0530 Subject: [PATCH 10/13] fix: fixed explore tab crashing on open fixes sources(tachiyomi) returning null. MangaSource.getSummary(context) returned null for MihonMangaSource types --- .../futon/core/model/MangaSource.kt | 22 +++++++++++++++++++ .../ui/adapter/ExploreAdapterDelegates.kt | 7 ++++-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 75d77d42a4..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 @@ -15,6 +15,7 @@ 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 @@ -97,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 } 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 From 84c6ee5b9adad59d8b6c79c2986c43e1166292b6 Mon Sep 17 00:00:00 2001 From: Land Date: Fri, 24 Apr 2026 14:54:51 +0530 Subject: [PATCH 11/13] fix: Fetch Tachiyomi source icons Replace the previous white placeholder for MihonMangaRepository with a new fetchMihonIcon method that loads the extension's application icon via PackageManager. --- .../core/parser/favicon/FaviconFetcher.kt | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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 9ba94a2e86..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 @@ -66,11 +66,7 @@ class FaviconFetcher( ) is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options) - is MihonMangaRepository -> ImageFetchResult( - image = ColorImage(Color.WHITE), - isSampled = false, - dataSource = DataSource.MEMORY, - ) + is MihonMangaRepository -> fetchMihonIcon(repo) else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}") } @@ -131,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 -> { From 05d8150d666c660c36838da5dea07c7b24adbccc Mon Sep 17 00:00:00 2001 From: Land Date: Mon, 27 Apr 2026 16:37:40 +0530 Subject: [PATCH 12/13] feat: add search functionality for extensions in Extension Downloader --- .../extension/ExtensionDownloaderActivity.kt | 41 +++++++++++++++++++ .../extension/ExtensionDownloaderViewModel.kt | 17 ++++++-- app/src/main/res/menu/opt_extensions.xml | 13 ++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/menu/opt_extensions.xml 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 8618a1c41d..0455beab92 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,7 +1,12 @@ package io.github.landwarderer.futon.settings.sources.extension import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem 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 @@ -40,6 +45,8 @@ class ExtensionDownloaderActivity : BaseActivity startActivity(intent) } + + addMenuProvider(ExtensionManagerMenuProvider()) } override fun onApplyWindowInsets(v: android.view.View, insets: WindowInsetsCompat): WindowInsetsCompat { @@ -47,4 +54,38 @@ class ExtensionDownloaderActivity : BaseActivity>(emptyList()) + private val searchQuery = MutableStateFlow("") private val _intentAction = MutableEventFlow() val intentAction = _intentAction @@ -48,9 +49,15 @@ class ExtensionDownloaderViewModel @Inject constructor( catalogExtensions, extensionManager.installedExtensions, installService.downloadStates, - refreshing - ) { available, installed, downloads, isRefreshing -> - val items = available.map { extension -> + refreshing, + searchQuery + ) { available, installed, downloads, isRefreshing, query -> + val filteredExtensions = if (query.isNotEmpty()) { + available.filter { it.name.contains(query, ignoreCase = true) } + } else { + available + } + val items = filteredExtensions.map { extension -> val installedExtension = installed.find { it.pkgName == extension.pkgName } ExtensionItem( available = extension, @@ -93,6 +100,10 @@ class ExtensionDownloaderViewModel @Inject constructor( fun cancelDownload(pkgName: String) { installService.cancelDownload(pkgName) } + + fun performSearch(query: String?) { + searchQuery.value = query?.trim().orEmpty() + } } data class ExtensionDownloaderState( diff --git a/app/src/main/res/menu/opt_extensions.xml b/app/src/main/res/menu/opt_extensions.xml new file mode 100644 index 0000000000..f124562a2d --- /dev/null +++ b/app/src/main/res/menu/opt_extensions.xml @@ -0,0 +1,13 @@ + + + + + + From 4897348071ade2a699f4ba8bb3c3a327b46477eb Mon Sep 17 00:00:00 2001 From: Land Date: Mon, 27 Apr 2026 16:38:00 +0530 Subject: [PATCH 13/13] feat: add summary string for extensions manager in preferences --- app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_sources.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd48d1a16e..5652640b33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -519,6 +519,7 @@ %1$s, %2$s Sources catalog Extensions manager + Manage, install and uninstall Tachiyomi extensions from keiyoushi repository 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/main/res/xml/pref_sources.xml b/app/src/main/res/xml/pref_sources.xml index d9c2890d03..cf10f51fa7 100644 --- a/app/src/main/res/xml/pref_sources.xml +++ b/app/src/main/res/xml/pref_sources.xml @@ -35,6 +35,7 @@