From 48f07656f3157c5ccdae2fdb387a1e61f6e69471 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 19 Aug 2026 01:50:41 +0700 Subject: [PATCH 01/16] Improve plugins manager / flow, allow to sideload them Co-authored-by: sang765 Co-authored-by: dragonx943 --- .../org/draken/usagi/core/ui/BaseService.kt | 3 +- .../kotlin/org/draken/usagi/core/AppModule.kt | 69 +++- .../kotlin/org/draken/usagi/core/BaseApp.kt | 21 +- .../usagi/core/db/dao/MangaSourcesDao.kt | 48 ++- .../usagi/core/image/ExternalSourceFetcher.kt | 2 +- .../draken/usagi/core/model/MangaSource.kt | 125 +++++- .../org/draken/usagi/core/nav/AppRouter.kt | 13 + .../core/parser/MangaDynamicRepository.kt | 2 +- .../usagi/core/parser/MangaRepository.kt | 21 +- .../core/parser/favicon/FaviconFetcher.kt | 41 +- .../tachiyomi/ExternalMangaRepository.kt | 109 +++-- .../draken/usagi/details/ui/ChaptersMapper.kt | 2 +- .../explore/data/MangaSourcesRepository.kt | 86 ++-- .../usagi/explore/ui/ExploreViewModel.kt | 11 +- .../usagi/list/domain/ListFilterOption.kt | 2 +- .../org/draken/usagi/main/ui/MainActivity.kt | 14 +- .../usagi/search/ui/MangaListActivity.kt | 8 +- .../usagi/settings/RootSettingsFragment.kt | 12 +- .../usagi/settings/RootSettingsViewModel.kt | 13 +- .../settings/sources/SourceSettingsExt.kt | 6 +- .../sources/SourceSettingsFragment.kt | 7 +- .../sources/SourceSettingsViewModel.kt | 9 +- .../sources/SourcesSettingsFragment.kt | 41 +- .../sources/SourcesSettingsViewModel.kt | 37 +- .../adapter/SourceConfigAdapterDelegates.kt | 6 +- .../sources/catalog/SourceCatalogItem.kt | 148 ++++++- .../sources/catalog/SourceCatalogItemAD.kt | 122 ++++-- .../sources/catalog/SourcesCatalogActivity.kt | 274 +++++++++++-- .../sources/catalog/SourcesCatalogAdapter.kt | 25 +- .../sources/catalog/SourcesCatalogFilter.kt | 5 + .../catalog/SourcesCatalogViewModel.kt | 376 +++++++++++++++--- .../sources/manage/SourcesListProducer.kt | 14 +- .../sources/manage/SourcesManageFragment.kt | 21 +- .../sources/manage/SourcesManageViewModel.kt | 12 +- .../manage/plugins/PluginManageAdapter.kt | 241 ++++++++++- .../manage/plugins/PluginsManageFragment.kt | 96 +++-- .../manage/plugins/PluginsManageViewModel.kt | 296 ++++++++++---- .../manage/plugins/UpdatePluginsProvider.kt | 92 ++++- .../manage/plugins/model/PluginManageItem.kt | 60 +++ .../sources/model/SourceConfigItem.kt | 1 + .../res/layout/fragment_settings_sources.xml | 4 +- .../main/res/layout/item_source_catalog.xml | 2 + .../org/draken/usagi/core/ui/BaseService.kt | 3 +- gradle/libs.versions.toml | 2 +- 44 files changed, 2061 insertions(+), 441 deletions(-) diff --git a/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt b/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt index e68223c9..683025b8 100644 --- a/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt +++ b/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt @@ -7,7 +7,8 @@ import leakcanary.AppWatcher abstract class BaseService : LifecycleService() { override fun attachBaseContext(newBase: Context) { - super.attachBaseContext(ContextCompat.getContextForLanguage(newBase)) + val applicationBase = newBase.applicationContext ?: newBase + super.attachBaseContext(ContextCompat.getContextForLanguage(applicationBase)) } override fun onDestroy() { diff --git a/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt b/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt index 7c392535..3ba0a79b 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt @@ -30,6 +30,10 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import okhttp3.OkHttpClient +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider +import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager +import org.draken.tsukimix.core.parser.tachiyomi.RuntimeInitializer +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga import org.draken.usagi.BuildConfig import org.draken.usagi.backups.domain.BackupObserver import org.draken.usagi.core.db.MangaDatabase @@ -38,6 +42,9 @@ import org.draken.usagi.core.image.AvifImageDecoder import org.draken.usagi.core.image.CbzFetcher import org.draken.usagi.core.image.ExternalSourceFetcher import org.draken.usagi.core.image.MangaSourceHeaderInterceptor +import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata +import org.draken.usagi.core.model.MangaSourceRegistry +import org.draken.usagi.core.network.BaseHttpClient import org.draken.usagi.core.network.MangaHttpClient import org.draken.usagi.core.network.imageproxy.ImageProxyInterceptor import org.draken.usagi.core.network.webview.WebViewExecutor @@ -69,9 +76,10 @@ import tsuki.MangaLoaderContext import tsuki.network.UserAgents import javax.inject.Provider import javax.inject.Singleton -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionLoader as Loader -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as Manager -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiInjektBridge as Bridge +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionBridge as Bridge +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionLoader as Loader +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionManager as Manager +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime as Runtime @Module @InstallIn(SingletonComponent::class) @@ -265,5 +273,60 @@ interface AppModule { @ApplicationContext context: Context, loader: Loader, ): Manager = Manager(context, loader) + + @Provides + @Singleton + fun provideNativeExtManager( + @ApplicationContext context: Context, + @BaseHttpClient httpClient: OkHttpClient, + injektBridge: Bridge, + ): NativeExtManager = NativeExtManager(context, httpClient, injektBridge) + + @Provides + @Singleton + fun provideExtensionProvider( + @ApplicationContext context: Context, + @BaseHttpClient httpClient: OkHttpClient, + ): ExtensionProvider = ExtensionProvider(context, httpClient) + + @Provides + @Singleton + fun provideTachiyomiRuntime( + installedManager: Manager, + directManager: NativeExtManager, + catalogProvider: ExtensionProvider, + database: MangaDatabase, + settings: AppSettings, + ): Runtime = + Runtime( + installedManager = installedManager, + directManager = directManager, + disabledSourceProvider = + { + if (settings.isAllSourcesEnabled) { + emptySet() + } else { + database + .getSourcesDao() + .findAll() + .asSequence() + .filterNot { it.isEnabled } + .map { it.source } + .toSet() + } + }, + sourcesPublisher = + { sources -> + DirectTachiyomiPluginMetadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } + val nonTachiyomi = MangaSourceRegistry.sources.filterNot { it is Manga } + MangaSourceRegistry.publish(nonTachiyomi + sources) + }, + ) + + @Provides + @Singleton + fun providePluginRuntimeInitializer( + tachiyomiRuntime: Provider, + ): RuntimeInitializer = RuntimeInitializer { tachiyomiRuntime.get() } } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt b/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt index 9678a0d8..ead4ae18 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt @@ -19,7 +19,6 @@ import okhttp3.internal.platform.PlatformRegistry import org.conscrypt.Conscrypt import org.draken.usagi.BuildConfig import org.draken.usagi.core.db.MangaDatabase -import org.draken.usagi.core.model.MangaSourceRegistry import org.draken.usagi.core.model.PluginKeyResolver import org.draken.usagi.core.os.AppValidator import org.draken.usagi.core.parser.MangaDynamicRepository @@ -34,8 +33,6 @@ import org.draken.usagi.settings.work.WorkScheduleManager import java.security.Security import javax.inject.Inject import javax.inject.Provider -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as External open class BaseApp : Application(), @@ -73,9 +70,6 @@ open class BaseApp : @Inject lateinit var pluginKeyResolver: PluginKeyResolver - @Inject - lateinit var externalManager: ExternalManager - @Inject @LocalStorageChanges lateinit var localStorageChanges: MutableSharedFlow @@ -104,19 +98,14 @@ open class BaseApp : launch { localStorageChanges.collect(localMangaIndexProvider.get()) } - launch { - externalManager.sources.collect { - val e = MangaSourceRegistry.sources.filterNot { it is External } - MangaSourceRegistry.publish(e + externalManager.getActiveSources()) + runCatching { + mangaDynamicRepository.load(mangaDynamicRepository.getDir()) + withContext(Dispatchers.Default) { + pluginKeyResolver.normalize(database.get(), savedFiltersRepository) } } - mangaDynamicRepository.load(mangaDynamicRepository.getDir()) - externalManager.ensureReady() - withContext(Dispatchers.Default) { - pluginKeyResolver.normalize(database.get(), savedFiltersRepository) - } + workScheduleManager.init() } - workScheduleManager.init() } override fun attachBaseContext(base: Context) { diff --git a/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt index 3dbf90f6..f1193b2e 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt @@ -59,12 +59,6 @@ abstract class MangaSourcesDao { value: Long, ) - @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source") - abstract suspend fun setPinned( - source: String, - isPinned: Boolean, - ) - @Query("UPDATE sources SET cf_state = :state WHERE source = :source") abstract suspend fun setCfState( source: String, @@ -136,17 +130,17 @@ abstract class MangaSourcesDao { isEnabled: Boolean, ) { if (updateIsEnabled(source, isEnabled) == 0) { - val entity = - MangaSourceEntity( - source = source, - isEnabled = isEnabled, - sortKey = getMaxSortKey() + 1, - addedIn = BuildConfig.VERSION_CODE, - lastUsedAt = 0, - isPinned = false, - cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED, - ) - upsert(entity) + upsert(newSourceEntity(source, isEnabled = isEnabled)) + } + } + + @Transaction + open suspend fun setPinned( + source: String, + isPinned: Boolean, + ) { + if (updateIsPinned(source, isPinned) == 0) { + upsert(newSourceEntity(source, isPinned = isPinned)) } } @@ -170,6 +164,26 @@ abstract class MangaSourcesDao { isEnabled: Boolean, ): Int + @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source") + protected abstract suspend fun updateIsPinned( + source: String, + isPinned: Boolean, + ): Int + + private suspend fun newSourceEntity( + source: String, + isEnabled: Boolean = true, + isPinned: Boolean = false, + ) = MangaSourceEntity( + source = source, + isEnabled = isEnabled, + sortKey = getMaxSortKey() + 1, + addedIn = BuildConfig.VERSION_CODE, + lastUsedAt = 0, + isPinned = isPinned, + cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED, + ) + @RawQuery(observedEntities = [MangaSourceEntity::class]) protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow> diff --git a/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt b/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt index e4be672d..43064818 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt @@ -15,7 +15,7 @@ import okhttp3.Request import org.draken.usagi.core.model.unwrap import org.draken.usagi.core.util.ext.mangaSourceKey import coil3.Uri as CoilUri -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as External +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as External class ExternalSourceFetcher( private val httpSource: HttpSource, diff --git a/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt b/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt index b2ba7cdf..9a1f61ec 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt @@ -9,6 +9,8 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.text.inSpans +import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager +import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled import org.draken.usagi.R import org.draken.usagi.core.parser.external.ExternalMangaSource import org.draken.usagi.core.util.ext.getDisplayName @@ -17,9 +19,9 @@ import org.draken.usagi.core.util.ext.toLocaleOrNull import tsuki.model.ContentType import tsuki.model.MangaSource import tsuki.util.splitTwoParts +import java.net.URI import java.util.Locale -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as ExternalSource +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource data class PluginMangaSource( val delegate: MangaSource, @@ -44,6 +46,63 @@ data class PluginMangaSource( get() = delegate.isBroken } +/** + * Keeps the repository label for sources loaded from direct Tachiyomi DEX artifacts. + * + * Direct artifacts preserve their repository URL in installation metadata, whereas the source + * model itself only exposes the individual extension label. The map lets shared source UI show + * the repository identity consistently without changing source IDs or parser behavior. + */ +object DirectTachiyomiPluginMetadata { + @Volatile + private var namesByPackage: Map = emptyMap() + + fun update( + installed: Collection, + customNameResolver: ((String) -> String?)? = null, + ) { + namesByPackage = + buildMap { + installed.forEach { record -> + val custom = customNameResolver?.invoke(record.repositoryUrl) + val name = + custom ?: if (record.repositoryUrl.startsWith("local:") || record.repositoryUrl.startsWith("installed:")) { + record.name + .removePrefix("Tachiyomi: ") + .removePrefix("Tachiyomi - ") + .trim() + .ifBlank { "Local" } + } else { + deriveName(record.repositoryUrl) + } + if (name != null) put(record.packageName, name) + } + } + } + + fun get(packageName: String): String? = namesByPackage[packageName] + + fun deriveName(repositoryUrl: String): String? = + runCatching { + val uri = URI(repositoryUrl) + val host = uri.host?.lowercase(Locale.ROOT).orEmpty() + when { + host.endsWith(".github.io") -> { + host.removeSuffix(".github.io") + } + + else -> { + uri.path + .trim('/') + .split('/') + .firstOrNull { it.isNotBlank() } + ?.replaceFirstChar { it.titlecase(Locale.ROOT) } + ?: host.takeIf { it.isNotBlank() } + } + } + }.getOrNull() +} + data object LocalMangaSource : MangaSource { override val name = "LOCAL" } @@ -75,7 +134,10 @@ fun MangaSource(name: String?): MangaSource { val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource return ExternalMangaSource(packageName = parts.first, authority = parts.second) } else if (name.startsWith("EXTERNAL_")) { - ExternalManager.getByName(name)?.let { return it } // tachi + NativeExtManager.getByName(name)?.let { return it } + org.draken.tsukimix.core.parser.tachiyomi.ExtensionManager + .getByName(name) + ?.let { return it } // tachi } MangaSourceRegistry.resolveByName(name)?.let { return it } // Backward compatibility for loaded database items saved as '1.jar:MANGADEX' @@ -139,28 +201,62 @@ fun MangaSource.isExternalSource(): Boolean = else -> false } +fun MangaSource.isManageableSource(): Boolean = + when (unwrap()) { + is LocalMangaSource, is TestMangaSource, is UnknownMangaSource -> false + else -> true + } + fun MangaSource.externalPackageName(): String? = when (val source = unwrap()) { is ExternalMangaSource -> source.packageName - is ExternalSource -> source.pkgName + is ExternalSource -> source.pkgName.takeIf { source.isPreInstalled } else -> null } fun MangaSource.getSummary(context: Context): String? { val baseSummary = - when { - isExternalSource() -> { - context.getString(R.string.external_source) + when (val source = unwrap()) { + is ExternalSource -> { + val type = context.getString(source.contentType.titleResId) + val language = + if (source.locale.equals("all", ignoreCase = true)) { + context.getString(R.string.various_languages) + } else { + source.locale.toLocaleOrNull().getDisplayName(context) + } + val sourceLabel = + if (source.isPreInstalled) { + context.getString(R.string.external_source) + } else { + DirectTachiyomiPluginMetadata.get(source.pkgName) + ?: context.getString(R.string.external_source) + } + + "$type, $language • $sourceLabel" } - this === LocalMangaSource || this === TestMangaSource || this === UnknownMangaSource -> { - null + is ExternalMangaSource -> { + context.getString(R.string.external_source) } else -> { - val type = context.getString(contentType.titleResId) - val loc = locale.toLocale().getDisplayName(context) - context.getString(R.string.source_summary_pattern, type, loc) + when { + this === LocalMangaSource || this === TestMangaSource || this === UnknownMangaSource -> { + null + } + + else -> { + val type = context.getString(contentType.titleResId) + val loc = + if (locale.equals("all", ignoreCase = true) || locale.isBlank()) { + context.getString(R.string.various_languages) + } else { + locale.toLocale().getDisplayName(context) + } + context.getString(R.string.source_summary_pattern, type, loc) + } + } } } val pluginSource = @@ -170,9 +266,10 @@ fun MangaSource.getSummary(context: Context): String? { else -> null } return if (pluginSource != null && baseSummary != null) { - "$baseSummary • ${pluginSource.jarName}" + val pluginLabel = pluginSource.jarName.removeSuffix(".jar").removeSuffix(".apk") + "$baseSummary • $pluginLabel" } else { - pluginSource?.jarName ?: baseSummary + pluginSource?.jarName?.removeSuffix(".jar")?.removeSuffix(".apk") ?: baseSummary } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/nav/AppRouter.kt b/app/src/main/kotlin/org/draken/usagi/core/nav/AppRouter.kt index c2d0da7b..a2ab703b 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/nav/AppRouter.kt @@ -233,6 +233,19 @@ class AppRouter private constructor( fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java) + fun openPluginCatalog( + repositoryUrl: String, + repositoryName: String?, + ) { + val context = contextOrNull() ?: return + startActivity( + Intent(context, SourcesCatalogActivity::class.java).apply { + putExtra(SourcesCatalogActivity.EXTRA_REPOSITORY_URL, repositoryUrl) + putExtra(SourcesCatalogActivity.EXTRA_REPOSITORY_NAME, repositoryName) + }, + ) + } + fun openDownloads() = startActivity(DownloadsActivity::class.java) fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java) diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt index 33b4a1ad..83b63414 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt @@ -17,7 +17,7 @@ import java.lang.reflect.Proxy import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as External +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as External @Singleton class MangaDynamicRepository diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt index ee00c8c7..248c6cdc 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime import org.draken.usagi.core.cache.MemoryContentCache import org.draken.usagi.core.model.LocalMangaSource import org.draken.usagi.core.model.MangaSourceInfo @@ -30,9 +31,9 @@ import tsuki.model.MangaSource import tsuki.model.SortOrder import java.lang.ref.WeakReference import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as ExternalSource +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource import org.draken.usagi.core.network.imageproxy.ImageProxyInterceptor as Interceptor import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository as ExternalRepository @@ -82,11 +83,11 @@ interface MangaRepository { constructor( @ApplicationContext private val context: Context, private val localMangaRepository: LocalMangaRepository, - private val loaderContext: MangaLoaderContext, + private val loaderContext: Provider, private val contentCache: MemoryContentCache, - private val mirrorSwitcher: MirrorSwitcher, + private val mirrorSwitcher: Provider, private val mangaRepository: MangaDynamicRepository, - private val externalManager: ExternalManager, + private val tachiyomiRuntime: Provider, ) { private val cache = ArrayMap>() private var cacheVersion = -1 @@ -131,7 +132,7 @@ interface MangaRepository { when (source) { TestMangaSource -> { TestMangaRepository( - loaderContext = loaderContext, + loaderContext = loaderContext.get(), cache = contentCache, ) } @@ -154,6 +155,7 @@ interface MangaRepository { context = context, source = source, cache = contentCache, + runtime = tachiyomiRuntime.get(), ) } catch (_: Throwable) { EmptyMangaRepository(source) @@ -164,9 +166,9 @@ interface MangaRepository { try { MangaParserRepository( compoundSource = source, - parser = loaderContext.newParserInstance(source), + parser = loaderContext.get().newParserInstance(source), cache = contentCache, - mirrorSwitcher = mirrorSwitcher, + mirrorSwitcher = mirrorSwitcher.get(), ) } catch (_: Throwable) { EmptyMangaRepository(source) @@ -179,9 +181,6 @@ interface MangaRepository { if (MangaSourceRegistry.sources.isEmpty()) { runCatching { mangaRepository.load(mangaRepository.getDir()) - kotlinx.coroutines.runBlocking { - externalManager.ensureReady() - } } } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt index d7ef2195..2bc6356c 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt @@ -17,12 +17,14 @@ import coil3.request.Options import coil3.size.pxOrElse import coil3.toAndroidUri import coil3.toBitmap +import eu.kanade.tachiyomi.source.online.HttpSource 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.draken.tsukimix.core.parser.tachiyomi.ExtensionSourceSettings import org.draken.usagi.R import org.draken.usagi.core.exceptions.CloudFlareProtectedException import org.draken.usagi.core.model.MangaSource @@ -41,6 +43,7 @@ import tsuki.util.runCatchingCancellable import java.io.File import javax.inject.Inject import coil3.Uri as CoilUri +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as TachiyomiMangaSource import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository as ExternalRepository class FaviconFetcher( @@ -56,7 +59,7 @@ class FaviconFetcher( return when (val repo = mangaRepositoryFactory.create(mangaSource)) { is MangaParserRepository -> fetchParserFavicon(repo) is ExternalMangaRepository -> fetchPluginIcon(repo) - is ExternalRepository -> fetchPackageIcon(repo.source.pkgName) + is ExternalRepository -> fetchTachiyomiIcon(repo.source) is EmptyMangaRepository -> throwNSEE(null) is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options) else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}") @@ -110,6 +113,42 @@ class FaviconFetcher( return fetchPackageIcon(source.packageName, source.authority) } + private suspend fun fetchTachiyomiIcon(source: TachiyomiMangaSource): FetchResult { + val configuredUrl = + runCatchingCancellable { + ExtensionSourceSettings.browserUrl(options.context, source) + }.getOrNull() + ?: (source.catalogueSource as? HttpSource)?.baseUrl + return runCatchingCancellable { + configuredUrl + ?.takeIf { it.isNotBlank() } + ?.let { fetchDomainFavicon(it, source) } + ?: fetchPackageIcon(source.pkgName) + }.getOrElse { + fetchPackageIcon(source.pkgName) + } + } + + private suspend fun fetchDomainFavicon( + configuredUrl: String, + source: TachiyomiMangaSource, + ): FetchResult { + val sizePx = + maxOf( + options.size.width.pxOrElse { FALLBACK_SIZE }, + options.size.height.pxOrElse { FALLBACK_SIZE }, + ) + val cacheKey = options.diskCacheKey ?: "${source.name}_${configuredUrl}_$sizePx" + if (options.diskCachePolicy.readEnabled) { + localStorageCache[cacheKey]?.let { file -> + return file.asFetchResult() + } + } + val faviconUrl = configuredUrl.trimEnd('/') + "/favicon.ico" + val result = imageLoader.fetch(faviconUrl, options) ?: throwNSEE(null) + return if (options.diskCachePolicy.writeEnabled) writeToCache(cacheKey, result) else result + } + private suspend fun fetchPackageIcon( packageName: String, authority: String? = null, diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt index 8ef3ba70..d1a748da 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt @@ -1,15 +1,18 @@ package org.draken.usagi.core.parser.tachiyomi import android.content.Context +import androidx.collection.LruCache import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime import org.draken.tsukimix.core.parser.tachiyomi.model.toManga import org.draken.tsukimix.core.parser.tachiyomi.model.toMangaChapter import org.draken.tsukimix.core.parser.tachiyomi.model.toMangaPage @@ -31,16 +34,18 @@ import tsuki.util.runCatchingCancellable import tsuki.util.suspendlazy.suspendLazy import java.io.IOException import java.util.EnumSet -import java.util.concurrent.ConcurrentHashMap -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiSourceSettings as externalSettings +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionSourceSettings as externalSettings +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as TachiyomiMangaSource import org.draken.usagi.core.network.imageproxy.ImageProxyInterceptor as Interceptor class ExternalMangaRepository( - private val context: Context, + context: Context, override val source: TachiyomiMangaSource, cache: MemoryContentCache, + private val runtime: TachiyomiRuntime? = null, ) : CachingMangaRepository(cache), FilterHost { + private val appContext = context.applicationContext val external = source.catalogueSource private val filterListLazy = suspendLazy(Dispatchers.Default) { @@ -108,23 +113,18 @@ class ExternalMangaRepository( lastOffset = 0 hasMorePages = true } else if (offset > lastOffset) { + if (!hasMorePages) return@withContext emptyList() + currentPage++ lastOffset = offset - currentPage += 1 } currentPage } - if (offset > 0 && !hasMorePages) return@withContext emptyList() - val query = filter?.query?.trim().orEmpty() - val hasFilters = - filter?.let { - it.query?.isNotBlank() == true || it.tags.isNotEmpty() || it.tagsExclude.isNotEmpty() - } ?: false - + val query = filter?.query val mangasPage = try { when { - hasFilters -> { + !query.isNullOrBlank() || filter?.isEmpty() == false -> { val mihonFilters = try { external.getFilterList() @@ -132,7 +132,7 @@ class ExternalMangaRepository( FilterList() } FilterMapper.decode(mihonFilters, filter) - external.getSearchManga(page, query, mihonFilters) + external.getSearchManga(page, query ?: "", mihonFilters) } (order ?: defaultSortOrder).isLatest() && source.supportsLatest -> { @@ -166,31 +166,60 @@ class ExternalMangaRepository( throw mapException(e) } val details = update.manga.toManga(source, fallbackUrl = manga.url, fallbackTitle = manga.title) + val primaryChapters = + update.chapters.asReversed().mapIndexed { index, chapter -> + chapter.toMangaChapter(source, details.title, index) + } + val siblings = runtime?.getSiblingSources(source)?.filter { it.sourceId != source.sourceId }.orEmpty() + val allChapters = + if (siblings.isEmpty()) { + primaryChapters + } else { + val siblingChapters = + siblings + .map { sibling -> + async { + runCatching { + val siblingUpdate = + sibling.catalogueSource.getMangaUpdate( + original, + emptyList(), + fetchDetails = false, + fetchChapters = true, + ) + siblingUpdate.chapters.asReversed().mapIndexed { index, chapter -> + chapter.toMangaChapter(sibling, details.title, index) + } + }.getOrDefault(emptyList()) + } + }.awaitAll() + .flatten() + primaryChapters + siblingChapters + } details.copy( - chapters = - update.chapters.asReversed().mapIndexed { index, chapter -> - chapter.toMangaChapter(source, details.title, index) - }, + chapters = allChapters, source = source, ) } override suspend fun getPagesImpl(chapter: MangaChapter): List = withContext(Dispatchers.IO) { + val chapterSource = (chapter.source as? TachiyomiMangaSource)?.catalogueSource ?: external + val targetSource = chapter.source as? TachiyomiMangaSource ?: source val pageList = try { - external.getPageList(chapter.toSChapter()) + chapterSource.getPageList(chapter.toSChapter()) } catch (e: Throwable) { throw mapException(e) } pageList.map { page -> val resolved = page.imageUrl - ?: (external as? HttpSource)?.getImageUrl(page) + ?: (chapterSource as? HttpSource)?.getImageUrl(page) ?: page.url page.imageUrl = resolved - pageCache[pageCacheKey(source, resolved)] = page - page.toMangaPage(source, resolved) + pageCache.put(pageCacheKey(targetSource, resolved), page) + page.toMangaPage(targetSource, resolved) } } @@ -200,9 +229,11 @@ class ExternalMangaRepository( } override suspend fun getPageRequest(page: MangaPage): Request { - val httpSource = external as? HttpSource ?: return super.getPageRequest(page) + val pageSource = (page.source as? TachiyomiMangaSource)?.catalogueSource ?: external + val targetSource = page.source as? TachiyomiMangaSource ?: source + val httpSource = pageSource as? HttpSource ?: return super.getPageRequest(page) val tachiyomiPage = - pageCache[pageCacheKey(source, page.url)] + pageCache[pageCacheKey(targetSource, page.url)] ?: Page(index = 0, url = page.url, imageUrl = page.url) return withContext(Dispatchers.IO) { val imageRequest = @@ -213,7 +244,7 @@ class ExternalMangaRepository( } imageRequest .newBuilder() - .tag(tsuki.model.MangaSource::class.java, source) + .tag(tsuki.model.MangaSource::class.java, targetSource) .build() } } @@ -223,9 +254,11 @@ class ExternalMangaRepository( okHttp: OkHttpClient, interceptor: Interceptor, ): Response { - val httpSource = external as? HttpSource ?: return super.getPageResponse(page, okHttp, interceptor) + val pageSource = (page.source as? TachiyomiMangaSource)?.catalogueSource ?: external + val targetSource = page.source as? TachiyomiMangaSource ?: source + val httpSource = pageSource as? HttpSource ?: return super.getPageResponse(page, okHttp, interceptor) val tachiyomiPage = - pageCache[pageCacheKey(source, page.url)] + pageCache[pageCacheKey(targetSource, page.url)] ?: Page(index = 0, url = page.url, imageUrl = page.url) return withContext(Dispatchers.IO) { try { @@ -240,15 +273,15 @@ class ExternalMangaRepository( override suspend fun getExternalFilters(): Any = filterListLazy.get() - fun getBrowserUrl(): String? = externalSettings.browserUrl(context, source) + fun getBrowserUrl(): String? = externalSettings.browserUrl(appContext, source) - fun getSettingsPreferences() = externalSettings.preferences(context, source) + fun getSettingsPreferences() = externalSettings.preferences(appContext, source) fun refreshDomainOverride() { - externalSettings.refreshDomainOverride(context, source) + externalSettings.refreshDomainOverride(appContext, source) } - fun isSlowdownEnabled(): Boolean = externalSettings.isSlowdownEnabled(context, source) + fun isSlowdownEnabled(): Boolean = externalSettings.isSlowdownEnabled(appContext, source) override suspend fun getRelatedMangaImpl(seed: Manga): List { val httpSource = external as? HttpSource ?: return emptyList() @@ -267,12 +300,20 @@ class ExternalMangaRepository( val httpSource = external as? HttpSource if (httpSource != null && error.hasMessage("cloudflare bypass failed")) { return CloudFlareProtectedException( - url = externalSettings.browserUrl(context, source) ?: httpSource.getHomeUrl(), + url = externalSettings.browserUrl(appContext, source) ?: httpSource.getHomeUrl(), source = source, headers = httpSource.headers, ) } - return error as? IOException ?: IOException(error.message ?: error.toString(), error) + if (error is IOException) return error + val causeMessage = error.localizedMessage ?: error.message + val message = + if (!causeMessage.isNullOrBlank()) { + appContext.getString(org.draken.usagi.R.string.plugin_incompatible_with_cause, causeMessage) + } else { + appContext.getString(org.draken.usagi.R.string.plugin_incompatible) + } + return IOException(message, error) } private fun SortOrder.isLatest(): Boolean = this == SortOrder.NEWEST || this == SortOrder.UPDATED @@ -287,7 +328,7 @@ class ExternalMangaRepository( } private companion object { - val pageCache = ConcurrentHashMap() + val pageCache = LruCache(500) fun pageCacheKey( source: TachiyomiMangaSource, diff --git a/app/src/main/kotlin/org/draken/usagi/details/ui/ChaptersMapper.kt b/app/src/main/kotlin/org/draken/usagi/details/ui/ChaptersMapper.kt index 2b1373bb..6745429f 100644 --- a/app/src/main/kotlin/org/draken/usagi/details/ui/ChaptersMapper.kt +++ b/app/src/main/kotlin/org/draken/usagi/details/ui/ChaptersMapper.kt @@ -18,7 +18,7 @@ fun MangaDetails.mapChapters( isGrid: Boolean, isDownloadedOnly: Boolean, ): List { - val remoteChapters = chapters[branch].orEmpty() + val remoteChapters = (if (branch != null) chapters[branch] else chapters[null] ?: chapters.values.firstOrNull()).orEmpty() val localChapters = local?.manga?.getChapters(branch).orEmpty() if (remoteChapters.isEmpty() && localChapters.isEmpty()) { return emptyList() diff --git a/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt index 6a80b729..dbbe1ef3 100644 --- a/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt @@ -16,19 +16,26 @@ import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.util.canonicalLanguageCode import org.draken.usagi.BuildConfig import org.draken.usagi.core.LocalizedAppContext import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.db.dao.MangaSourcesDao import org.draken.usagi.core.db.entity.MangaSourceEntity +import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata import org.draken.usagi.core.model.MangaSourceInfo import org.draken.usagi.core.model.MangaSourceRegistry import org.draken.usagi.core.model.PluginMangaSource +import org.draken.usagi.core.model.UnresolvedMangaSource import org.draken.usagi.core.model.getTitle import org.draken.usagi.core.model.isExternalSource +import org.draken.usagi.core.model.isManageableSource import org.draken.usagi.core.model.isNsfw +import org.draken.usagi.core.model.unwrap import org.draken.usagi.core.parser.external.ExternalMangaSource import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.prefs.observeAsFlow @@ -40,10 +47,10 @@ import tsuki.model.MangaSource import tsuki.network.CloudFlareHelper import tsuki.util.mapNotNullToSet import tsuki.util.mapToSet +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as ExternalSource +import org.draken.usagi.core.model.MangaSource as createMangaSource @Singleton class MangaSourcesRepository @@ -52,7 +59,7 @@ class MangaSourcesRepository @LocalizedAppContext private val context: Context, private val db: MangaDatabase, private val settings: AppSettings, - private val tachiyomiExtensionManager: dagger.Lazy? = null, + private val tachiyomiRuntime: dagger.Lazy? = null, ) { private var assimilatedVersion = -1 private val dao: MangaSourcesDao @@ -60,15 +67,16 @@ class MangaSourcesRepository init { processLifecycleScope.launch(Dispatchers.IO) { + runCatching { tachiyomiRuntime?.get()?.ensureReady() } assimilateNewSources() - MangaSourceRegistry.updates.collect { + merge(MangaSourceRegistry.updates, observeExternalSources()).collect { assimilateNewSources() } } } val allMangaSources: List - get() = MangaSourceRegistry.sources + get() = (MangaSourceRegistry.sources + getExternalSources()).distinctBy { it.name } suspend fun getEnabledSources(): List { assimilateNewSources() @@ -79,13 +87,7 @@ class MangaSourcesRepository skipNsfwSources = settings.isNsfwContentDisabled, sortOrder = order, hideBrokenSources = settings.isBrokenSourcesHidden, - ).let { enabled -> - val external = getAllExtSources() - val list = ArrayList(enabled.size + external.size) - external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) } - list.addAll(enabled) - list - } + ) } suspend fun getPinnedSources(): Set { @@ -113,7 +115,7 @@ class MangaSourcesRepository if (settings.isAllSourcesEnabled) { return emptySet() } - val result = allMangaSources.filterNot { it.isExternalSource() }.toMutableSet() + val result = allMangaSources.toMutableSet() if (settings.isNsfwContentDisabled) { result.removeAll { it.isNsfw() } } @@ -157,7 +159,8 @@ class MangaSourcesRepository mapNotNullTo(ArrayList(size)) { it.mangaSource } } if (locale != null) { - sources.retainAll { it.locale == locale } + val canonicalLocale = canonicalLanguageCode(locale) + sources.retainAll { canonicalLanguageCode(it.locale) == canonicalLocale } } if (excludeBroken && !hideBrokenSources) { sources.removeAll { it.isBroken } @@ -165,7 +168,12 @@ class MangaSourcesRepository if (plugin != null) { sources.retainAll { val ps = it as? PluginMangaSource ?: (it as? MangaSourceInfo)?.mangaSource as? PluginMangaSource - ps?.jarName == plugin + if (ps != null) { + ps.jarName == plugin + } else { + val ext = it.unwrap() as? org.draken.tsukimix.core.parser.tachiyomi.model.Manga + ext?.pkgName == plugin || DirectTachiyomiPluginMetadata.get(ext?.pkgName.orEmpty()) == plugin + } } } if (types.isNotEmpty()) { @@ -195,7 +203,7 @@ class MangaSourcesRepository ) { skipNsfw, hideBroken, sources, _ -> sources.count { it.source.toMangaSourceOrNull()?.let { s -> - !s.isExternalSource() && (!skipNsfw || !s.isNsfw()) && (!hideBroken || !s.isBroken) + (!skipNsfw || !s.isNsfw()) && (!hideBroken || !s.isBroken) } == true } }.distinctUntilChanged() @@ -211,10 +219,17 @@ class MangaSourcesRepository ) { skipNsfw, hideBroken, enabledSources, _ -> val enabled = enabledSources.mapToSet { it.source } allMangaSources.count { x -> - !x.isExternalSource() && x.name !in enabled && (!skipNsfw || !x.isNsfw()) && (!hideBroken || !x.isBroken) + x.name !in enabled && (!skipNsfw || !x.isNsfw()) && (!hideBroken || !x.isBroken) } }.distinctUntilChanged() + fun observeManageableSourcesCount(): Flow> = + observeEnabledSources() + .map { sources -> + val manageable = sources.filter { it.isManageableSource() } + manageable.size to manageable.count { it.isExternalSource() } + }.distinctUntilChanged() + fun observeEnabledSources(): Flow> = combine( observeIsNsfwDisabled(), @@ -226,13 +241,7 @@ class MangaSourcesRepository dao.observeAll(!allEnabled, order).map { it.toSources(skipNsfw, order, hideBroken) } - }.flattenLatest().combine(observeExternalSources()) { enabled, external -> - val external = external + getSpecialSources() - val list = ArrayList(enabled.size + external.size) - external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) } - list.addAll(enabled) - list - } + }.flattenLatest() fun observeAll(): Flow>> = combine( @@ -313,8 +322,8 @@ class MangaSourcesRepository } private suspend fun assimilateNewSources(): Boolean { - if (MangaSourceRegistry.sources.isEmpty()) { - return false // No plugins loaded yet, preserve existing DB records + if (allMangaSources.isEmpty()) { + return false } val currentVersion = MangaSourceRegistry.version if (assimilatedVersion == currentVersion) { @@ -349,7 +358,7 @@ class MangaSourcesRepository ): ReversibleHandle { setSourcesPinnedImpl(sources, isPinned) return ReversibleHandle { - setSourcesEnabledImpl(sources, !isPinned) + setSourcesPinnedImpl(sources, !isPinned) } } @@ -376,10 +385,9 @@ class MangaSourcesRepository private suspend fun getNewSources(): MutableSet { val entities = dao.findAll() - val result = allMangaSources.filterNot { it.isExternalSource() }.toMutableSet() - for (e in entities) { - result.remove(e.source.toMangaSourceOrNull() ?: continue) - } + val existingNames = entities.mapToSet { it.source } + val result = allMangaSources.filter { it.isManageableSource() }.toMutableSet() + result.removeAll { it.name in existingNames } return result } @@ -408,7 +416,7 @@ class MangaSourcesRepository ) { launch(Dispatchers.Default) { try { - tachiyomiExtensionManager?.get()?.ensureReady(forceRefresh = true) + tachiyomiRuntime?.get()?.ensureReady(forceRefresh = true) } catch (_: Throwable) { } } @@ -448,10 +456,6 @@ class MangaSourcesRepository ) } - private fun getSpecialSources(): List = allMangaSources.filterIsInstance() - - private fun getAllExtSources(): List = getExternalSources() + getSpecialSources() - private fun List.toSources( skipNsfwSources: Boolean, sortOrder: SourcesSortOrder?, @@ -459,9 +463,10 @@ class MangaSourcesRepository ): MutableList { val isAllEnabled = settings.isAllSourcesEnabled val result = ArrayList(size) + val seen = HashSet(size) for (entity in this) { val source = entity.source.toMangaSourceOrNull() ?: continue - if (source.isExternalSource()) { + if (!seen.add(source.name)) { continue } if (skipNsfwSources && source.isNsfw()) { @@ -479,7 +484,7 @@ class MangaSourcesRepository ) } if (sortOrder == SourcesSortOrder.ALPHABETIC) { - result.sortWith(compareBy { !it.isPinned }.thenBy { it.getTitle(context) }) + result.sortWith(compareBy { !it.isPinned }.thenBy { it.getTitle(context).lowercase(Locale.ROOT) }) } return result } @@ -504,5 +509,8 @@ class MangaSourcesRepository isAllSourcesEnabled } - private fun String.toMangaSourceOrNull(): MangaSource? = MangaSourceRegistry.resolveByName(this) + private fun String.toMangaSourceOrNull(): MangaSource? { + val src = createMangaSource(this) + return if (src is UnresolvedMangaSource) null else src + } } diff --git a/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt index 534c16a7..f21ab22d 100644 --- a/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime import org.draken.usagi.R import org.draken.usagi.core.model.MangaSourceInfo import org.draken.usagi.core.os.AppShortcutManager @@ -20,6 +21,7 @@ import org.draken.usagi.core.prefs.observeAsFlow import org.draken.usagi.core.prefs.observeAsStateFlow import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.core.ui.util.ReversibleAction +import org.draken.usagi.core.ui.util.ReversibleHandle import org.draken.usagi.core.util.ext.MutableEventFlow import org.draken.usagi.core.util.ext.call import org.draken.usagi.core.util.ext.combine @@ -48,6 +50,7 @@ class ExploreViewModel private val suggestionRepository: SuggestionRepository, private val exploreRepository: ExploreRepository, private val sourcesRepository: MangaSourcesRepository, + private val tachiyomiRuntime: TachiyomiRuntime, private val shortcutManager: AppShortcutManager, private val updatePluginsProvider: UpdatePluginsProvider, ) : BaseViewModel() { @@ -112,8 +115,14 @@ class ExploreViewModel fun disableSources(sources: Collection) { launchJob(Dispatchers.Default) { val rollback = sourcesRepository.setSourcesEnabled(sources, isEnabled = false) + tachiyomiRuntime.ensureReady(forceRefresh = true) + val synchronizedRollback = + ReversibleHandle { + rollback.reverse() + tachiyomiRuntime.ensureReady(forceRefresh = true) + } val message = if (sources.size == 1) R.string.source_disabled else R.string.sources_disabled - onActionDone.call(ReversibleAction(message, rollback)) + onActionDone.call(ReversibleAction(message, synchronizedRollback)) } } diff --git a/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt b/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt index 82aaef6e..244bfb4e 100644 --- a/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt +++ b/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt @@ -11,7 +11,7 @@ import org.draken.usagi.core.parser.external.ExternalMangaSource import org.draken.usagi.core.parser.favicon.faviconUri import tsuki.model.MangaSource import tsuki.model.MangaTag -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as ExternalSource +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource sealed interface ListFilterOption { @get:StringRes diff --git a/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt b/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt index 660321b5..75ade787 100644 --- a/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.draken.tsukimix.core.parser.tachiyomi.RuntimeInitializer import org.draken.usagi.R import org.draken.usagi.backups.ui.periodical.PeriodicalBackupService import org.draken.usagi.browser.AdListUpdateService @@ -57,7 +58,6 @@ import org.draken.usagi.core.nav.router import org.draken.usagi.core.os.VoiceInputContract import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.prefs.NavItem -import org.draken.usagi.core.ui.AppCrashActivity import org.draken.usagi.core.ui.BaseActivity import org.draken.usagi.core.ui.dialog.BigButtonsAlertDialog import org.draken.usagi.core.ui.util.FadingAppbarMediator @@ -101,6 +101,9 @@ class MainActivity : @Inject lateinit var settings: AppSettings + @Inject + lateinit var runtimeInit: RuntimeInitializer + private val viewModel by viewModels() private val searchSuggestionViewModel by viewModels() private val voiceInputLauncher = @@ -204,6 +207,15 @@ class MainActivity : initSearch() } + override fun onPostResume() { + super.onPostResume() + window.decorView.post { + lifecycleScope.launch { + runtimeInit.initialize() + } + } + } + override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) adjustSearchUI(viewBinding.searchView.isShowing) diff --git a/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt index 92272aac..967664f5 100644 --- a/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime import org.draken.usagi.R import org.draken.usagi.core.model.LocalMangaSource import org.draken.usagi.core.model.MangaSource @@ -57,8 +58,7 @@ import tsuki.model.SortOrder import javax.inject.Inject import kotlin.math.absoluteValue import com.google.android.material.R as materialR -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as ExternalSource +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource import org.draken.usagi.filter.ui.external.sheet.FilterSheetFragment as ExternalSheetFragment @AndroidEntryPoint @@ -80,7 +80,7 @@ class MangaListActivity : private lateinit var source: MangaSource @Inject - lateinit var externalManager: ExternalManager + lateinit var tachiyomiRuntime: TachiyomiRuntime override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -213,7 +213,7 @@ class MangaListActivity : } } - private fun resolve(source: MangaSource): MangaSource = (source as? ExternalSource)?.let(externalManager::resolve) ?: source + private fun resolve(source: MangaSource): MangaSource = (source as? ExternalSource)?.let(tachiyomiRuntime::resolve) ?: source private fun initFilter(filterOwner: FilterCoordinator.Owner) { if (viewBinding.containerSide != null) { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsFragment.kt index e1fc07f2..70d0b060 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsFragment.kt @@ -12,7 +12,6 @@ import org.draken.usagi.R import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.ui.BasePreferenceFragment import org.draken.usagi.core.util.ext.addMenuProvider -import org.draken.usagi.core.util.ext.getQuantityStringSafe import org.draken.usagi.core.util.ext.observe import org.draken.usagi.settings.search.SettingsSearchMenuProvider import org.draken.usagi.settings.search.SettingsSearchViewModel @@ -43,16 +42,11 @@ class RootSettingsFragment : BasePreferenceFragment(0) { ) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> - viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { - val total = viewModel.totalSourcesCount - pref.summary = - if (it >= 0) { - getString(R.string.enabled_d_of_d, it, total) - } else { - resources.getQuantityStringSafe(R.plurals.items, total, total) - } + viewModel.sourceCounts.observe(viewLifecycleOwner) { (total, _) -> + pref.summary = resources.getQuantityString(R.plurals.items, total, total) } } + addMenuProvider(SettingsSearchMenuProvider(activityViewModel)) } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsViewModel.kt index e49fd9a1..d29fa342 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/RootSettingsViewModel.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus -import org.draken.usagi.core.model.isExternalSource import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.explore.data.MangaSourcesRepository import javax.inject.Inject @@ -15,14 +14,10 @@ import javax.inject.Inject class RootSettingsViewModel @Inject constructor( - private val sourcesRepository: MangaSourcesRepository, + sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { - val totalSourcesCount: Int - get() = sourcesRepository.allMangaSources.count { !it.isExternalSource() } - - val enabledSourcesCount = + val sourceCounts = sourcesRepository - .observeEnabledSourcesCount() - .withErrorHandling() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) + .observeManageableSourcesCount() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0 to 0) } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt index 8ef8f19f..dbb55945 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt @@ -9,7 +9,7 @@ import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.preferenceKey -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiSourceSettings +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionSourceSettings import org.draken.usagi.R import org.draken.usagi.core.parser.EmptyMangaRepository import org.draken.usagi.core.parser.MangaParserRepository @@ -41,8 +41,8 @@ private fun PreferenceFragmentCompat.addPreferences(repository: ExternalMangaRep // Let extension add its preferences first configurableSource?.setupPreferenceScreen(preferenceScreen) // Remove extension's domain preference if it added one — Usagi replaces it - TachiyomiSourceSettings.mergeDomainPreference(requireContext(), repository.source) - for (key in arrayOf(TachiyomiSourceSettings.KEY_DOMAIN, TachiyomiSourceSettings.KEY_OVERRIDE_BASE_URL)) { + ExtensionSourceSettings.mergeDomainPreference(requireContext(), repository.source) + for (key in arrayOf(ExtensionSourceSettings.KEY_DOMAIN, ExtensionSourceSettings.KEY_OVERRIDE_BASE_URL)) { preferenceScreen.findPreference(key)?.let { it.parent?.removePreference(it) } } // Add Usagi's EditTextPreference last so key lookup always returns it diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt index cfb9247f..64143a35 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt @@ -10,7 +10,7 @@ import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.filterNotNull -import org.draken.tsukimix.core.parser.tachiyomi.addLangToPref +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime import org.draken.usagi.R import org.draken.usagi.core.exceptions.resolve.SnackbarErrorObserver import org.draken.usagi.core.model.getTitle @@ -29,7 +29,6 @@ import org.draken.usagi.core.util.ext.observeEvent import org.draken.usagi.core.util.ext.withArgs import tsuki.model.MangaSource import javax.inject.Inject -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as ExternalManager @AndroidEntryPoint class SourceSettingsFragment : @@ -38,7 +37,7 @@ class SourceSettingsFragment : private val viewModel: SourceSettingsViewModel by viewModels() @Inject - lateinit var externalManager: ExternalManager + lateinit var tachiyomiRuntime: TachiyomiRuntime override fun onResume() { super.onResume() @@ -56,7 +55,7 @@ class SourceSettingsFragment : addPreferencesFromResource(R.xml.pref_source) addPreferencesFromRepository(viewModel.repository) (viewModel.repository as? ExternalMangaRepository)?.source?.let { - externalManager.addLangToPref(preferenceScreen, it, getString(R.string.language), viewModel::publish) + tachiyomiRuntime.addLangToPref(preferenceScreen, it, getString(R.string.language), viewModel::publish) } val isValidSource = viewModel.repository !is EmptyMangaRepository diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt index a892075f..4100a505 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import okhttp3.HttpUrl +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime import org.draken.usagi.R import org.draken.usagi.core.model.MangaSource import org.draken.usagi.core.model.MangaSourceRegistry @@ -26,8 +27,7 @@ import org.draken.usagi.explore.data.MangaSourcesRepository import tsuki.MangaParserAuthProvider import tsuki.exception.AuthRequiredException import javax.inject.Inject -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as ExternalSource +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource @HiltViewModel class SourceSettingsViewModel @@ -37,7 +37,7 @@ class SourceSettingsViewModel mangaRepositoryFactory: MangaRepository.Factory, private val cookieJar: MutableCookieJar, private val mangaSourcesRepository: MangaSourcesRepository, - private val externalManager: ExternalManager, + private val tachiyomiRuntime: TachiyomiRuntime, ) : BaseViewModel(), SharedPreferences.OnSharedPreferenceChangeListener { val source = MangaSource(savedStateHandle.get(AppRouter.KEY_SOURCE)).resolve() @@ -122,12 +122,13 @@ class SourceSettingsViewModel fun setEnabled(value: Boolean) { launchJob(Dispatchers.Default) { mangaSourcesRepository.setSourcesEnabled(setOf(source), value) + tachiyomiRuntime.ensureReady(forceRefresh = true) } } fun publish() { val e = MangaSourceRegistry.sources.filterNot { it is ExternalSource } - MangaSourceRegistry.publish(e + externalManager.getActiveSources()) + MangaSourceRegistry.publish(e + tachiyomiRuntime.getActiveSources()) } private fun loadUsername(authProvider: MangaParserAuthProvider?) { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt index f925d3d6..103875d3 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsFragment.kt @@ -71,38 +71,32 @@ class SourcesSettingsFragment : ) { super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> - viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { - pref.summary = - if (it >= 0) { - resources.getQuantityStringSafe(R.plurals.items, it, it) - } else { - null - } + viewModel.sourceCounts.observe(viewLifecycleOwner) { (total, _) -> + pref.summary = resources.getQuantityStringSafe(R.plurals.items, total, total) + hideEmptyCatalog() } } + findPreference(AppSettings.KEY_SOURCES_CATALOG)?.let { pref -> viewModel.availableSourcesCount.observe(viewLifecycleOwner) { - pref.summary = - when { - it == 0 -> getString(R.string.all_sources_enabled) - it > 0 -> getString(R.string.available_d, it) - else -> null - } - hideEmptyCatalog() + updateCatalogSummary(pref) } } + findPreference(AppSettings.KEY_HANDLE_LINKS)?.let { pref -> viewModel.isLinksEnabled.observe(viewLifecycleOwner) { pref.isChecked = it } } updateEnableAllDependencies() + viewModel.externalPluginCount.observe(viewLifecycleOwner) { updatePluginsSummary() } updatePluginsSummary() settings.subscribe(this) } override fun onResume() { super.onResume() + viewModel.refreshExternalCounts() updatePluginsSummary() } @@ -142,15 +136,28 @@ class SourcesSettingsFragment : } private fun updatePluginsSummary() { - val count = mangaDynamicRepository.get().size + val count = mangaDynamicRepository.get().size + viewModel.externalPluginCount.value findPreference("plugins_manager")?.summary = resources.getQuantityStringSafe(R.plurals.items, count, count) hideEmptyCatalog() } + private fun updateCatalogSummary(pref: Preference) { + val count = viewModel.availableSourcesCount.value + pref.summary = + when { + count == 0 -> getString(R.string.all_sources_enabled) + count > 0 -> getString(R.string.available_d, count) + else -> null + } + hideEmptyCatalog() + } + private fun hideEmptyCatalog() { + val total = viewModel.sourceCounts.value.first val catalog = viewModel.availableSourcesCount.value - val imported = mangaDynamicRepository.get().size - findPreference(AppSettings.KEY_REMOTE_SOURCES)?.isVisible = !(catalog == 0 && imported == 0) + val imported = mangaDynamicRepository.get().size + viewModel.externalPluginCount.value + val hasSources = total > 0 || catalog > 0 || imported > 0 + findPreference(AppSettings.KEY_REMOTE_SOURCES)?.isVisible = hasSources } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt index 92c473dc..0f7b4833 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider +import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.explore.data.MangaSourcesRepository import javax.inject.Inject @@ -23,15 +25,19 @@ class SourcesSettingsViewModel @Inject constructor( sourcesRepository: MangaSourcesRepository, + private val catalogProvider: ExtensionProvider, + private val directManager: NativeExtManager, @ApplicationContext private val context: Context, ) : BaseViewModel() { private val linksHandlerActivity = ComponentName(context, "org.draken.usagi.details.ui.DetailsByLinkActivity") - val enabledSourcesCount = + val sourceCounts = sourcesRepository - .observeEnabledSourcesCount() - .withErrorHandling() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) + .observeManageableSourcesCount() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0 to 0) + + private val externalPluginCountState = MutableStateFlow(calculateExternalCount()) + val externalPluginCount = externalPluginCountState val availableSourcesCount = sourcesRepository @@ -41,6 +47,29 @@ class SourcesSettingsViewModel val isLinksEnabled = MutableStateFlow(isLinksEnabled()) + init { + refreshExternalCounts() + } + + private fun calculateExternalCount(): Int { + val cached = catalogProvider.getSavedRepositories().map { catalogProvider.canonicalKey(it) } + val installed = directManager.installed.value.map { catalogProvider.canonicalKey(it.repositoryUrl) } + return (cached + installed).filter { it.isNotBlank() }.toSet().size + } + + fun refreshExternalCounts() { + launchJob(Dispatchers.Default) { + val artifacts = catalogProvider.loadSavedCached() + val repos = + ( + artifacts.map { catalogProvider.canonicalKey(it.repositoryUrl) } + + directManager.installed.value.map { catalogProvider.canonicalKey(it.repositoryUrl) } + ).filter { it.isNotBlank() } + .toSet() + externalPluginCountState.value = repos.size + } + } + fun setLinksEnabled(isEnabled: Boolean) { context.packageManager.setComponentEnabledSetting( linksHandlerActivity, diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/adapter/SourceConfigAdapterDelegates.kt index f6b5a387..a214557c 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -44,9 +44,11 @@ fun sourceConfigItemDelegate2(listener: SourceConfigListener) = binding.textViewTitle.text = item.source.getTitle(context) binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable binding.imageViewRemove.isVisible = item.isEnabled && item.isDisableAvailable + binding.imageViewRemove.setImageResource(R.drawable.ic_disable) + binding.imageViewRemove.contentDescription = context.getString(R.string.disable) binding.imageViewMenu.isVisible = item.isEnabled binding.textViewTitle.drawableStart = if (item.isPinned) iconPinned else null - + val summary = item.source.getSummary(context) val pluginSource = item.source as? org.draken.usagi.core.model.PluginMangaSource if (pluginSource != null) { @@ -55,7 +57,7 @@ fun sourceConfigItemDelegate2(listener: SourceConfigListener) = } else { binding.textViewDescription.text = summary } - + binding.imageViewIcon.setImageAsync(item.source) } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt index 9774f9ce..b6803d48 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt @@ -1,21 +1,163 @@ package org.draken.usagi.settings.sources.catalog +import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled +import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiCatalogSource +import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact +import org.draken.usagi.R +import org.draken.usagi.core.model.getSummary +import org.draken.usagi.core.model.titleResId +import org.draken.usagi.core.model.unwrap import org.draken.usagi.list.ui.model.ListModel +import tsuki.model.ContentType import tsuki.model.MangaSource +import java.net.URI +import java.util.Locale sealed interface SourceCatalogItem : ListModel { data class Source( val source: MangaSource, + val isMultiLanguage: Boolean = false, ) : SourceCatalogItem { + fun description(context: Context): String? { + if (!isMultiLanguage) { + return source.getSummary(context) + } + val type = context.getString(source.contentType.titleResId) + val lang = context.getString(R.string.various_languages) + val unwrapped = source.unwrap() + val pluginLabel = + (source as? org.draken.usagi.core.model.PluginMangaSource)?.jarName?.removeSuffix(".jar")?.removeSuffix(".apk") + ?: when (unwrapped) { + is org.draken.tsukimix.core.parser.tachiyomi.model.Manga -> { + if (unwrapped.isPreInstalled) { + context.getString(R.string.external_source) + } else { + org.draken.usagi.core.model.DirectTachiyomiPluginMetadata + .get(unwrapped.pkgName) + ?: context.getString(R.string.external_source) + } + } + + is org.draken.usagi.core.parser.external.ExternalMangaSource -> { + context.getString(R.string.external_source) + } + + else -> { + null + } + } + return if (pluginLabel != null) { + "$type, $lang • $pluginLabel" + } else { + context.getString(R.string.source_summary_pattern, type, lang) + } + } + override fun areItemsTheSame(other: ListModel): Boolean = other is Source && other.source == source } + data class Tachiyomi( + val source: TachiyomiCatalogSource, + val artifact: TachiyomiExtensionArtifact, + val installed: DirectTachiyomiInstalled?, + val isLoaded: Boolean, + val isPreInstalledApk: Boolean, + val isMultiLanguage: Boolean = false, + val isInstalling: Boolean = false, + val customPluginName: String? = null, + ) : SourceCatalogItem { + val isInstalled: Boolean get() = installed != null + + val hasUpdate: Boolean + get() { + val availableVersion = artifact.versionCode ?: return false + val installedVersion = installed?.versionCode ?: return false + return availableVersion > installedVersion + } + + val contentType: ContentType + get() = source.contentType + val isNsfw: Boolean get() = contentType == ContentType.HENTAI + + val displayName: String get() = source.name + + fun description(context: Context): String { + val lang = + if (isMultiLanguage || source.language.equals("all", true)) { + context.getString(R.string.various_languages) + } else { + languageDisplayName(context) + } + val details = + listOf( + contentTypeLabel(context), + lang, + ).joinToString(", ") + val label = + if (isPreInstalledApk) { + context.getString(R.string.external_source) + } else { + pluginName + } + return "$details • $label" + } + + private fun contentTypeLabel(context: Context): String = + when (contentType) { + ContentType.MANGA -> context.getString(R.string.content_type_manga) + ContentType.HENTAI -> context.getString(R.string.content_type_hentai) + else -> context.getString(R.string.unknown) + } + + private fun languageDisplayName(context: Context): String { + if (source.language.equals("all", true)) return context.getString(R.string.various_languages) + val locale = Locale.forLanguageTag(source.language) + return locale.getDisplayName(locale).takeIf { it.isNotBlank() && it != source.language } ?: source.language + } + + private val pluginName: String + get() = + customPluginName?.trim()?.takeIf { it.isNotBlank() } + ?: org.draken.usagi.core.model.DirectTachiyomiPluginMetadata + .get(artifact.packageName) + ?: if (artifact.repositoryUrl.startsWith("local:") || artifact.repositoryUrl.startsWith("installed:")) { + installed + ?.name + ?.removePrefix("Tachiyomi: ") + ?.removePrefix("Tachiyomi - ") + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: artifact.name + .removePrefix("Tachiyomi: ") + .removePrefix("Tachiyomi - ") + .trim() + } else { + runCatching { + val uri = URI(artifact.repositoryUrl) + val host = uri.host?.lowercase(Locale.ROOT).orEmpty() + val segments = + uri.path + .trim('/') + .split('/') + .filter { it.isNotBlank() } + when { + host.endsWith(".github.io") -> host.removeSuffix(".github.io") + segments.isNotEmpty() -> segments.first().replaceFirstChar { it.titlecase(Locale.ROOT) } + else -> host.ifBlank { artifact.repositoryUrl } + } + }.getOrDefault(artifact.repositoryUrl) + } + + override fun areItemsTheSame(other: ListModel): Boolean = other is Tachiyomi && source.id == other.source.id + } + data class Hint( - @DrawableRes val icon: Int, - @StringRes val title: Int, - @StringRes val text: Int, + @field:DrawableRes val icon: Int, + @field:StringRes val title: Int, + @field:StringRes val text: Int, ) : SourceCatalogItem { override fun areItemsTheSame(other: ListModel): Boolean = other is Hint && other.title == title } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt index 58fd1424..27c47dda 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt @@ -1,15 +1,18 @@ package org.draken.usagi.settings.sources.catalog +import android.graphics.drawable.Animatable +import android.widget.ImageView import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative +import androidx.swiperefreshlayout.widget.CircularProgressDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.draken.usagi.R -import org.draken.usagi.core.model.getSummary import org.draken.usagi.core.model.getTitle import org.draken.usagi.core.ui.image.FaviconDrawable import org.draken.usagi.core.ui.list.OnListItemClickListener import org.draken.usagi.core.util.ext.drawableStart +import org.draken.usagi.core.util.ext.getThemeColor import org.draken.usagi.core.util.ext.getThemeDimensionPixelOffset import org.draken.usagi.core.util.ext.setTextAndVisible import org.draken.usagi.databinding.ItemEmptyHintBinding @@ -19,45 +22,110 @@ import androidx.appcompat.R as appcompatR fun sourceCatalogItemSourceAD(listener: OnListItemClickListener) = adapterDelegateViewBinding( - { layoutInflater, parent -> - ItemSourceCatalogBinding.inflate(layoutInflater, parent, false) - }, + { layoutInflater, parent -> ItemSourceCatalogBinding.inflate(layoutInflater, parent, false) }, ) { - binding.imageViewAdd.setOnClickListener { v -> - listener.onItemLongClick(item, v) - } - binding.root.setOnClickListener { v -> - listener.onItemClick(item, v) - } - val basePadding = - context.getThemeDimensionPixelOffset( - appcompatR.attr.listPreferredItemPaddingEnd, - binding.root.paddingStart, - ) - binding.root.updatePaddingRelative( - end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0), - ) - + binding.imageViewAdd.setOnClickListener { v -> listener.onItemLongClick(item, v) } + binding.root.setOnClickListener { v -> listener.onItemClick(item, v) } + val basePadding = context.getThemeDimensionPixelOffset(appcompatR.attr.listPreferredItemPaddingEnd, binding.root.paddingStart) + binding.root.updatePaddingRelative(end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0)) bind { binding.textViewTitle.text = item.source.getTitle(context) - binding.textViewDescription.text = item.source.getSummary(context) - binding.textViewDescription.drawableStart = - if (item.source.isBroken) { - ContextCompat.getDrawable(context, R.drawable.ic_off_small) - } else { - null - } + binding.textViewDescription.text = item.description(context) + binding.textViewDescription.drawableStart = if (item.source.isBroken) ContextCompat.getDrawable(context, R.drawable.ic_off_small) else null FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) binding.imageViewIcon.setImageAsync(item.source) + binding.imageViewAdd.isVisible = true + binding.imageViewAdd.setImageResource(R.drawable.ic_add) + binding.imageViewAdd.contentDescription = context.getString(R.string.add) + } + } + +fun sourceCatalogItemTachiyomiAD( + onClick: (SourceCatalogItem.Tachiyomi, android.view.View) -> Unit, + onInstall: (SourceCatalogItem.Tachiyomi, android.view.View) -> Unit, + onUninstall: (SourceCatalogItem.Tachiyomi, android.view.View) -> Unit, + onSideload: (SourceCatalogItem.Tachiyomi, android.view.View) -> Unit, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceCatalogBinding.inflate(layoutInflater, parent, false) }, +) { + binding.root.setOnClickListener { v -> onClick(item, v) } + binding.imageViewAdd.setOnClickListener { v -> + when { + item.hasUpdate -> onInstall(item, v) + item.isInstalled || item.isLoaded || item.isPreInstalledApk -> onUninstall(item, v) + else -> onInstall(item, v) + } + } + binding.imageViewAdd.setOnLongClickListener { v -> + if (item.isPreInstalledApk) { + true + } else { + onSideload(item, v) + true } } + val basePadding = context.getThemeDimensionPixelOffset(appcompatR.attr.listPreferredItemPaddingEnd, binding.root.paddingStart) + binding.root.updatePaddingRelative(end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0)) + bind { + val iconSize = context.resources.getDimensionPixelSize(R.dimen.card_indicator_size) + binding.imageViewIcon.layoutParams = + binding.imageViewIcon.layoutParams.apply { + width = iconSize + height = iconSize + } + binding.imageViewIcon.scaleType = ImageView.ScaleType.CENTER_CROP + val fallback = FaviconDrawable(context, R.style.FaviconDrawable, item.artifact.packageName) + + binding.imageViewIcon.errorDrawable = fallback + binding.imageViewIcon.fallbackDrawable = fallback + if (item.artifact.iconUrl.isNullOrBlank()) { + binding.imageViewIcon.setImageDrawable(fallback) + } else { + binding.imageViewIcon.setImageAsync(item.artifact.iconUrl) + } + binding.imageViewIcon.background = null + binding.textViewTitle.text = item.displayName + binding.textViewDescription.text = item.description(context) + binding.textViewDescription.drawableStart = null + + val isInstalled = item.isInstalled || item.isLoaded || item.isPreInstalledApk + (binding.imageViewAdd.drawable as? Animatable)?.stop() + if (item.isInstalling) { + binding.imageViewAdd.isEnabled = false + binding.imageViewAdd.setImageDrawable( + CircularProgressDrawable(context).also { + it.setStyle(CircularProgressDrawable.DEFAULT) + it.setColorSchemeColors(context.getThemeColor(appcompatR.attr.colorControlNormal)) + it.start() + }, + ) + binding.imageViewAdd.contentDescription = context.getString(R.string.loading_) + } else { + binding.imageViewAdd.isEnabled = true + binding.imageViewAdd.setImageResource( + when { + item.hasUpdate -> R.drawable.ic_updated + isInstalled -> R.drawable.ic_delete + else -> R.drawable.ic_download + }, + ) + binding.imageViewAdd.contentDescription = + context.getString( + when { + item.hasUpdate -> R.string.download + isInstalled -> R.string.delete + else -> R.string.add + }, + ) + } + } +} fun sourceCatalogItemHintAD() = adapterDelegateViewBinding( { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, ) { binding.buttonRetry.isVisible = false - bind { binding.icon.setImageAsync(item.icon) binding.textPrimary.setText(item.title) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt index 0c0413e5..18dff260 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt @@ -1,19 +1,36 @@ package org.draken.usagi.settings.sources.catalog +import android.annotation.SuppressLint +import android.app.DownloadManager +import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.os.Environment import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.ViewGroup import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView +import androidx.core.content.ContextCompat import androidx.core.graphics.Insets +import androidx.core.net.toUri +import androidx.core.os.ConfigurationCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch import org.draken.usagi.R import org.draken.usagi.core.model.titleResId import org.draken.usagi.core.nav.router @@ -23,8 +40,6 @@ import org.draken.usagi.core.ui.util.FadingAppbarMediator import org.draken.usagi.core.ui.util.ReversibleActionObserver import org.draken.usagi.core.ui.widgets.ChipsView import org.draken.usagi.core.ui.widgets.ChipsView.ChipModel -import org.draken.usagi.core.util.LocaleComparator -import org.draken.usagi.core.util.ext.getDisplayName import org.draken.usagi.core.util.ext.observe import org.draken.usagi.core.util.ext.observeEvent import org.draken.usagi.core.util.ext.toLocale @@ -32,6 +47,7 @@ import org.draken.usagi.databinding.ActivitySourcesCatalogBinding import org.draken.usagi.list.ui.adapter.TypedListSpacingDecoration import org.draken.usagi.main.ui.owners.AppBarOwner import tsuki.model.ContentType +import java.util.Locale @AndroidEntryPoint class SourcesCatalogActivity : @@ -44,20 +60,55 @@ class SourcesCatalogActivity : get() = viewBinding.appbar private val viewModel by viewModels() + private val downloadManager by lazy { getSystemService(DOWNLOAD_SERVICE) as DownloadManager } + private val sideloadDownloadIds = mutableSetOf() + private var fadingAppbarMediator: FadingAppbarMediator? = null + private var navigationBarBottomInset = 0 + private val downloadReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return + val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) + if (downloadId != 0L && sideloadDownloadIds.remove(downloadId)) { + openDownloadedApk(downloadId) + } + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) - val sourcesAdapter = SourcesCatalogAdapter(this) + if (viewModel.isScopedMode) { + val repoName = intent.getStringExtra(EXTRA_REPOSITORY_NAME) ?: viewModel.scopedRepositoryUrl + title = repoName + } + val sourcesAdapter = + SourcesCatalogAdapter( + nativeListener = this, + onTachiyomiClick = { item, _ -> openTachiyomiSource(item) }, + onTachiyomiInstall = { item, _ -> installTachiyomi(item) }, + onTachiyomiUninstall = { item, _ -> uninstallTachiyomi(item) }, + onTachiyomiSideload = ::showTachiyomiSideloadMenu, + ) + with(viewBinding.recyclerView) { setHasFixedSize(true) addItemDecoration(TypedListSpacingDecoration(context, false)) adapter = sourcesAdapter } viewBinding.chipsFilter.onChipClickListener = this - FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbar).bind() + fadingAppbarMediator = FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbar).also { it.bind() } viewModel.content.observe(this, sourcesAdapter) + ContextCompat.registerReceiver( + this, + downloadReceiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + ContextCompat.RECEIVER_EXPORTED, + ) viewModel.onActionDone.observeEvent( this, ReversibleActionObserver(viewBinding.recyclerView), @@ -68,8 +119,17 @@ class SourcesCatalogActivity : addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } + override fun onResume() { + super.onResume() + viewModel.refreshTachiyomiRuntime() + } + override fun onDestroy() { + fadingAppbarMediator?.unbind() + fadingAppbarMediator = null + viewBinding.chipsFilter.onChipClickListener = null viewBinding.recyclerView.adapter = null + runCatching { unregisterReceiver(downloadReceiver) } super.onDestroy() } @@ -78,6 +138,7 @@ class SourcesCatalogActivity : insets: WindowInsetsCompat, ): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + navigationBarBottomInset = bars.bottom viewBinding.recyclerView.updatePadding( left = bars.left, right = bars.right, @@ -121,6 +182,132 @@ class SourcesCatalogActivity : return false } + private fun installTachiyomi(item: SourceCatalogItem.Tachiyomi) { + if (item.isLoaded && !item.hasUpdate) return + lifecycleScope.launch { + val success = viewModel.installTachiyomi(item) + if (!success) { + showInsetSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_LONG) + } + } + } + + private fun unloadTachiyomi(item: SourceCatalogItem.Tachiyomi) { + lifecycleScope.launch { + if (!viewModel.unloadTachiyomi(item)) { + showInsetSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_SHORT) + } + } + } + + private fun uninstallTachiyomi(item: SourceCatalogItem.Tachiyomi) { + if (item.isPreInstalledApk) { + val action = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Intent.ACTION_DELETE + } else { + @Suppress("DEPRECATION") + Intent.ACTION_UNINSTALL_PACKAGE + } + startActivity(Intent(action, Uri.fromParts("package", item.artifact.packageName, null))) + } else { + lifecycleScope.launch { + val success = viewModel.uninstallTachiyomi(item) + if (!success) { + showInsetSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_SHORT) + } + } + } + } + + private fun openTachiyomiSource(item: SourceCatalogItem.Tachiyomi) { + lifecycleScope.launch { + var source = runCatching { viewModel.getImportedTachiyomiSource(item) }.getOrNull() + if (source == null && !item.isInstalled && !item.isLoaded && !item.isPreInstalledApk) { + val installed = viewModel.installTachiyomi(item) + if (installed) { + source = runCatching { viewModel.getImportedTachiyomiSource(item) }.getOrNull() + } + } + if (source != null) { + viewModel.enableTachiyomiSource(source) + router.openList(source, null, null) + } else { + showExternalSourceUnsupported() + } + } + } + + private fun showExternalSourceUnsupported() { + showInsetSnackbar(getString(R.string.unsupported_source), Snackbar.LENGTH_SHORT) + } + + private fun showTachiyomiSideloadMenu( + item: SourceCatalogItem.Tachiyomi, + anchor: View, + ) { + val menu = PopupMenu(this, anchor) + menu.menu.add(Menu.NONE, Menu.NONE, 0, R.string.download) + menu.setOnMenuItemClickListener { + downloadTachiyomiApk(item) + true + } + menu.show() + } + + private fun downloadTachiyomiApk(item: SourceCatalogItem.Tachiyomi) { + val apkUrl = + item.artifact.apkUrl + ?.trim() + ?.takeIf { it.isNotEmpty() } + if (apkUrl == null) { + showInsetSnackbar(getString(R.string.operation_not_supported), Snackbar.LENGTH_LONG) + return + } + + val uri = apkUrl.toUri() + val fileName = + uri.lastPathSegment + ?.takeIf { it.endsWith(".apk", ignoreCase = true) } + ?: "${item.artifact.packageName}-${item.artifact.versionName ?: item.artifact.versionCode ?: "latest"}.apk" + val request = + DownloadManager + .Request(uri) + .setTitle(item.displayName) + .setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, fileName) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setMimeType(APK_MIME_TYPE) + val downloadId = + runCatching { downloadManager.enqueue(request) }.getOrElse { + showInsetSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + return + } + sideloadDownloadIds += downloadId + showInsetSnackbar(getString(R.string.download), Snackbar.LENGTH_SHORT) + } + + @SuppressLint("RequestInstallPackagesPolicy") + @Suppress("DEPRECATION") + private fun openDownloadedApk(downloadId: Long) { + val apkUri = downloadManager.getUriForDownloadedFile(downloadId) + if (apkUri == null) { + showInsetSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + return + } + val installerIntent = + Intent(Intent.ACTION_INSTALL_PACKAGE).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + setDataAndType(apkUri, APK_MIME_TYPE) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + } + runCatching { startActivity(installerIntent) } + .onFailure { error -> + if (error is ActivityNotFoundException) { + showInsetSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + } + } + } + override fun onMenuItemActionExpand(item: MenuItem): Boolean { val sq = (item.actionView as? SearchView) @@ -143,20 +330,22 @@ class SourcesCatalogActivity : contentTypes: List, ) { val chips = ArrayList(contentTypes.size + 3) + if (!viewModel.isScopedMode) { + chips += + ChipModel( + title = appliedFilter.plugin?.let { key -> viewModel.plugins.firstOrNull { it.key == key }?.label ?: key.removeSuffix(".jar") } ?: getString(R.string.any), + icon = R.drawable.ic_services, + isDropdown = true, + data = "plugins", + ) + } chips += ChipModel( - title = appliedFilter.plugin?.removeSuffix(".jar") ?: getString(R.string.any), - icon = R.drawable.ic_services, - isDropdown = true, - data = "plugins", - ) - chips += - ChipModel( - title = appliedFilter.locale?.toLocale().getDisplayName(this), + title = localeDisplayName(appliedFilter.locale), icon = R.drawable.ic_language, isDropdown = true, ) - if (hasNewSources) { + if (hasNewSources && !viewModel.isScopedMode) { chips += ChipModel( title = getString(R.string._new), @@ -175,34 +364,73 @@ class SourcesCatalogActivity : viewBinding.chipsFilter.setChips(chips) } + private fun showInsetSnackbar( + message: CharSequence, + duration: Int, + ) { + showInsetSnackbar(Snackbar.make(viewBinding.recyclerView, message, duration)) + } + + private fun showInsetSnackbar(snackbar: Snackbar) { + val view = snackbar.view + val params = view.layoutParams as? ViewGroup.MarginLayoutParams + if (params != null) { + params.bottomMargin += navigationBarBottomInset + view.layoutParams = params + } + snackbar.show() + } + private fun showLocalesMenu(anchor: View) { - val locales = - viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) { - it to it?.toLocale() - } - locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second }) + val locales = viewModel.locales.value.sortedWith(compareBy { localeDisplayName(it) }) val menu = PopupMenu(this, anchor) - for ((i, lc) in locales.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(this)) + for ((i, locale) in locales.withIndex()) { + menu.menu.add(Menu.NONE, Menu.NONE, i, localeDisplayName(locale)) } menu.setOnMenuItemClickListener { - viewModel.setLocale(locales.getOrNull(it.order)?.first) + viewModel.setLocale(locales.getOrNull(it.order)) true } menu.show() } + private fun localeDisplayName(value: String?): String { + val code = value?.trim().orEmpty() + if (code.isEmpty()) return getString(R.string.all_languages) + if (code.equals("all", true)) return getString(R.string.various_languages) + + val locale = code.replace('_', '-').toLocale() + val displayLocale = ConfigurationCompat.getLocales(resources.configuration)[0] ?: Locale.getDefault() + val localizedName = locale.getDisplayLanguage(displayLocale).trim() + if (!localizedName.isLocaleCode(locale)) return localizedName + + val englishName = locale.getDisplayLanguage(Locale.ENGLISH).trim() + return englishName.takeUnless { it.isLocaleCode(locale) } ?: code.ifEmpty { getString(R.string.unknown) } + } + + private fun String.isLocaleCode(locale: Locale): Boolean { + val normalized = trim().lowercase(Locale.ROOT) + return normalized.isEmpty() || normalized == locale.language.lowercase(Locale.ROOT) || normalized == locale.toLanguageTag().lowercase(Locale.ROOT) + } + private fun showPluginsMenu(anchor: View) { val menu = PopupMenu(this, anchor) menu.menu.add(Menu.NONE, Menu.NONE, 0, getString(R.string.any)) for ((i, plugin) in viewModel.plugins.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i + 1, plugin.removeSuffix(".jar")) + menu.menu.add(Menu.NONE, Menu.NONE, i + 1, plugin.label) } menu.setOnMenuItemClickListener { - val p = if (it.order == 0) null else viewModel.plugins[it.order - 1] + val p = if (it.order == 0) null else viewModel.plugins[it.order - 1].key viewModel.setPlugin(p) + true } menu.show() } + + companion object { + const val APK_MIME_TYPE = "application/vnd.android.package-archive" + const val EXTRA_REPOSITORY_URL = SourcesCatalogViewModel.EXTRA_REPOSITORY_URL + const val EXTRA_REPOSITORY_NAME = SourcesCatalogViewModel.EXTRA_REPOSITORY_NAME + } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt index 5c6fd894..75b78588 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt @@ -1,6 +1,7 @@ package org.draken.usagi.settings.sources.catalog import android.content.Context +import android.view.View import org.draken.usagi.core.model.getTitle import org.draken.usagi.core.ui.BaseListAdapter import org.draken.usagi.core.ui.list.OnListItemClickListener @@ -10,11 +11,24 @@ import org.draken.usagi.list.ui.adapter.loadingStateAD import org.draken.usagi.list.ui.model.ListModel class SourcesCatalogAdapter( - listener: OnListItemClickListener, + nativeListener: OnListItemClickListener, + onTachiyomiClick: (SourceCatalogItem.Tachiyomi, View) -> Unit, + onTachiyomiInstall: (SourceCatalogItem.Tachiyomi, View) -> Unit, + onTachiyomiUninstall: (SourceCatalogItem.Tachiyomi, View) -> Unit, + onTachiyomiSideload: (SourceCatalogItem.Tachiyomi, View) -> Unit, ) : BaseListAdapter(), FastScroller.SectionIndexer { init { - addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(listener)) + addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(nativeListener)) + addDelegate( + ListItemType.INFO, + sourceCatalogItemTachiyomiAD( + onTachiyomiClick, + onTachiyomiInstall, + onTachiyomiUninstall, + onTachiyomiSideload, + ), + ) addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) } @@ -22,5 +36,10 @@ class SourcesCatalogAdapter( override fun getSectionText( context: Context, position: Int, - ): CharSequence? = (items.getOrNull(position) as? SourceCatalogItem.Source)?.source?.getTitle(context)?.take(1) + ): CharSequence? = + when (val item = items.getOrNull(position)) { + is SourceCatalogItem.Source -> item.source.getTitle(context).take(1) + is SourceCatalogItem.Tachiyomi -> item.displayName.take(1) + else -> null + } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt index a63f098c..3bfe3416 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt @@ -8,3 +8,8 @@ data class SourcesCatalogFilter( val isNewOnly: Boolean, val plugin: String?, ) + +data class SourceCatalogPlugin( + val key: String, + val label: String, +) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt index d0a1971d..d3893fc5 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -1,6 +1,7 @@ package org.draken.usagi.settings.sources.catalog import androidx.annotation.WorkerThread +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -9,11 +10,23 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider +import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled +import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiCatalogSource +import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact +import org.draken.tsukimix.core.util.canonicalLanguageCode import org.draken.usagi.R import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.db.TABLE_SOURCES +import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata import org.draken.usagi.core.model.MangaSourceInfo +import org.draken.usagi.core.model.unwrap +import org.draken.usagi.core.parser.external.ExternalMangaSource import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.core.ui.util.ReversibleAction @@ -29,27 +42,39 @@ import tsuki.model.MangaSource import java.util.EnumSet import java.util.Locale import javax.inject.Inject +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionManager as InstalledTachiyomiExtensionManager +import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as TachiyomiMangaSource @HiltViewModel class SourcesCatalogViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val repository: MangaSourcesRepository, db: MangaDatabase, - settings: AppSettings, + private val settings: AppSettings, + private val directManager: NativeExtManager, + private val installedTachiyomiManager: InstalledTachiyomiExtensionManager, + private val tachiyomiRuntime: TachiyomiRuntime, + private val catalogProvider: ExtensionProvider, ) : BaseViewModel() { + val scopedRepositoryUrl: String? = savedStateHandle.get(EXTRA_REPOSITORY_URL)?.takeIf { it.isNotBlank() } + val isScopedMode: Boolean get() = scopedRepositoryUrl != null + val onActionDone = MutableEventFlow() - val locales: Set = - repository.allMangaSources.mapTo(HashSet()) { it.locale }.also { - it.add(null) - } + private val tachiyomiCatalog = MutableStateFlow>(emptyList()) + private val isInitialLoading = MutableStateFlow(true) private val searchQuery = MutableStateFlow(null) + private val installingPackages = MutableStateFlow>(emptySet()) + + val locales = MutableStateFlow>(setOf(null)) + val appliedFilter = MutableStateFlow( SourcesCatalogFilter( types = emptySet(), - locale = Locale.getDefault().language.takeIf { it in locales }, + locale = null, isNewOnly = false, plugin = null, ), @@ -60,44 +85,174 @@ class SourcesCatalogViewModel .observeHasNewSources() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - val plugins = - repository.allMangaSources - .mapNotNullTo(HashSet()) { - ( - it as? org.draken.usagi.core.model.PluginMangaSource - ?: (it as? MangaSourceInfo)?.mangaSource as? org.draken.usagi.core.model.PluginMangaSource - )?.jarName - }.sorted() + val plugins: List + get() = + buildMap { + repository.allMangaSources.forEach { source -> + val plugin = + (source as? org.draken.usagi.core.model.PluginMangaSource) + ?: (source as? MangaSourceInfo)?.mangaSource as? org.draken.usagi.core.model.PluginMangaSource + plugin?.let { put(it.jarName, SourceCatalogPlugin(it.jarName, it.jarName.removeSuffix(".jar"))) } + } + directManager.installed.value.forEach { record -> + val pluginName = + DirectTachiyomiPluginMetadata.get(record.packageName) + ?: record.name + .removePrefix("Tachiyomi: ") + .removePrefix("Tachiyomi - ") + .trim() + put(record.packageName, SourceCatalogPlugin(record.packageName, pluginName)) + } + }.values.sortedBy { it.label.lowercase(Locale.ROOT) } val contentTypes = MutableStateFlow>(emptyList()) + private val tachiyomiState = + combine(directManager.installed, tachiyomiRuntime.sources, installedTachiyomiManager.sources, isInitialLoading, installingPackages) { installed, loaded, preInstalled, loading, installing -> + CatalogTachiyomiState(installed, loaded.map { it.sourceId }.toSet(), preInstalled.filter { it.isPreInstalled }, loading, installing) + } + val content: StateFlow> = combine( searchQuery, appliedFilter, db.invalidationTracker.createFlow(TABLE_SOURCES), - ) { q, f, _ -> - buildSourcesList(f, q) + tachiyomiCatalog, + tachiyomiState, + ) { query, filter, _, artifacts, (installed, loaded, preInstalled, loading, installing) -> + if (loading) listOf(LoadingState) else buildSourcesList(filter, query, artifacts, installed, loaded, preInstalled, installing) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { repository.clearNewSourcesBadge() launchJob(Dispatchers.Default) { + val cachedCatalog = catalogProvider.loadSavedCached() + tachiyomiCatalog.value = cachedCatalog contentTypes.value = getContentTypes(settings.isNsfwContentDisabled) + isInitialLoading.value = false + + launch { + runCatching { tachiyomiRuntime.ensureReady() } + } + launch { + val refreshedCatalog = catalogProvider.loadSaved() + if (refreshedCatalog != cachedCatalog) { + tachiyomiCatalog.value = refreshedCatalog + } + } } } fun performSearch(query: String?) { - searchQuery.value = query?.trim() + searchQuery.value = query } fun setLocale(value: String?) { appliedFilter.value = appliedFilter.value.copy(locale = value) } + fun refreshTachiyomiRuntime() { + launchJob(Dispatchers.Default) { + runCatching { tachiyomiRuntime.ensureReady(forceRefresh = true) } + } + } + + private fun scopedArtifacts(artifacts: List): List { + val scoped = scopedRepositoryUrl ?: return emptyList() + val normScoped = catalogProvider.normalizeUrl(scoped) ?: scoped + val filtered = artifacts.filter { (catalogProvider.normalizeUrl(it.repositoryUrl) ?: it.repositoryUrl) == normScoped } + if (filtered.isNotEmpty()) return filtered + val installed = directManager.installed.value.filter { it.repositoryUrl == scoped || it.packageName == scoped.removePrefix("local://") } + return installed.map { it.toArtifact() } + } + + suspend fun installTachiyomi(item: SourceCatalogItem.Tachiyomi): Boolean { + return try { + installingPackages.update { it + item.artifact.packageName } + if (item.isInstalled && !item.hasUpdate && !item.isLoaded) { + val source = getImportedTachiyomiSource(item) ?: return false + repository.setSourcesEnabled(setOf(source), true) + tachiyomiRuntime.ensureReady(forceRefresh = true) + true + } else { + val installed = directManager.install(item.artifact) + if (installed) { + tachiyomiRuntime.ensureReady(forceRefresh = true) + val source = getImportedTachiyomiSource(item) + if (source != null) { + repository.setSourcesEnabled(setOf(source), true) + } + } + installed + } + } catch (_: Throwable) { + false + } finally { + installingPackages.update { it - item.artifact.packageName } + } + } + + suspend fun uninstallTachiyomi(item: SourceCatalogItem.Tachiyomi): Boolean = + runCatching { + val removed = directManager.remove(item.artifact.packageName) + if (removed) { + tachiyomiRuntime.ensureReady(forceRefresh = true) + } + removed + }.getOrDefault(false) + + suspend fun unloadTachiyomi(item: SourceCatalogItem.Tachiyomi): Boolean { + val source = getImportedTachiyomiSource(item) ?: return false + repository.setSourcesEnabled(setOf(source), false) + tachiyomiRuntime.ensureReady(forceRefresh = true) + return true + } + + suspend fun getImportedTachiyomiSource(item: SourceCatalogItem.Tachiyomi): MangaSource? { + tachiyomiRuntime.ensureReady() + return tachiyomiRuntime.getSourceById(item.source.id) + ?: tachiyomiRuntime.getSourceByName(item.source.name) + ?: directManager.sources.value.firstOrNull { it.matchesCatalogItem(item) } + ?: installedTachiyomiManager.sources.value.firstOrNull { it.matchesCatalogItem(item) } + ?: repository.allMangaSources.firstOrNull { source -> + when (val unwrapped = source.unwrap()) { + is TachiyomiMangaSource -> unwrapped.matchesCatalogItem(item) || (unwrapped.pkgName == item.artifact.packageName && unwrapped.displayName.equals(item.source.name, ignoreCase = true)) + is ExternalMangaSource -> unwrapped.packageName == item.artifact.packageName + else -> false + } + } + } + + suspend fun enableTachiyomiSource(source: MangaSource) { + repository.setSourcesEnabled(setOf(source), true) + tachiyomiRuntime.ensureReady(forceRefresh = true) + } + + private fun TachiyomiMangaSource.matchesCatalogItem(item: SourceCatalogItem.Tachiyomi): Boolean = matchesCatalogSource(item.artifact, item.source) + + private fun TachiyomiMangaSource.matchesCatalogSource( + artifact: TachiyomiExtensionArtifact, + source: TachiyomiCatalogSource, + ): Boolean = + pkgName == artifact.packageName && + displayName.equals(source.name, ignoreCase = true) && + canonicalLanguageCode(locale) == canonicalLanguageCode(source.language) + + private data class CatalogTachiyomiState( + val installed: List, + val loadedSourceIds: Set, + val preInstalledSources: List, + val loading: Boolean, + val installingPackages: Set, + ) + fun addSource(source: MangaSource) { launchJob(Dispatchers.Default) { - val rollback = repository.setSourcesEnabled(setOf(source), true) + val allVariants = + repository.allMangaSources + .filter { it.title.equals(source.title, ignoreCase = true) } + .ifEmpty { listOf(source) } + val rollback = repository.setSourcesEnabled(allVariants, true) onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) } } @@ -109,11 +264,7 @@ class SourcesCatalogViewModel val filter = appliedFilter.value val types = EnumSet.noneOf(ContentType::class.java) types.addAll(filter.types) - if (isAdd) { - types.add(value) - } else { - types.remove(value) - } + if (isAdd) types.add(value) else types.remove(value) appliedFilter.value = filter.copy(types = types) } @@ -128,48 +279,169 @@ class SourcesCatalogViewModel private suspend fun buildSourcesList( filter: SourcesCatalogFilter, query: String?, + artifacts: List, + installed: List, + loadedSourceIds: Set, + preInstalledSources: List, + installingPackages: Set, ): List { - val sources = - repository.queryParserSources( - isDisabledOnly = true, - isNewOnly = filter.isNewOnly, - excludeBroken = false, - types = filter.types, - query = query, - locale = filter.locale, - plugin = filter.plugin, - sortOrder = SourcesSortOrder.ALPHABETIC, + if (!isScopedMode) { + val allDisabledSources = + repository.queryParserSources( + isDisabledOnly = true, + isNewOnly = filter.isNewOnly, + excludeBroken = false, + types = filter.types, + query = null, + locale = null, + plugin = filter.plugin, + sortOrder = SourcesSortOrder.ALPHABETIC, + ) + val allGrouped = + allDisabledSources.groupBy { source: MangaSource -> + val plugin = + (source as? org.draken.usagi.core.model.PluginMangaSource)?.jarName + ?: (source as? MangaSourceInfo)?.mangaSource as? org.draken.usagi.core.model.PluginMangaSource + (plugin ?: "") to source.title + } + + val newLocales = + buildSet { + add(null) + for ((_, variants) in allGrouped) { + if (variants.size > 1 || variants.any { it.locale.equals("all", true) }) { + add("all") + } else { + variants.forEach { add(canonicalLanguageCode(it.locale)) } + } + } + } + if (locales.value != newLocales) { + locales.value = newLocales + } + + val result = ArrayList(allGrouped.size) + for ((_, variants) in allGrouped) { + val isMultiLanguage = variants.size > 1 || variants.any { it.locale.equals("all", true) } + if (filter.locale != null) { + if (filter.locale.equals("all", true)) { + if (!isMultiLanguage) continue + } else { + if (isMultiLanguage) continue + val filterLang = canonicalLanguageCode(filter.locale) + if (variants.none { canonicalLanguageCode(it.locale) == filterLang }) continue + } + } + val preferred = + variants.firstOrNull { it.locale.equals(Locale.getDefault().language, true) } + ?: variants.firstOrNull { it.locale.equals("en", true) } + ?: variants.first() + if (!query.isNullOrBlank() && !preferred.title.contains(query, true) && !preferred.name.contains(query, true)) continue + result.add(SourceCatalogItem.Source(preferred, isMultiLanguage = isMultiLanguage)) + } + return if (result.isEmpty()) { + listOf( + if (query == null) { + SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.no_manga_sources, R.string.no_manga_sources_catalog_text) + } else { + SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.nothing_found, R.string.no_manga_sources_found) + }, + ) + } else { + result + } + } + + val scoped = scopedArtifacts(artifacts) + val newLocales = + buildSet { + add(null) + for (artifact in scoped) { + if (artifact.sources.size > 1 || artifact.sources.any { it.language.equals("all", true) }) { + add("all") + } else { + artifact.sources.forEach { add(canonicalLanguageCode(it.language)) } + } + } + } + if (locales.value != newLocales) { + locales.value = newLocales + } + val installedByPackage = installed.associateBy { it.packageName } + val tachiyomiSources = ArrayList() + for (artifact in scoped) { + val isMultiLanguage = artifact.sources.size > 1 || artifact.sources.any { it.language.equals("all", true) } + if (filter.locale != null) { + if (filter.locale.equals("all", true)) { + if (!isMultiLanguage) continue + } else { + if (isMultiLanguage) continue + val filterLang = canonicalLanguageCode(filter.locale) + if (artifact.sources.none { canonicalLanguageCode(it.language) == filterLang }) continue + } + } + val candidate = + artifact.sources.firstOrNull { matchesLocale(it.language, Locale.getDefault().language) } + ?: artifact.sources.firstOrNull { it.language.equals("en", true) } + ?: artifact.sources.firstOrNull() + ?: continue + val type = candidate.contentType + if (settings.isNsfwContentDisabled && type == ContentType.HENTAI) continue + if (filter.types.isNotEmpty() && type !in filter.types) continue + if (!query.isNullOrBlank() && !candidate.name.contains(query, true) && !artifact.name.contains(query, true) && !artifact.packageName.contains(query, true)) continue + + val customPluginName = + catalogProvider.repositoryName(artifact.repositoryUrl) + ?: DirectTachiyomiPluginMetadata + .get(artifact.packageName) + tachiyomiSources.add( + SourceCatalogItem.Tachiyomi( + source = candidate, + artifact = artifact, + installed = installedByPackage[artifact.packageName], + isLoaded = candidate.id in loadedSourceIds, + isPreInstalledApk = preInstalledSources.any { installedSource -> installedSource.matchesCatalogSource(artifact, candidate) }, + isMultiLanguage = isMultiLanguage, + isInstalling = artifact.packageName in installingPackages, + customPluginName = customPluginName, + ), ) - return if (sources.isEmpty()) { + } + return if (tachiyomiSources.isEmpty()) { listOf( if (query == null) { - SourceCatalogItem.Hint( - icon = R.drawable.ic_empty_feed, - title = R.string.no_manga_sources, - text = R.string.no_manga_sources_catalog_text, - ) + SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.no_manga_sources, R.string.no_manga_sources_catalog_text) } else { - SourceCatalogItem.Hint( - icon = R.drawable.ic_empty_feed, - title = R.string.nothing_found, - text = R.string.no_manga_sources_found, - ) + SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.nothing_found, R.string.no_manga_sources_found) }, ) } else { - sources.map { - SourceCatalogItem.Source(source = it) - } + tachiyomiSources } } + @WorkerThread + private fun matchesLocale( + sourceLanguage: String, + filterLocale: String?, + ): Boolean { + if (filterLocale == null) return true + return canonicalLanguageCode(sourceLanguage) == canonicalLanguageCode(filterLocale) + } + @WorkerThread private fun getContentTypes(isNsfwDisabled: Boolean): List { - val result = repository.allMangaSources.mapSortedByCount { it.contentType } - return if (isNsfwDisabled) { - result.filterNot { it == ContentType.HENTAI } - } else { - result + if (!isScopedMode) { + val result = repository.allMangaSources.mapSortedByCount { it.contentType }.toMutableList() + return if (isNsfwDisabled) result.filterNot { it == ContentType.HENTAI } else result } + val scoped = scopedArtifacts(tachiyomiCatalog.value) + val result = scoped.flatMap { it.sources }.mapSortedByCount { it.contentType }.toMutableList() + return if (isNsfwDisabled) result.filterNot { it == ContentType.HENTAI } else result + } + + companion object { + const val EXTRA_REPOSITORY_URL = "repository_url" + const val EXTRA_REPOSITORY_NAME = "repository_name" } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt index 710a30cd..4a8b7a06 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt @@ -17,8 +17,8 @@ import org.draken.usagi.R import org.draken.usagi.core.LocalizedAppContext import org.draken.usagi.core.db.TABLE_SOURCES import org.draken.usagi.core.model.getTitle +import org.draken.usagi.core.model.isManageableSource import org.draken.usagi.core.model.isNsfw -import org.draken.usagi.core.model.unwrap import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.util.ext.lifecycleScope import org.draken.usagi.explore.data.MangaSourcesRepository @@ -72,15 +72,7 @@ class SourcesListProducer } private suspend fun buildList(): List { - val enabledSources = - repository.getEnabledSources().filter { - val unwrapped = it.unwrap() - unwrapped !is org.draken.usagi.core.parser.external.ExternalMangaSource && - unwrapped !is org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource && - unwrapped !is org.draken.usagi.core.model.LocalMangaSource && - unwrapped !is org.draken.usagi.core.model.TestMangaSource && - unwrapped !is org.draken.usagi.core.model.UnknownMangaSource - } + val enabledSources = repository.getEnabledSources().filter { it.isManageableSource() } val pinned = repository.getPinnedSources().mapToSet { it.name } val isNsfwDisabled = settings.isNsfwContentDisabled val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL @@ -100,6 +92,7 @@ class SourcesListProducer isAvailable = !isNsfwDisabled || !it.isNsfw(), isPinned = it.name in pinned, isDisableAvailable = isDisableAvailable, + uninstallPackageName = null, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) @@ -123,6 +116,7 @@ class SourcesListProducer isAvailable = false, isPinned = it.name in pinned, isDisableAvailable = isDisableAvailable, + uninstallPackageName = null, ) } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt index 933b0bb1..d017fd03 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt @@ -1,5 +1,8 @@ package org.draken.usagi.settings.sources.manage +import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -141,7 +144,23 @@ class SourcesManageFragment : item: SourceConfigItem.SourceItem, isEnabled: Boolean, ) { - viewModel.setEnabled(item.source, isEnabled) + val packageName = item.uninstallPackageName + if (!isEnabled && packageName != null) { + uninstallExternalPackage(packageName) + } else { + viewModel.setEnabled(item.source, isEnabled) + } + } + + private fun uninstallExternalPackage(packageName: String) { + val action = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Intent.ACTION_DELETE + } else { + @Suppress("DEPRECATION") + Intent.ACTION_UNINSTALL_PACKAGE + } + startActivity(Intent(action, Uri.fromParts("package", packageName, null))) } override fun onCloseTip(tip: SourceConfigItem.Tip) { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt index eb1a2d90..0e963411 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt @@ -4,12 +4,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime import org.draken.usagi.R import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.db.removeObserverAsync import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.core.ui.util.ReversibleAction +import org.draken.usagi.core.ui.util.ReversibleHandle import org.draken.usagi.core.util.ext.MutableEventFlow import org.draken.usagi.core.util.ext.call import org.draken.usagi.explore.data.MangaSourcesRepository @@ -26,6 +28,7 @@ class SourcesManageViewModel private val settings: AppSettings, private val repository: MangaSourcesRepository, private val listProducer: SourcesListProducer, + private val tachiyomiRuntime: TachiyomiRuntime, ) : BaseViewModel() { val content = listProducer.list val onActionDone = MutableEventFlow() @@ -75,8 +78,14 @@ class SourcesManageViewModel ) { launchJob(Dispatchers.Default) { val rollback = repository.setSourcesEnabled(setOf(source), isEnabled) + tachiyomiRuntime.ensureReady(forceRefresh = true) if (!isEnabled) { - onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) + val synchronizedRollback = + ReversibleHandle { + rollback.reverse() + tachiyomiRuntime.ensureReady(forceRefresh = true) + } + onActionDone.call(ReversibleAction(R.string.source_disabled, synchronizedRollback)) } } } @@ -125,6 +134,7 @@ class SourcesManageViewModel fun disableAll() { launchJob(Dispatchers.Default) { repository.disableAllSources() + tachiyomiRuntime.ensureReady(forceRefresh = true) } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt index 198bb290..b13d393f 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt @@ -1,30 +1,49 @@ package org.draken.usagi.settings.sources.manage.plugins +import android.animation.TimeAnimator import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.Shader +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable import android.view.View +import android.view.ViewGroup import androidx.core.view.isVisible +import com.google.android.material.shape.CornerFamily import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.draken.usagi.R import org.draken.usagi.core.ui.BaseListAdapter +import org.draken.usagi.core.ui.image.FaviconDrawable +import org.draken.usagi.core.util.ext.getThemeColor import org.draken.usagi.core.util.ext.setTextAndVisible import org.draken.usagi.databinding.ItemEmptyHintBinding import org.draken.usagi.databinding.ItemSourceConfigBinding import org.draken.usagi.list.ui.adapter.ListItemType import org.draken.usagi.list.ui.model.ListModel import org.draken.usagi.settings.sources.manage.plugins.model.PluginManageItem +import com.google.android.material.R as materialR class PluginManageAdapter( onRenameClick: (PluginManageItem.Plugin) -> Unit, onUpdateClick: (PluginManageItem.Plugin) -> Unit, + onTachiyomiRenameClick: (PluginManageItem.Tachiyomi) -> Unit, + onTachiyomiLongClick: (PluginManageItem.Tachiyomi) -> Unit, + onTachiyomiClick: (PluginManageItem.Tachiyomi) -> Unit, onLongClick: (PluginManageItem.Plugin) -> Unit, onClick: (PluginManageItem.Plugin) -> Unit, isSelected: (PluginManageItem.Plugin) -> Boolean, + isTachiyomiSelected: (PluginManageItem.Tachiyomi) -> Boolean, ) : BaseListAdapter() { init { - addDelegate( - ListItemType.CHAPTER_LIST, - pluginItemDelegate(onRenameClick, onUpdateClick, onLongClick, onClick, isSelected), - ) + addDelegate(ListItemType.STATE_LOADING, loadingItemDelegate()) + addDelegate(ListItemType.CHAPTER_LIST, pluginItemDelegate(onRenameClick, onUpdateClick, onLongClick, onClick, isSelected)) + addDelegate(ListItemType.INFO, tachiyomiItemDelegate(onTachiyomiRenameClick, onTachiyomiLongClick, onTachiyomiClick, isTachiyomiSelected)) addDelegate(ListItemType.HINT_EMPTY, pluginPlaceholderDelegate()) } @@ -38,16 +57,13 @@ class PluginManageAdapter( ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, ) { - binding.imageViewIcon.setImageResource(R.drawable.ic_services) binding.imageViewIcon.background = null + binding.imageViewMenu.isVisible = true binding.imageViewMenu.setImageResource(R.drawable.ic_edit) binding.imageViewMenu.contentDescription = context.getString(R.string.rename) binding.imageViewMenu.setOnClickListener { onRenameClick(item) } - binding.imageViewMenu.setOnTouchListener(null) binding.imageViewRemove.isVisible = false - binding.imageViewRemove.setOnTouchListener(null) - binding.imageViewRemove.setOnClickListener(null) binding.imageViewAdd.setImageResource(R.drawable.ic_download) binding.imageViewAdd.contentDescription = context.getString(R.string.update) @@ -55,24 +71,123 @@ class PluginManageAdapter( onLongClick(item) true } - itemView.setOnClickListener { - onClick(item) - } + itemView.setOnClickListener { onClick(item) } bind { itemView.isSelected = isSelected(item) + binding.textViewTitle.background = null + binding.textViewTitle.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + binding.textViewDescription.background = null + binding.textViewDescription.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + + val avatarUrl = item.repository?.takeIf { it.isNotBlank() }?.let(::githubAvatarUrl) + val fallback = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.displayName) + binding.imageViewIcon.errorDrawable = fallback + binding.imageViewIcon.fallbackDrawable = fallback + if (avatarUrl.isNullOrBlank()) { + binding.imageViewIcon.setImageResource(R.drawable.ic_services) + } else { + applyRemoteIconShape(binding.imageViewIcon, context) + binding.imageViewIcon.setImageAsync(avatarUrl) + } binding.textViewTitle.text = item.displayName - val parts = ArrayList(3) - item.repository?.takeIf { it.isNotBlank() }?.let(parts::add) - item.installedTag?.takeIf { it.isNotBlank() }?.let(parts::add) - binding.textViewDescription.text = if (parts.isEmpty()) item.name else parts.joinToString(" • ") + + binding.textViewDescription.text = + buildList { + item.repository?.takeIf { it.isNotBlank() }?.let { add(repositoryLabel(it)) } ?: add(item.name) + item.installedTag?.takeIf { it.isNotBlank() }?.let(::add) + }.joinToString(" • ") binding.imageViewAdd.isVisible = item.hasUpdate - binding.imageViewAdd.setOnClickListener( - if (item.hasUpdate) View.OnClickListener { onUpdateClick(item) } else null, - ) + binding.imageViewAdd.setOnClickListener(if (item.hasUpdate) View.OnClickListener { onUpdateClick(item) } else null) } } + private fun tachiyomiItemDelegate( + onRenameClick: (PluginManageItem.Tachiyomi) -> Unit, + onLongClick: (PluginManageItem.Tachiyomi) -> Unit, + onClick: (PluginManageItem.Tachiyomi) -> Unit, + isSelected: (PluginManageItem.Tachiyomi) -> Boolean, + ) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, + ) { + binding.imageViewIcon.background = null + binding.imageViewMenu.setImageResource(R.drawable.ic_edit) + binding.imageViewMenu.contentDescription = context.getString(R.string.rename) + + binding.imageViewRemove.isVisible = false + binding.imageViewAdd.isVisible = false + itemView.setOnLongClickListener { + onLongClick(item) + true + } + itemView.setOnClickListener { onClick(item) } + + bind { + itemView.isSelected = isSelected(item) + binding.textViewTitle.background = null + binding.textViewTitle.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + binding.textViewDescription.background = null + binding.textViewDescription.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + + applyRemoteIconShape(binding.imageViewIcon, context) + + if (item.isLocal) { + val fallback = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.displayName) + binding.imageViewIcon.errorDrawable = fallback + binding.imageViewIcon.fallbackDrawable = fallback + val iconUrl = item.installed.firstOrNull()?.iconUrl + if (!iconUrl.isNullOrBlank()) { + binding.imageViewIcon.setImageAsync(iconUrl) + } else { + binding.imageViewIcon.setImageDrawable(fallback) + } + } else { + val fallback = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.repositoryLabel) + binding.imageViewIcon.errorDrawable = fallback + binding.imageViewIcon.fallbackDrawable = fallback + val avatarUrl = githubAvatarUrl(item.repositoryLabel) + binding.imageViewIcon.setImageAsync(avatarUrl) + } + + binding.imageViewMenu.isVisible = true + binding.imageViewMenu.setOnClickListener { onRenameClick(item) } + + binding.textViewTitle.text = item.displayName + binding.textViewDescription.text = + buildList { + add(item.repositoryLabel) + add(context.getString(R.string.external_source)) + if (item.hasFailures) add(context.getString(R.string.load_failed)) + }.joinToString(" • ") + } + } + + private fun loadingItemDelegate() = + adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, + ) { + binding.imageViewMenu.isVisible = false + binding.imageViewRemove.isVisible = false + binding.imageViewAdd.isVisible = false + itemView.isClickable = false + itemView.isFocusable = false + + bind { + itemView.isSelected = false + val density = context.resources.displayMetrics.density + binding.imageViewIcon.setImageDrawable(null) + binding.imageViewIcon.background = ShimmerPlaceholderDrawable(context, 8f * density) + + binding.textViewTitle.text = " " + binding.textViewTitle.layoutParams.width = (180 * density).toInt() + binding.textViewTitle.background = ShimmerPlaceholderDrawable(context, 4f * density) + + binding.textViewDescription.text = " " + binding.textViewDescription.layoutParams.width = (120 * density).toInt() + binding.textViewDescription.background = ShimmerPlaceholderDrawable(context, 4f * density) + } + } + private fun pluginPlaceholderDelegate() = adapterDelegateViewBinding( { layoutInflater, parent -> ItemEmptyHintBinding.inflate(layoutInflater, parent, false) }, @@ -83,4 +198,94 @@ class PluginManageAdapter( binding.textSecondary.setTextAndVisible(item.summaryResId ?: 0) } } + + private class ShimmerPlaceholderDrawable( + context: Context, + private val cornerRadius: Float = 8f, + ) : Drawable(), + Animatable, + TimeAnimator.TimeListener { + private val colorLow = context.getThemeColor(materialR.attr.colorSurfaceContainerLowest, Color.DKGRAY) + private val colorHigh = context.getThemeColor(materialR.attr.colorSurfaceContainerHighest, Color.LTGRAY) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val timeAnimator = TimeAnimator().apply { setTimeListener(this@ShimmerPlaceholderDrawable) } + private var phase = 0f + + override fun draw(canvas: Canvas) { + if (!isRunning) start() + val bounds = bounds + if (bounds.width() <= 0 || bounds.height() <= 0) return + + val w = bounds.width().toFloat() + val h = bounds.height().toFloat() + val bandWidth = w * 0.7f + val x = (w + bandWidth * 2) * phase - bandWidth + + paint.shader = + LinearGradient( + x, + 0f, + x + bandWidth, + 0f, + intArrayOf(colorLow, colorHigh, colorLow), + floatArrayOf(0f, 0.5f, 1f), + Shader.TileMode.CLAMP, + ) + canvas.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint) + } + + override fun onTimeUpdate( + animation: TimeAnimator?, + totalTime: Long, + deltaTime: Long, + ) { + phase = (totalTime % 1200L) / 1200f + invalidateSelf() + } + + override fun start() { + if (!timeAnimator.isRunning) timeAnimator.start() + } + + override fun stop() { + timeAnimator.cancel() + } + + override fun isRunning(): Boolean = timeAnimator.isRunning + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + } + + private fun githubAvatarUrl(repository: String): String? { + val owner = repositoryLabel(repository).substringBefore('/').trim() + return owner.takeIf { it.isNotBlank() }?.let { "https://github.com/$it.png" } + } + + private fun applyRemoteIconShape( + imageView: org.draken.usagi.core.ui.image.FaviconView, + context: Context, + ) { + imageView.shapeAppearanceModel = + imageView.shapeAppearanceModel + .toBuilder() + .setAllCorners(CornerFamily.ROUNDED, context.resources.getDimension(R.dimen.margin_small)) + .build() + } + + private fun repositoryLabel(repository: String): String = + repository + .trim() + .removeSuffix("/") + .removePrefix("https://github.com/") + .removePrefix("http://github.com/") + .removePrefix("github.com/") } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt index 72cabe9b..c0a3d567 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt @@ -4,12 +4,10 @@ import android.annotation.SuppressLint import android.os.Bundle import android.text.InputType import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.documentfile.provider.DocumentFile @@ -24,6 +22,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.draken.usagi.R +import org.draken.usagi.core.nav.router import org.draken.usagi.core.parser.PluginFileLoader import org.draken.usagi.core.ui.BaseFragment import org.draken.usagi.core.ui.dialog.buildAlertDialog @@ -52,13 +51,15 @@ class PluginsManageFragment : ActivityResultContracts.OpenDocument(), ) { uri -> if (uri != null && isAdded) { - viewModel.importPlugin( - uri = uri, - getOriginalName = { DocumentFile.fromSingleUri(requireContext().applicationContext, it)?.name }, - askName = { askText(R.string.set_plugin_name, it, R.string.plugin_name) }, - askOverwrite = ::askOverwrite, - onResult = ::showImportResult, - ) + viewLifecycleOwnerLiveData.value?.lifecycleScope?.launch { + viewModel + .importPlugin( + uri = uri, + getOriginalName = { DocumentFile.fromSingleUri(requireContext().applicationContext, it)?.name }, + askName = { askText(R.string.set_plugin_name, it, R.string.plugin_name) }, + askOverwrite = ::askOverwrite, + )?.let(::showImportResult) + } } } @@ -80,9 +81,13 @@ class PluginsManageFragment : PluginManageAdapter( onRenameClick = ::onRenameClick, onUpdateClick = ::onUpdateClick, + onTachiyomiRenameClick = ::onTachiyomiRenameClick, + onTachiyomiLongClick = ::onTachiyomiLongClick, + onTachiyomiClick = ::onTachiyomiClick, onLongClick = ::onLongClick, onClick = ::onClick, isSelected = { item -> viewModel.isSelected(item.name) }, + isTachiyomiSelected = viewModel::isTachiyomiSelected, ) with(binding.recyclerView) { setHasFixedSize(true) @@ -176,6 +181,7 @@ class PluginsManageFragment : binding.buttonFile.setIconResource(R.drawable.ic_storage) binding.buttonDir.title = getString(R.string.import_from_github) binding.buttonDir.subtitle = getString(R.string.import_github_summary) + binding.buttonDir.setIconResource(R.drawable.ic_open_external) val dialog = buildAlertDialog(requireContext()) { @@ -189,13 +195,15 @@ class PluginsManageFragment : } binding.buttonDir.setOnClickListener { dialog.dismiss() - viewModel.import( - askInput = { askText(R.string.import_from_github, "", null) }, - askSelect = ::askSelect, - askOverwrite = ::askOverwrite, - onResult = ::showImportResult, - ) + viewLifecycleOwner.lifecycleScope.launch { + viewModel + .importUrl( + askInput = { askText(R.string.import_from_github, "", R.string.import_github_summary) }, + askOverwrite = ::askOverwrite, + )?.let(::showImportResult) + } } + dialog.show() } @@ -225,6 +233,18 @@ class PluginsManageFragment : viewModel.toggleSelection(item.name) } + private fun onTachiyomiClick(item: PluginManageItem.Tachiyomi) { + if (viewModel.selectedPlugins.value.isNotEmpty()) { + viewModel.toggleTachiyomiSelection(item) + } else { + router.openPluginCatalog(item.repositoryUrl, item.displayName) + } + } + + private fun onTachiyomiLongClick(item: PluginManageItem.Tachiyomi) { + viewModel.toggleTachiyomiSelection(item) + } + private fun showDeleteSelectedConfirm() { val count = viewModel.selectedPlugins.value.size val itemsText = resources.getQuantityString(R.plurals.items, count, count) @@ -238,7 +258,7 @@ class PluginsManageFragment : val binding = viewBinding ?: return@launch Snackbar .make( - binding.recyclerView, + binding.root, if (success) R.string.removal_completed else R.string.load_failed, Snackbar.LENGTH_SHORT, ).show() @@ -247,13 +267,29 @@ class PluginsManageFragment : }.show() } + private fun onTachiyomiRenameClick(item: PluginManageItem.Tachiyomi) { + viewLifecycleOwner.lifecycleScope.launch { + val newName = askText(R.string.rename, item.displayName, R.string.plugin_name) + if (!newName.isNullOrBlank()) { + val success = viewModel.renameTachiyomi(item, newName) + val binding = viewBinding ?: return@launch + Snackbar + .make( + binding.root, + if (success) R.string.load_success else R.string.load_failed, + Snackbar.LENGTH_SHORT, + ).show() + } + } + } + private fun onUpdateClick(item: PluginManageItem.Plugin) { viewLifecycleOwner.lifecycleScope.launch { val success = viewModel.updatePlugin(item) val binding = viewBinding ?: return@launch Snackbar .make( - binding.recyclerView, + binding.root, if (success) R.string.load_success else R.string.load_failed, Snackbar.LENGTH_SHORT, ).show() @@ -278,6 +314,9 @@ class PluginsManageFragment : dialog.setOnCancelListener { if (cont.isActive) cont.resume(false) } + cont.invokeOnCancellation { + dialog.dismiss() + } dialog.show() } } @@ -308,20 +347,15 @@ class PluginsManageFragment : dialog.setOnCancelListener { if (cont.isActive) cont.resume(null) } - dialog.show() - } - } - - private suspend fun askSelect(fileNames: List): Int? = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - val dialog = - buildAlertDialog(requireContext()) { - setTitle(R.string.import_from_github) - setItems(fileNames.toTypedArray()) { _, w -> if (cont.isActive) cont.resume(w) } - setNegativeButton(android.R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) } + dialog.setOnDismissListener { + input.setCursorVisible(false) + } + cont.invokeOnCancellation { + input.post { + input.setCursorVisible(false) + if (dialog.isShowing) dialog.dismiss() } - dialog.setOnCancelListener { if (cont.isActive) cont.resume(null) } + } dialog.show() } } @@ -330,7 +364,7 @@ class PluginsManageFragment : val binding = viewBinding ?: return Snackbar .make( - binding.recyclerView, + binding.root, if (isSuccess) R.string.load_success else R.string.load_failed, Snackbar.LENGTH_LONG, ).show() diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt index f423ee8c..4dacdcee 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt @@ -9,9 +9,15 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider +import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager +import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact import org.draken.usagi.R import org.draken.usagi.core.db.MangaDatabase +import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata import org.draken.usagi.core.model.PluginKeyResolver import org.draken.usagi.core.parser.MangaDynamicRepository import org.draken.usagi.core.parser.PluginFileLoader @@ -34,6 +40,9 @@ class PluginsManageViewModel private val settings: AppSettings, private val mangaDynamicRepository: MangaDynamicRepository, private val pluginKeyResolver: PluginKeyResolver, + private val directManager: NativeExtManager, + private val tachiyomiRuntime: TachiyomiRuntime, + private val catalogProvider: ExtensionProvider, ) : BaseViewModel() { val content = MutableStateFlow>(emptyList()) val selectedPlugins = MutableStateFlow>(emptySet()) @@ -41,33 +50,47 @@ class PluginsManageViewModel @Volatile private var pluginsSnapshot = emptyList() + @Volatile + private var tachiyomiSnapshot = emptyList() + @Volatile private var query = "" + @Volatile + private var pendingImportUrl: String? = null + init { refresh() } fun refresh() { - launchLoadingJob(Dispatchers.Default) { + launchJob(Dispatchers.Default) { val localPlugins = loadPluginsLocal() pluginsSnapshot = localPlugins + refreshTachiyomiItems(catalogProvider.loadSavedCached()) publishFiltered() + launch { + runCatching { tachiyomiRuntime.ensureReady() } + val refreshed = catalogProvider.loadSaved() + refreshTachiyomiItems(refreshed) + } if (localPlugins.isNotEmpty()) { - val updatedPlugins = - coroutineScope { - localPlugins - .map { plugin -> - async { - val repo = plugin.repository ?: return@async plugin - val latest = updatePluginsProvider.requestTag(repo) ?: return@async plugin - plugin.copy(latestTag = latest) - } - }.awaitAll() - } - pluginsSnapshot = updatedPlugins - publishFiltered() + launch { + val updatedPlugins = + coroutineScope { + localPlugins + .map { plugin -> + async { + val repo = plugin.repository ?: return@async plugin + val latest = updatePluginsProvider.requestTag(repo) ?: return@async plugin + plugin.copy(latestTag = latest) + } + }.awaitAll() + } + pluginsSnapshot = updatedPlugins + publishFiltered() + } } } } @@ -123,55 +146,118 @@ class PluginsManageViewModel .installPlugin(release, PluginFileLoader.resolve(fileName)) .also { if (it) refresh() } - fun importPlugin( + suspend fun importPlugin( uri: Uri, getOriginalName: (Uri) -> String?, askName: suspend (String) -> String?, askOverwrite: suspend (String) -> Boolean, - onResult: (Boolean) -> Unit, - ) { - launchJob(Dispatchers.Default) { + ): Boolean? = + withContext(Dispatchers.Default) { val originalName = getOriginalName(uri) ?: "plugin_${System.currentTimeMillis()}.jar" + val tempDir = File(context.cacheDir, "imports").also { it.mkdirs() } + val tempFile = File(tempDir, originalName) + try { + PluginFileLoader.copyFromUri(context, uri, tempFile) + val tachiyomiInstalled = directManager.installLocal(tempFile, originalName) + if (tachiyomiInstalled) { + tachiyomiRuntime.ensureReady(forceRefresh = true) + refresh() + return@withContext true + } + } finally { + tempFile.delete() + } + val pluginName = askName(originalName.removeSuffix(".jar"))?.trim().orEmpty() - if (pluginName.isBlank()) return@launchJob + if (pluginName.isBlank()) return@withContext null val fileName = PluginFileLoader.resolve(pluginName) - if (isInstalled(fileName) && !askOverwrite(fileName)) return@launchJob + if (isInstalled(fileName) && !askOverwrite(fileName)) return@withContext null - val success = importFromUri(uri, fileName) - withContext(Dispatchers.Main) { onResult(success) } + importFromUri(uri, fileName) } - } - fun import( + suspend fun importUrl( askInput: suspend () -> String?, - askSelect: suspend (List) -> Int?, askOverwrite: suspend (String) -> Boolean, - onResult: (Boolean) -> Unit, - ) { - launchJob(Dispatchers.Default) { - val input = askInput()?.trim()?.takeIf { it.isNotBlank() } ?: return@launchJob - val releases = resolveGithubReleases(input) - if (releases.isEmpty()) { - withContext(Dispatchers.Main) { onResult(false) } - return@launchJob - } + ): Boolean? = + withContext(Dispatchers.Default) { + val input = askInput()?.trim()?.takeIf { it.isNotBlank() } ?: return@withContext null + pendingImportUrl = input + publishFiltered() + try { + val trimmed = input.trim() + // 1. Direct .jar/.apk file URL or direct release download URL + if (trimmed.endsWith(".jar", ignoreCase = true) || + trimmed.contains(".jar?", ignoreCase = true) || + trimmed.endsWith(".apk", ignoreCase = true) || + trimmed.contains(".apk?", ignoreCase = true) || + trimmed.contains("/releases/download/") + ) { + if (updatePluginsProvider.importFromUrl(trimmed)) { + refresh() + return@withContext true + } + } - val select = - if (releases.size > 1) { - val index = askSelect(releases.map { it.fileName }) ?: return@launchJob - releases.getOrNull(index) ?: return@launchJob - } else { - releases.firstOrNull() ?: return@launchJob + // 2. Fast check: Tsuki/Kotatsu plugin repository on GitHub (single latest release with .jar plugin) + val releases = resolveGithubReleases(trimmed) + val select = releases.firstOrNull() + val isTsukiPlugin = + select != null && + !select.fileName.startsWith("tachiyomi-", ignoreCase = true) && + !select.fileName.startsWith("eu.kanade.tachiyomi", ignoreCase = true) + + if (isTsukiPlugin) { + val name = PluginFileLoader.resolve(select.fileName) + if (isInstalled(name) && !askOverwrite(name)) return@withContext null + if (importFromGithub(select, name)) { + refresh() + return@withContext true + } + } + + // 3. Tachiyomi/Mihon extensions repository + val artifacts = catalogProvider.load(trimmed) + if (artifacts.isNotEmpty()) { + artifacts.forEach { catalogProvider.restorePackage(it.packageName) } + catalogProvider.saveRepository(trimmed) + refreshTachiyomiItems(catalogProvider.loadSaved() + artifacts) + return@withContext true + } + + // 4. Fallback: Any other GitHub release asset + if (select != null) { + val name = PluginFileLoader.resolve(select.fileName) + if (isInstalled(name) && !askOverwrite(name)) return@withContext null + if (importFromGithub(select, name)) { + refresh() + return@withContext true + } } - val name = PluginFileLoader.resolve(select.fileName) - if (isInstalled(name) && !askOverwrite(name)) return@launchJob + if (updatePluginsProvider.importFromUrl(trimmed)) { + refresh() + return@withContext true + } - val success = importFromGithub(select, name) - withContext(Dispatchers.Main) { onResult(success) } + return@withContext false + } finally { + pendingImportUrl = null + publishFiltered() + } + } + + suspend fun renameTachiyomi( + item: PluginManageItem.Tachiyomi, + name: String, + ): Boolean = + withContext(Dispatchers.Default) { + catalogProvider.setRepositoryName(item.repositoryUrl, name) + DirectTachiyomiPluginMetadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } + refresh() + true } - } suspend fun updatePlugin(item: PluginManageItem.Plugin): Boolean { val repository = item.repository ?: return false @@ -189,6 +275,12 @@ class PluginsManageViewModel selectedPlugins.value = if (jarName in current) current - jarName else current + jarName } + fun toggleTachiyomiSelection(item: PluginManageItem.Tachiyomi) { + toggleSelection(tachiyomiSelectionKey(item.repositoryUrl)) + } + + fun isTachiyomiSelected(item: PluginManageItem.Tachiyomi): Boolean = tachiyomiSelectionKey(item.repositoryUrl) in selectedPlugins.value + fun clearSelection() { selectedPlugins.value = emptySet() } @@ -200,16 +292,33 @@ class PluginsManageViewModel val select = selectedPlugins.value if (select.isEmpty()) return@withContext false var allSuccess = true - for (jar in select) { - try { - mangaDynamicRepository.deletePlugin(jar) - updatePluginsProvider.clearDto(jar) - } catch (_: Throwable) { - allSuccess = false + var hasLocalPlugins = false + for (key in select) { + if (key.startsWith(TACHIYOMI_SELECTION_PREFIX)) { + val repositoryUrl = key.removePrefix(TACHIYOMI_SELECTION_PREFIX) + val item = tachiyomiSnapshot.firstOrNull { it.repositoryUrl == repositoryUrl } + if (item == null) { + allSuccess = false + continue + } + val installedPackages = + (item.installed.map { it.packageName } + listOfNotNull(item.repositoryUrl.removePrefix("local://").takeIf { item.isLocal })).distinct() + installedPackages.forEach { directManager.remove(it) } + item.artifacts.forEach { catalogProvider.restorePackage(it.packageName) } + catalogProvider.removeRepository(repositoryUrl) + } else { + hasLocalPlugins = true + try { + mangaDynamicRepository.deletePlugin(key) + updatePluginsProvider.clearDto(key) + } catch (_: Throwable) { + allSuccess = false + } } } selectedPlugins.value = emptySet() - reloadPlugins(mangaDynamicRepository.getDir()) + if (hasLocalPlugins) reloadPlugins(mangaDynamicRepository.getDir()) + tachiyomiRuntime.ensureReady(forceRefresh = true) allSuccess }.also { if (it) refresh() } @@ -237,16 +346,50 @@ class PluginsManageViewModel fun isInstalled(fileName: String): Boolean = File(mangaDynamicRepository.getDir(), PluginFileLoader.resolve(fileName)).exists() + private fun refreshTachiyomiItems(artifacts: List) { + val failures = directManager.failed.value + val installed = directManager.installed.value.distinctBy { it.packageName } + val uniqueArtifacts = artifacts.distinctBy { it.packageName } + val artifactRepositoryByPackage = uniqueArtifacts.associate { it.packageName to canonicalRepository(it.repositoryUrl) } + val artifactsByRepository = uniqueArtifacts.groupBy { canonicalRepository(it.repositoryUrl) } + val installedByRepository = + installed.groupBy { record -> + record.repositoryUrl + .takeIf { it.isNotBlank() } + ?.let(::canonicalRepository) + ?: artifactRepositoryByPackage[record.packageName] + ?: INSTALLED_REPOSITORY_FALLBACK + } + val repositories = (artifactsByRepository.keys + installedByRepository.keys).distinct() + val items = + repositories.map { repositoryUrl -> + val repositoryArtifacts = artifactsByRepository[repositoryUrl].orEmpty() + val repositoryInstalled = installedByRepository[repositoryUrl].orEmpty() + val packageNames = (repositoryArtifacts.map { it.packageName } + repositoryInstalled.map { it.packageName }).toSet() + PluginManageItem.Tachiyomi( + repositoryUrl = repositoryUrl, + artifacts = repositoryArtifacts, + installed = repositoryInstalled, + failures = failures.filter { it.packageName in packageNames }, + customName = catalogProvider.repositoryName(repositoryUrl), + ) + } + DirectTachiyomiPluginMetadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } + tachiyomiSnapshot = items.sortedBy { it.displayName.lowercase() } + publishFiltered() + } + + private fun canonicalRepository(value: String): String = catalogProvider.normalizeUrl(value) ?: value.trim().removeSuffix("/") + private fun publishFiltered() { - val all = pluginsSnapshot + val all: List = + buildList { + pendingImportUrl?.let { add(PluginManageItem.Loading(it)) } + addAll(pluginsSnapshot) + addAll(tachiyomiSnapshot) + } if (all.isEmpty()) { - content.value = - listOf( - PluginManageItem.Placeholder( - titleResId = R.string.no_plugins, - summaryResId = R.string.no_plugins_summary, - ), - ) + content.value = listOf(PluginManageItem.Placeholder(R.string.no_plugins, R.string.no_plugins_summary)) return } val q = query @@ -255,14 +398,26 @@ class PluginsManageViewModel return } val filtered = - all.filter { plugin -> - plugin.name.contains(q, true) || - plugin.repository?.contains(q, true) == true - } - content.value = - filtered.ifEmpty { - listOf(PluginManageItem.Placeholder(titleResId = R.string.nothing_found, summaryResId = null)) + all.filter { item -> + when (item) { + is PluginManageItem.Loading -> { + true + } + + is PluginManageItem.Plugin -> { + item.name.contains(q, true) || item.repository?.contains(q, true) == true + } + + is PluginManageItem.Tachiyomi -> { + item.displayName.contains(q, true) || item.repositoryLabel.contains(q, true) || item.repositoryUrl.contains(q, true) + } + + is PluginManageItem.Placeholder -> { + false + } + } } + content.value = filtered.ifEmpty { listOf(PluginManageItem.Placeholder(R.string.nothing_found, null)) } } private fun loadPluginsLocal(): List { @@ -281,8 +436,15 @@ class PluginsManageViewModel } } + private fun tachiyomiSelectionKey(repositoryUrl: String): String = TACHIYOMI_SELECTION_PREFIX + repositoryUrl + private suspend fun reloadPlugins(pluginsDir: File) { mangaDynamicRepository.load(pluginsDir) pluginKeyResolver.normalize(database, savedFiltersRepository) } + + private companion object { + const val TACHIYOMI_SELECTION_PREFIX = "tachiyomi:" + const val INSTALLED_REPOSITORY_FALLBACK = "installed://direct" + } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt index fc119f08..5f2d0b87 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt @@ -6,11 +6,15 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.model.PluginKeyResolver import org.draken.usagi.core.network.BaseHttpClient @@ -20,7 +24,6 @@ import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.filter.data.SavedFiltersRepository import org.json.JSONArray import org.json.JSONObject -import tsuki.util.await import tsuki.util.runCatchingCancellable import java.io.File import java.io.IOException @@ -102,6 +105,28 @@ class UpdatePluginsProvider pluginKeyResolver.normalize(database, savedFiltersRepository) } + private suspend fun Call.await(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + if (!continuation.isCancelled) continuation.resumeWith(Result.failure(e)) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + continuation.resumeWith(Result.success(response)) + } + }, + ) + } + suspend fun requestRelease( repository: String, name: String? = null, @@ -166,10 +191,59 @@ class UpdatePluginsProvider }.getOrDefault(emptyList()) } + suspend fun importFromUrl(url: String): Boolean { + val trimmed = url.trim() + val info = parseDownloadUrl(trimmed) + if (info != null) { + val release = + ExternalPluginDto( + repository = info.repository, + tag = info.tag, + fileName = info.fileName, + downloadUrl = trimmed, + ) + return installPlugin(release, info.fileName) + } + if (trimmed.endsWith(".jar", ignoreCase = true) || trimmed.contains(".jar?", ignoreCase = true)) { + val rawName = trimmed.substringBefore('?').substringAfterLast('/') + val fileName = PluginFileLoader.resolve(rawName) + val dest = File(mangaDynamicRepository.getDir(), fileName) + return runCatchingCancellable { + val request = + Request + .Builder() + .url(trimmed) + .header("User-Agent", "Usagi-PluginDownloader/1.0") + .build() + okHttpClient.newCall(request).await().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code}") + val body = response.body + PluginFileLoader.copyFromStream(dest, body.byteStream()) + } + reloadPlugins(mangaDynamicRepository.getDir()) + true + }.getOrDefault(false) + } + return false + } + + private fun parseDownloadUrl(url: String): ParsedDownloadUrl? { + val match = DOWNLOAD_URL_REGEX.matchEntire(url.trim()) ?: return null + val (owner, repo, tag, fileName) = match.destructured + if (owner.isBlank() || repo.isBlank() || tag.isBlank() || fileName.isBlank()) return null + return ParsedDownloadUrl( + repository = "$owner/$repo", + tag = tag, + fileName = fileName, + ) + } + fun resolve(input: String): String? { val trimmed = input.trim().takeIf { it.isNotEmpty() } ?: return null return (GITHUB_URL_REGEX.matchEntire(trimmed) ?: REPOSITORY_REGEX.matchEntire(trimmed)) - ?.let { "${it.groupValues[1]}/${it.groupValues[2]}" } + ?.groupValues + ?.takeIf { it.size >= 3 } + ?.let { "${it[1]}/${it[2]}" } } fun splitRepository(repository: String): Pair? { @@ -208,7 +282,8 @@ class UpdatePluginsProvider .build() okHttpClient.newCall(request).await().use { response -> if (!response.isSuccessful) throw IOException() - PluginFileLoader.copyFromStream(dest, response.body.byteStream()) + val body = response.body + PluginFileLoader.copyFromStream(dest, body.byteStream()) } }.isSuccess @@ -302,5 +377,16 @@ class UpdatePluginsProvider Regex( """(?i)^\s*(?:https?://)?(?:www\.)?github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?(?:/.*)?\s*$""", ) + private val DOWNLOAD_URL_REGEX = + Regex( + """https?://(?:www\.)?github\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/(.+\.jar)""", + RegexOption.IGNORE_CASE, + ) } + + private data class ParsedDownloadUrl( + val repository: String, + val tag: String, + val fileName: String, + ) } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt index e73eaf80..f081b754 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt @@ -1,7 +1,12 @@ package org.draken.usagi.settings.sources.manage.plugins.model import androidx.annotation.StringRes +import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiFailure +import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled +import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact +import org.draken.usagi.R import org.draken.usagi.list.ui.model.ListModel +import java.net.URI sealed interface PluginManageItem : ListModel { data class Plugin( @@ -19,10 +24,65 @@ sealed interface PluginManageItem : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is Plugin && name == other.name } + data class Tachiyomi( + val repositoryUrl: String, + val artifacts: List, + val installed: List, + val failures: List, + val customName: String? = null, + ) : PluginManageItem { + val isLocal: Boolean + get() = repositoryUrl.startsWith("local:") || repositoryUrl.startsWith("installed:") + + val repositoryLabel: String + get() = + if (isLocal) { + R.string.local_storage.toString() + } else { + runCatching { + val uri = URI(repositoryUrl) + val segments = + uri.path + .trim('/') + .split('/') + .filter { it.isNotBlank() } + when (uri.host?.lowercase()) { + "raw.githubusercontent.com" -> segments.take(2).joinToString("/") + "github.com", "www.github.com" -> segments.take(2).joinToString("/") + else -> uri.host.orEmpty().ifBlank { repositoryUrl } + } + }.getOrDefault(repositoryUrl) + } + + val displayName: String + get() { + customName?.trim()?.takeIf { it.isNotBlank() }?.let { return it } + if (isLocal) { + installed + .firstOrNull() + ?.name + ?.takeIf { it.isNotBlank() } + ?.let { return it } + } + return repositoryLabel.substringBefore('/').ifBlank { "Tachiyomi/Mihon" } + } + + val hasFailures: Boolean + get() = failures.isNotEmpty() + + override fun areItemsTheSame(other: ListModel): Boolean = other is Tachiyomi && repositoryUrl == other.repositoryUrl + } + data class Placeholder( @field:StringRes val titleResId: Int, @field:StringRes val summaryResId: Int?, ) : PluginManageItem { override fun areItemsTheSame(other: ListModel): Boolean = other is Placeholder && titleResId == other.titleResId && summaryResId == other.summaryResId } + + data class Loading( + val targetUrl: String, + ) : PluginManageItem { + override fun areItemsTheSame(other: ListModel): Boolean = other is Loading && targetUrl == other.targetUrl + } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt index 65671e5d..f03469e2 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt @@ -14,6 +14,7 @@ sealed interface SourceConfigItem : ListModel { val isAvailable: Boolean, val isPinned: Boolean, val isDisableAvailable: Boolean, + val uninstallPackageName: String? = null, ) : SourceConfigItem { val isNsfw: Boolean get() = source.isNsfw() diff --git a/app/src/main/res/layout/fragment_settings_sources.xml b/app/src/main/res/layout/fragment_settings_sources.xml index 6de0be77..ff39adf9 100644 --- a/app/src/main/res/layout/fragment_settings_sources.xml +++ b/app/src/main/res/layout/fragment_settings_sources.xml @@ -1,5 +1,5 @@ - - + diff --git a/app/src/main/res/layout/item_source_catalog.xml b/app/src/main/res/layout/item_source_catalog.xml index 418d3fb0..25725ac4 100644 --- a/app/src/main/res/layout/item_source_catalog.xml +++ b/app/src/main/res/layout/item_source_catalog.xml @@ -65,6 +65,8 @@ android:id="@+id/imageView_add" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:minWidth="56dp" + android:minHeight="56dp" android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/add" android:padding="@dimen/margin_normal" diff --git a/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt b/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt index 4697a5b9..704ce020 100644 --- a/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt +++ b/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.LifecycleService abstract class BaseService : LifecycleService() { override fun attachBaseContext(newBase: Context) { - super.attachBaseContext(ContextCompat.getContextForLanguage(newBase)) + val applicationBase = newBase.applicationContext ?: newBase + super.attachBaseContext(ContextCompat.getContextForLanguage(applicationBase)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9301de78..b0199f05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ testRules = "1.7.0" testRunner = "1.7.0" transition = "1.6.0" tsuki = "1.0.5" -tsukimix = "1.0.5" +tsukimix = "18f5203691" usagiRpc = "1.0.2" usagi-serialization = "ad8b778c88" viewpager2 = "1.1.0" From b44ba5912a437280e4d633df075b8b9cd17916d5 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Thu, 27 Aug 2026 15:18:33 +0700 Subject: [PATCH 02/16] Re-work: Improve external/plugin compatibility, refactor code (attempt 1) I have no idea. I'm speechless... Assisted-by: claude:claude-opus-4.6, manus:manus-1.6 Co-authored-by: sang765 Co-authored-by: sang765 --- .../org/draken/usagi/core/ui/BaseService.kt | 3 +- .../kotlin/org/draken/usagi/core/AppModule.kt | 36 +- .../kotlin/org/draken/usagi/core/BaseApp.kt | 7 + .../usagi/core/db/dao/MangaSourcesDao.kt | 48 +- .../usagi/core/image/ExternalSourceFetcher.kt | 2 +- .../draken/usagi/core/model/MangaSource.kt | 169 +++--- .../core/network/webview/adblock/AdBlock.kt | 3 +- .../core/parser/MangaDynamicRepository.kt | 8 +- .../usagi/core/parser/MangaRepository.kt | 10 +- .../tachiyomi/ExternalMangaRepository.kt | 286 ++++++++++ .../core/parser/favicon/FaviconFetcher.kt | 11 +- .../tachiyomi/ExternalMangaRepository.kt | 338 ----------- .../org/draken/usagi/core/ui/BaseActivity.kt | 25 + .../org/draken/usagi/core/util/ViewBadge.kt | 3 +- .../ui/worker/DownloadSlowdownDispatcher.kt | 3 +- .../explore/data/MangaSourcesRepository.kt | 14 +- .../usagi/explore/ui/ExploreViewModel.kt | 8 +- .../usagi/list/domain/ListFilterOption.kt | 2 +- .../draken/usagi/list/ui/MangaListFragment.kt | 6 + .../org/draken/usagi/main/ui/MainActivity.kt | 13 - .../remotelist/ui/RemoteListViewModel.kt | 3 +- .../usagi/search/ui/MangaListActivity.kt | 30 +- .../draken/usagi/settings/SettingsActivity.kt | 13 +- .../settings/sources/SourceSettingsExt.kt | 4 +- .../sources/SourceSettingsFragment.kt | 11 +- .../sources/SourceSettingsViewModel.kt | 17 +- .../sources/SourcesSettingsViewModel.kt | 48 +- .../sources/catalog/SourceCatalogItem.kt | 107 ++-- .../sources/catalog/SourceCatalogItemAD.kt | 88 +-- .../sources/catalog/SourcesCatalogActivity.kt | 300 ++++------ .../sources/catalog/SourcesCatalogAdapter.kt | 20 +- .../sources/catalog/SourcesCatalogFilter.kt | 5 - .../catalog/SourcesCatalogMenuProvider.kt | 20 +- .../catalog/SourcesCatalogViewModel.kt | 529 ++++++++++-------- .../sources/manage/SourcesListProducer.kt | 2 - .../sources/manage/SourcesManageFragment.kt | 3 +- .../sources/manage/SourcesManageViewModel.kt | 10 +- .../manage/plugins/PluginManageAdapter.kt | 266 +++++---- .../manage/plugins/PluginsManageFragment.kt | 262 +++------ .../manage/plugins/PluginsManageViewModel.kt | 526 ++++++++--------- .../manage/plugins/UpdatePluginsProvider.kt | 198 ++----- .../manage/plugins/model/PluginManageItem.kt | 53 +- .../sources/model/SourceConfigItem.kt | 1 - app/src/main/res/xml/pref_source.xml | 2 +- .../org/draken/usagi/core/ui/BaseService.kt | 3 +- gradle.properties | 3 + gradle/libs.versions.toml | 2 +- 47 files changed, 1609 insertions(+), 1912 deletions(-) create mode 100644 app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt delete mode 100644 app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt diff --git a/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt b/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt index 683025b8..e68223c9 100644 --- a/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt +++ b/app/src/debug/kotlin/org/draken/usagi/core/ui/BaseService.kt @@ -7,8 +7,7 @@ import leakcanary.AppWatcher abstract class BaseService : LifecycleService() { override fun attachBaseContext(newBase: Context) { - val applicationBase = newBase.applicationContext ?: newBase - super.attachBaseContext(ContextCompat.getContextForLanguage(applicationBase)) + super.attachBaseContext(ContextCompat.getContextForLanguage(newBase)) } override fun onDestroy() { diff --git a/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt b/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt index 3ba0a79b..6c5550ff 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt @@ -30,10 +30,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import okhttp3.OkHttpClient -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider -import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager -import org.draken.tsukimix.core.parser.tachiyomi.RuntimeInitializer -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga +import org.draken.tsukimix.core.parser.external.ExtRuntime +import org.draken.tsukimix.core.parser.external.ExtensionProvider +import org.draken.tsukimix.core.parser.external.NativeExtManager +import org.draken.tsukimix.core.parser.external.RuntimeInitializer +import org.draken.tsukimix.core.parser.external.model.Manga import org.draken.usagi.BuildConfig import org.draken.usagi.backups.domain.BackupObserver import org.draken.usagi.core.db.MangaDatabase @@ -42,7 +43,6 @@ import org.draken.usagi.core.image.AvifImageDecoder import org.draken.usagi.core.image.CbzFetcher import org.draken.usagi.core.image.ExternalSourceFetcher import org.draken.usagi.core.image.MangaSourceHeaderInterceptor -import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata import org.draken.usagi.core.model.MangaSourceRegistry import org.draken.usagi.core.network.BaseHttpClient import org.draken.usagi.core.network.MangaHttpClient @@ -76,10 +76,10 @@ import tsuki.MangaLoaderContext import tsuki.network.UserAgents import javax.inject.Provider import javax.inject.Singleton -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionBridge as Bridge -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionLoader as Loader -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionManager as Manager -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime as Runtime +import org.draken.tsukimix.core.parser.external.ExtensionBridge as Bridge +import org.draken.tsukimix.core.parser.external.ExtensionLoader as Loader +import org.draken.tsukimix.core.parser.external.ExtensionManager as Manager +import org.draken.usagi.core.model.DirectExternalPluginMetadata as Metadata @Module @InstallIn(SingletonComponent::class) @@ -291,14 +291,14 @@ interface AppModule { @Provides @Singleton - fun provideTachiyomiRuntime( + fun provideExtRuntime( installedManager: Manager, directManager: NativeExtManager, catalogProvider: ExtensionProvider, database: MangaDatabase, settings: AppSettings, - ): Runtime = - Runtime( + ): ExtRuntime = + ExtRuntime( installedManager = installedManager, directManager = directManager, disabledSourceProvider = @@ -317,16 +317,16 @@ interface AppModule { }, sourcesPublisher = { sources -> - DirectTachiyomiPluginMetadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } - val nonTachiyomi = MangaSourceRegistry.sources.filterNot { it is Manga } - MangaSourceRegistry.publish(nonTachiyomi + sources) + Metadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } + val non = MangaSourceRegistry.sources.filterNot { it is Manga } + MangaSourceRegistry.publish(non + sources) }, ) @Provides @Singleton - fun providePluginRuntimeInitializer( - tachiyomiRuntime: Provider, - ): RuntimeInitializer = RuntimeInitializer { tachiyomiRuntime.get() } + fun provideExtRuntimeInitializer( + extRuntime: Provider, + ): RuntimeInitializer = RuntimeInitializer { extRuntime.get() } } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt b/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt index ead4ae18..3c11d75e 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/BaseApp.kt @@ -33,6 +33,7 @@ import org.draken.usagi.settings.work.WorkScheduleManager import java.security.Security import javax.inject.Inject import javax.inject.Provider +import org.draken.tsukimix.core.parser.external.RuntimeInitializer as RuntimeInit open class BaseApp : Application(), @@ -70,6 +71,9 @@ open class BaseApp : @Inject lateinit var pluginKeyResolver: PluginKeyResolver + @Inject + lateinit var runtimeInit: dagger.Lazy + @Inject @LocalStorageChanges lateinit var localStorageChanges: MutableSharedFlow @@ -98,6 +102,9 @@ open class BaseApp : launch { localStorageChanges.collect(localMangaIndexProvider.get()) } + launch { + runCatching { runtimeInit.get().initialize() } + } runCatching { mangaDynamicRepository.load(mangaDynamicRepository.getDir()) withContext(Dispatchers.Default) { diff --git a/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt index f1193b2e..3dbf90f6 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/db/dao/MangaSourcesDao.kt @@ -59,6 +59,12 @@ abstract class MangaSourcesDao { value: Long, ) + @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source") + abstract suspend fun setPinned( + source: String, + isPinned: Boolean, + ) + @Query("UPDATE sources SET cf_state = :state WHERE source = :source") abstract suspend fun setCfState( source: String, @@ -130,17 +136,17 @@ abstract class MangaSourcesDao { isEnabled: Boolean, ) { if (updateIsEnabled(source, isEnabled) == 0) { - upsert(newSourceEntity(source, isEnabled = isEnabled)) - } - } - - @Transaction - open suspend fun setPinned( - source: String, - isPinned: Boolean, - ) { - if (updateIsPinned(source, isPinned) == 0) { - upsert(newSourceEntity(source, isPinned = isPinned)) + val entity = + MangaSourceEntity( + source = source, + isEnabled = isEnabled, + sortKey = getMaxSortKey() + 1, + addedIn = BuildConfig.VERSION_CODE, + lastUsedAt = 0, + isPinned = false, + cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED, + ) + upsert(entity) } } @@ -164,26 +170,6 @@ abstract class MangaSourcesDao { isEnabled: Boolean, ): Int - @Query("UPDATE sources SET pinned = :isPinned WHERE source = :source") - protected abstract suspend fun updateIsPinned( - source: String, - isPinned: Boolean, - ): Int - - private suspend fun newSourceEntity( - source: String, - isEnabled: Boolean = true, - isPinned: Boolean = false, - ) = MangaSourceEntity( - source = source, - isEnabled = isEnabled, - sortKey = getMaxSortKey() + 1, - addedIn = BuildConfig.VERSION_CODE, - lastUsedAt = 0, - isPinned = isPinned, - cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED, - ) - @RawQuery(observedEntities = [MangaSourceEntity::class]) protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow> diff --git a/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt b/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt index 43064818..c3cfe203 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt @@ -15,7 +15,7 @@ import okhttp3.Request import org.draken.usagi.core.model.unwrap import org.draken.usagi.core.util.ext.mangaSourceKey import coil3.Uri as CoilUri -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as External +import org.draken.tsukimix.core.parser.external.model.Manga as External class ExternalSourceFetcher( private val httpSource: HttpSource, diff --git a/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt b/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt index 9a1f61ec..51eb274a 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt @@ -9,8 +9,8 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.text.inSpans -import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager -import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled +import org.draken.tsukimix.core.parser.external.NativeExtManager +import org.draken.tsukimix.core.parser.external.model.ExtInstalled import org.draken.usagi.R import org.draken.usagi.core.parser.external.ExternalMangaSource import org.draken.usagi.core.util.ext.getDisplayName @@ -21,84 +21,62 @@ import tsuki.model.MangaSource import tsuki.util.splitTwoParts import java.net.URI import java.util.Locale -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource +import org.draken.tsukimix.core.parser.external.model.Manga as ExternalSource data class PluginMangaSource( val delegate: MangaSource, val jarName: String, ) : MangaSource { - override val name: String - get() = "$jarName:${delegate.name}" - - val sourceName: String - get() = delegate.name - - override val locale: String - get() = delegate.locale - - override val contentType: ContentType - get() = delegate.contentType - - override val title: String - get() = delegate.title - - override val isBroken: Boolean - get() = delegate.isBroken + override val name: String get() = "$jarName:${delegate.name}" + val sourceName: String get() = delegate.name + override val locale: String get() = delegate.locale + override val contentType: ContentType get() = delegate.contentType + override val title: String get() = delegate.title + override val isBroken: Boolean get() = delegate.isBroken } -/** - * Keeps the repository label for sources loaded from direct Tachiyomi DEX artifacts. - * - * Direct artifacts preserve their repository URL in installation metadata, whereas the source - * model itself only exposes the individual extension label. The map lets shared source UI show - * the repository identity consistently without changing source IDs or parser behavior. - */ -object DirectTachiyomiPluginMetadata { +object DirectExternalPluginMetadata { @Volatile - private var namesByPackage: Map = emptyMap() + private var names: Map = emptyMap() fun update( - installed: Collection, - customNameResolver: ((String) -> String?)? = null, + installed: Collection, + resolver: ((String) -> String?)? = null, ) { - namesByPackage = - buildMap { - installed.forEach { record -> - val custom = customNameResolver?.invoke(record.repositoryUrl) + names = + installed + .mapNotNull { r -> val name = - custom ?: if (record.repositoryUrl.startsWith("local:") || record.repositoryUrl.startsWith("installed:")) { - record.name - .removePrefix("Tachiyomi: ") - .removePrefix("Tachiyomi - ") - .trim() - .ifBlank { "Local" } - } else { - deriveName(record.repositoryUrl) - } - if (name != null) put(record.packageName, name) - } - } + resolver?.invoke(r.repositoryUrl) + ?: if (r.repositoryUrl.startsWith("local:") || + r.repositoryUrl.startsWith("installed:") + ) { + r.name + .removePrefix("Extension: ") + .removePrefix("Extension - ") + .trim() + .ifBlank { "Local" } + } else { + deriveName(r.repositoryUrl) + } + name?.let { r.packageName to it } + }.toMap() } - fun get(packageName: String): String? = namesByPackage[packageName] + fun get(packageName: String): String? = names[packageName] - fun deriveName(repositoryUrl: String): String? = + fun deriveName(url: String): String? = runCatching { - val uri = URI(repositoryUrl) + val uri = URI(url) val host = uri.host?.lowercase(Locale.ROOT).orEmpty() - when { - host.endsWith(".github.io") -> { - host.removeSuffix(".github.io") - } - - else -> { - uri.path - .trim('/') - .split('/') - .firstOrNull { it.isNotBlank() } - ?.replaceFirstChar { it.titlecase(Locale.ROOT) } - ?: host.takeIf { it.isNotBlank() } - } + if (host.endsWith(".github.io")) { + host.removeSuffix(".github.io") + } else { + uri.path + .trim('/') + .split('/') + .firstOrNull { it.isNotBlank() } + ?.replaceFirstChar { it.titlecase(Locale.ROOT) } ?: host.takeIf { it.isNotBlank() } } }.getOrNull() } @@ -135,15 +113,13 @@ fun MangaSource(name: String?): MangaSource { return ExternalMangaSource(packageName = parts.first, authority = parts.second) } else if (name.startsWith("EXTERNAL_")) { NativeExtManager.getByName(name)?.let { return it } - org.draken.tsukimix.core.parser.tachiyomi.ExtensionManager + org.draken.tsukimix.core.parser.external.ExtensionManager .getByName(name) - ?.let { return it } // tachi + ?.let { return it } } MangaSourceRegistry.resolveByName(name)?.let { return it } - // Backward compatibility for loaded database items saved as '1.jar:MANGADEX' if (name.contains(':')) { - val raw = name.substringAfter(":") - MangaSourceRegistry.resolveByName(raw)?.let { return it } + MangaSourceRegistry.resolveByName(name.substringAfter(":"))?.let { return it } } return UnresolvedMangaSource(name) } @@ -220,57 +196,43 @@ fun MangaSource.getSummary(context: Context): String? { is ExternalSource -> { val type = context.getString(source.contentType.titleResId) val language = - if (source.locale.equals("all", ignoreCase = true)) { + if (source.locale.equals("all", true) || source.hasLanguageSuffix) { context.getString(R.string.various_languages) } else { source.locale.toLocaleOrNull().getDisplayName(context) } - val sourceLabel = + val label = if (source.isPreInstalled) { context.getString(R.string.external_source) } else { - DirectTachiyomiPluginMetadata.get(source.pkgName) + DirectExternalPluginMetadata.get(source.pkgName) ?: context.getString(R.string.external_source) } - - "$type, $language • $sourceLabel" + "$type, $language • $label" } is ExternalMangaSource -> { context.getString(R.string.external_source) } - else -> { - when { - this === LocalMangaSource || this === TestMangaSource || this === UnknownMangaSource -> { - null - } + LocalMangaSource, TestMangaSource, UnknownMangaSource -> { + null + } - else -> { - val type = context.getString(contentType.titleResId) - val loc = - if (locale.equals("all", ignoreCase = true) || locale.isBlank()) { - context.getString(R.string.various_languages) - } else { - locale.toLocale().getDisplayName(context) - } - context.getString(R.string.source_summary_pattern, type, loc) + else -> { + val type = context.getString(contentType.titleResId) + val loc = + if (locale.equals("all", true) || locale.isBlank()) { + context.getString(R.string.various_languages) + } else { + locale.toLocale().getDisplayName(context) } - } + context.getString(R.string.source_summary_pattern, type, loc) } } - val pluginSource = - when (this) { - is PluginMangaSource -> this - is MangaSourceInfo -> mangaSource as? PluginMangaSource - else -> null - } - return if (pluginSource != null && baseSummary != null) { - val pluginLabel = pluginSource.jarName.removeSuffix(".jar").removeSuffix(".apk") - "$baseSummary • $pluginLabel" - } else { - pluginSource?.jarName?.removeSuffix(".jar")?.removeSuffix(".apk") ?: baseSummary - } + val pluginSource = (this as? PluginMangaSource) ?: (this as? MangaSourceInfo)?.mangaSource as? PluginMangaSource + val pLabel = pluginSource?.jarName?.removeSuffix(".jar")?.removeSuffix(".apk") + return if (pLabel != null && baseSummary != null) "$baseSummary • $pLabel" else pLabel ?: baseSummary } fun MangaSource.getTitle(context: Context): String = @@ -292,11 +254,6 @@ fun SpannableStringBuilder.appendIcon( 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 - } + val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ImageSpan.ALIGN_CENTER else ImageSpan.ALIGN_BOTTOM return inSpans(ImageSpan(icon, alignment)) { append(' ') } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/network/webview/adblock/AdBlock.kt b/app/src/main/kotlin/org/draken/usagi/core/network/webview/adblock/AdBlock.kt index 78191675..6bb4d007 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/network/webview/adblock/AdBlock.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/network/webview/adblock/AdBlock.kt @@ -16,7 +16,6 @@ import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.util.ext.isNotEmpty import org.draken.usagi.core.util.ext.printStackTraceDebug import tsuki.util.await -import tsuki.util.requireBody import tsuki.util.runCatchingCancellable import java.io.File import java.net.HttpURLConnection @@ -111,7 +110,7 @@ class AdBlock dateFormat.parse(it) }.getOrNull() }?.time ?: System.currentTimeMillis() - response.requireBody().source().use { source -> + response.body.source().use { source -> file.sink().use { sink -> source.readAll(sink) } diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt index 83b63414..d317bd5f 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaDynamicRepository.kt @@ -17,7 +17,7 @@ import java.lang.reflect.Proxy import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as External +import org.draken.tsukimix.core.parser.external.model.Manga as External @Singleton class MangaDynamicRepository @@ -110,7 +110,11 @@ class MangaDynamicRepository } } - fun get(): List = PluginFileLoader.pluginsDir(context).listFiles { it.extension == "jar" }?.map { it.name } ?: emptyList() + fun get(): List = + PluginFileLoader + .pluginsDir(context) + .listFiles { it.extension == "jar" } + ?.map { it.name } ?: emptyList() fun getDir() = PluginFileLoader.pluginsDir(context) diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt index 248c6cdc..f0a9d803 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt @@ -7,7 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.external.ExtRuntime import org.draken.usagi.core.cache.MemoryContentCache import org.draken.usagi.core.model.LocalMangaSource import org.draken.usagi.core.model.MangaSourceInfo @@ -33,9 +33,9 @@ import java.lang.ref.WeakReference import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource +import org.draken.tsukimix.core.parser.external.model.Manga as ExternalSource import org.draken.usagi.core.network.imageproxy.ImageProxyInterceptor as Interceptor -import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository as ExternalRepository +import org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository as ExternalRepository interface MangaRepository { val source: MangaSource @@ -87,7 +87,7 @@ interface MangaRepository { private val contentCache: MemoryContentCache, private val mirrorSwitcher: Provider, private val mangaRepository: MangaDynamicRepository, - private val tachiyomiRuntime: Provider, + private val extRuntime: Provider, ) { private val cache = ArrayMap>() private var cacheVersion = -1 @@ -155,7 +155,7 @@ interface MangaRepository { context = context, source = source, cache = contentCache, - runtime = tachiyomiRuntime.get(), + runtime = extRuntime.get(), ) } catch (_: Throwable) { EmptyMangaRepository(source) diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt new file mode 100644 index 00000000..22f469a7 --- /dev/null +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt @@ -0,0 +1,286 @@ +package org.draken.usagi.core.parser.external.tachiyomi + +import android.content.Context +import androidx.collection.LruCache +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.draken.tsukimix.core.parser.external.ExtRuntime +import org.draken.tsukimix.core.parser.external.model.toManga +import org.draken.tsukimix.core.parser.external.model.toMangaChapter +import org.draken.tsukimix.core.parser.external.model.toMangaPage +import org.draken.tsukimix.core.parser.external.model.toSChapter +import org.draken.tsukimix.core.parser.external.model.toSManga +import org.draken.usagi.R +import org.draken.usagi.core.cache.MemoryContentCache +import org.draken.usagi.core.exceptions.CloudFlareProtectedException +import org.draken.usagi.core.parser.CachingMangaRepository +import org.draken.usagi.filter.ui.external.FilterHost +import org.draken.usagi.filter.ui.external.FilterMapper +import tsuki.model.Manga +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions +import tsuki.model.MangaPage +import tsuki.model.SortOrder +import tsuki.util.runCatchingCancellable +import tsuki.util.suspendlazy.suspendLazy +import java.io.IOException +import java.util.EnumSet +import org.draken.tsukimix.core.parser.external.ExtensionSourceSettings as externalSettings +import org.draken.tsukimix.core.parser.external.model.Manga as ExtensionMangaSource +import org.draken.usagi.core.network.imageproxy.ImageProxyInterceptor as Interceptor + +class ExternalMangaRepository( + context: Context, + override val source: ExtensionMangaSource, + cache: MemoryContentCache, + private val runtime: ExtRuntime? = null, +) : CachingMangaRepository(cache), + FilterHost { + private val appContext = context.applicationContext + val external = source.catalogueSource + private val filterList = + suspendLazy(Dispatchers.Default) { + withContext(Dispatchers.IO) { + runCatching { external.getFilterList() }.getOrDefault(FilterList()) + } + } + + private var lastOffset = -1 + private var currentPage = 1 + private val paginationLock = Any() + + @Volatile private var hasMorePages = true + + override val isDynamicFiltersSupported = true + + override suspend fun loadFilterList() = + withContext(Dispatchers.IO) { + runCatching { external.getFilterList() }.getOrDefault(FilterList()) + } + + init { + refreshDomainOverride() + } + + override val sortOrders: Set + get() = + if (source.supportsLatest) { + EnumSet.of(SortOrder.POPULARITY, SortOrder.NEWEST, SortOrder.RELEVANCE) + } else { + EnumSet.of(SortOrder.POPULARITY, SortOrder.RELEVANCE) + } + + override var defaultSortOrder = SortOrder.POPULARITY + set(value) = Unit + + override val filterCapabilities = + MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isSearchWithFiltersSupported = true, + ) + + override suspend fun getList( + offset: Int, + order: SortOrder?, + filter: MangaListFilter?, + ): List = + withContext(Dispatchers.IO) { + val page = + synchronized(paginationLock) { + if (offset == 0) { + currentPage = 1 + lastOffset = 0 + hasMorePages = true + } else if (offset > lastOffset) { + if (!hasMorePages) return@withContext emptyList() + currentPage++ + lastOffset = offset + } + currentPage + } + val q = filter?.query + val mangasPage = + try { + when { + !q.isNullOrBlank() || filter?.isEmpty() == false -> { + val f = runCatching { external.getFilterList() }.getOrDefault(FilterList()) + FilterMapper.decode(f, filter) + external.getSearchManga(page, q ?: "", f) + } + + (order ?: defaultSortOrder).isLatest() && source.supportsLatest -> { + external.getLatestUpdates(page) + } + + else -> { + external.getPopularManga(page) + } + } + } catch (e: Throwable) { + throw mapException(e) + } + hasMorePages = mangasPage.hasNextPage + val http = external as? HttpSource + mangasPage.mangas.map { it.toManga(source, fallbackUrl = http?.getMangaUrl(it).orEmpty()) } + } + + override suspend fun getDetailsImpl(manga: Manga): Manga = + withContext(Dispatchers.IO) { + val original = manga.toSManga() + val update = + try { + external.getMangaUpdate(original, emptyList(), fetchDetails = true, fetchChapters = true) + } catch (e: Throwable) { + throw mapException(e) + } + val details = update.manga.toManga(source, fallbackUrl = manga.url, fallbackTitle = manga.title) + val primary = update.chapters.asReversed().mapIndexed { i, c -> c.toMangaChapter(source, details.title, i) } + val siblings = runtime?.getSiblingSources(source)?.filter { it.sourceId != source.sourceId }.orEmpty() + val all = + if (siblings.isEmpty()) { + primary + } else { + primary + + siblings + .map { sib -> + async { + runCatching { + sib.catalogueSource + .getMangaUpdate(original, emptyList(), fetchDetails = false, fetchChapters = true) + .chapters + .asReversed() + .mapIndexed { i, c -> c.toMangaChapter(sib, details.title, i) } + }.getOrDefault(emptyList()) + } + }.awaitAll() + .flatten() + } + details.copy(chapters = all, source = source) + } + + override suspend fun getPagesImpl(chapter: MangaChapter): List = + withContext(Dispatchers.IO) { + val src = (chapter.source as? ExtensionMangaSource)?.catalogueSource ?: external + val target = chapter.source as? ExtensionMangaSource ?: source + val list = + try { + src.getPageList(chapter.toSChapter()) + } catch (e: Throwable) { + throw mapException(e) + } + list.map { p -> + val res = p.imageUrl ?: (src as? HttpSource)?.getImageUrl(p) ?: p.url + p.imageUrl = res + pageCache.put(pageCacheKey(target, res), p) + p.toMangaPage(target, res) + } + } + + override suspend fun getPageUrl(page: MangaPage): String = if (external !is HttpSource) page.url else getPageRequest(page).url.toString() + + override suspend fun getPageRequest(page: MangaPage): Request { + val src = (page.source as? ExtensionMangaSource)?.catalogueSource ?: external + val target = page.source as? ExtensionMangaSource ?: source + val http = src as? HttpSource ?: return super.getPageRequest(page) + val cp = pageCache[pageCacheKey(target, page.url)] ?: Page(0, page.url, page.url) + return withContext(Dispatchers.IO) { + try { + http + .getImageRequest(cp) + .newBuilder() + .tag(tsuki.model.MangaSource::class.java, target) + .build() + } catch (e: Throwable) { + throw mapException(e) + } + } + } + + override suspend fun getPageResponse( + page: MangaPage, + okHttp: OkHttpClient, + interceptor: Interceptor, + ): Response { + val src = (page.source as? ExtensionMangaSource)?.catalogueSource ?: external + val http = src as? HttpSource ?: return super.getPageResponse(page, okHttp, interceptor) + val target = page.source as? ExtensionMangaSource ?: source + val cp = pageCache[pageCacheKey(target, page.url)] ?: Page(0, page.url, page.url) + return withContext(Dispatchers.IO) { + try { + http.getImage(cp) + } catch (e: Throwable) { + throw mapException(e) + } + } + } + + override suspend fun getFilterOptions() = MangaListFilterOptions() + + override suspend fun getExternalFilters(): Any = filterList.get() + + fun getBrowserUrl() = externalSettings.browserUrl(appContext, source) + + fun getSettingsPreferences() = externalSettings.preferences(appContext, source) + + fun refreshDomainOverride() = externalSettings.refreshDomainOverride(appContext, source) + + fun isSlowdownEnabled() = externalSettings.isSlowdownEnabled(appContext, source) + + override suspend fun getRelatedMangaImpl(seed: Manga): List { + val http = external as? HttpSource ?: return emptyList() + if (!http.supportsRelatedMangas || http.disableRelatedMangas) return emptyList() + return runCatchingCancellable { + withContext(Dispatchers.IO) { + http.fetchRelatedMangaList(seed.toSManga()).map { it.toManga(source) } + } + }.getOrDefault(emptyList()) + } + + private fun mapException(error: Throwable): IOException { + val http = external as? HttpSource + if (http != null && error.hasMsg("cloudflare bypass failed")) { + val url = externalSettings.browserUrl(appContext, source) ?: http.getHomeUrl() + return CloudFlareProtectedException(url = url, source = source, headers = http.headers) + } + if (error is IOException) return error + val cause = error.localizedMessage ?: error.message + val msg = + if (!cause.isNullOrBlank()) { + appContext.getString(R.string.plugin_incompatible_with_cause, cause) + } else { + appContext.getString(R.string.plugin_incompatible) + } + return IOException(msg, error) + } + + private fun SortOrder.isLatest() = this == SortOrder.NEWEST || this == SortOrder.UPDATED + + private fun Throwable.hasMsg(value: String): Boolean { + var cur: Throwable? = this + while (cur != null) { + if (cur.message?.contains(value, true) == true) return true + cur = cur.cause + } + return false + } + + private companion object { + val pageCache = LruCache(500) + + fun pageCacheKey( + source: ExtensionMangaSource, + url: String, + ) = "${source.name}:$url" + } +} diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt index 2bc6356c..c6992c89 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/favicon/FaviconFetcher.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.runInterruptible import okio.FileSystem import okio.IOException import okio.Path.Companion.toOkioPath -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionSourceSettings +import org.draken.tsukimix.core.parser.external.ExtensionSourceSettings import org.draken.usagi.R import org.draken.usagi.core.exceptions.CloudFlareProtectedException import org.draken.usagi.core.model.MangaSource @@ -43,8 +43,7 @@ import tsuki.util.runCatchingCancellable import java.io.File import javax.inject.Inject import coil3.Uri as CoilUri -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as TachiyomiMangaSource -import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository as ExternalRepository +import org.draken.tsukimix.core.parser.external.model.Manga as ExtensionMangaSource class FaviconFetcher( private val uri: Uri, @@ -59,7 +58,7 @@ class FaviconFetcher( return when (val repo = mangaRepositoryFactory.create(mangaSource)) { is MangaParserRepository -> fetchParserFavicon(repo) is ExternalMangaRepository -> fetchPluginIcon(repo) - is ExternalRepository -> fetchTachiyomiIcon(repo.source) + is org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository -> fetchExternalIcon(repo.source) is EmptyMangaRepository -> throwNSEE(null) is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options) else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}") @@ -113,7 +112,7 @@ class FaviconFetcher( return fetchPackageIcon(source.packageName, source.authority) } - private suspend fun fetchTachiyomiIcon(source: TachiyomiMangaSource): FetchResult { + private suspend fun fetchExternalIcon(source: ExtensionMangaSource): FetchResult { val configuredUrl = runCatchingCancellable { ExtensionSourceSettings.browserUrl(options.context, source) @@ -131,7 +130,7 @@ class FaviconFetcher( private suspend fun fetchDomainFavicon( configuredUrl: String, - source: TachiyomiMangaSource, + source: ExtensionMangaSource, ): FetchResult { val sizePx = maxOf( diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt deleted file mode 100644 index d1a748da..00000000 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt +++ /dev/null @@ -1,338 +0,0 @@ -package org.draken.usagi.core.parser.tachiyomi - -import android.content.Context -import androidx.collection.LruCache -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.online.HttpSource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime -import org.draken.tsukimix.core.parser.tachiyomi.model.toManga -import org.draken.tsukimix.core.parser.tachiyomi.model.toMangaChapter -import org.draken.tsukimix.core.parser.tachiyomi.model.toMangaPage -import org.draken.tsukimix.core.parser.tachiyomi.model.toSChapter -import org.draken.tsukimix.core.parser.tachiyomi.model.toSManga -import org.draken.usagi.core.cache.MemoryContentCache -import org.draken.usagi.core.exceptions.CloudFlareProtectedException -import org.draken.usagi.core.parser.CachingMangaRepository -import org.draken.usagi.filter.ui.external.FilterHost -import org.draken.usagi.filter.ui.external.FilterMapper -import tsuki.model.Manga -import tsuki.model.MangaChapter -import tsuki.model.MangaListFilter -import tsuki.model.MangaListFilterCapabilities -import tsuki.model.MangaListFilterOptions -import tsuki.model.MangaPage -import tsuki.model.SortOrder -import tsuki.util.runCatchingCancellable -import tsuki.util.suspendlazy.suspendLazy -import java.io.IOException -import java.util.EnumSet -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionSourceSettings as externalSettings -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as TachiyomiMangaSource -import org.draken.usagi.core.network.imageproxy.ImageProxyInterceptor as Interceptor - -class ExternalMangaRepository( - context: Context, - override val source: TachiyomiMangaSource, - cache: MemoryContentCache, - private val runtime: TachiyomiRuntime? = null, -) : CachingMangaRepository(cache), - FilterHost { - private val appContext = context.applicationContext - val external = source.catalogueSource - private val filterListLazy = - suspendLazy(Dispatchers.Default) { - withContext(Dispatchers.IO) { - try { - external.getFilterList() - } catch (_: Throwable) { - FilterList() - } - } - } - - private var lastOffset = -1 - private var currentPage = 1 - private val paginationLock = Any() - - @Volatile private var hasMorePages = true - - override val isDynamicFiltersSupported: Boolean - get() = true - - override suspend fun loadFilterList(): FilterList = - withContext(Dispatchers.IO) { - try { - external.getFilterList() - } catch (_: Throwable) { - FilterList() - } - } - - init { - refreshDomainOverride() - } - - override val sortOrders: Set - get() = - if (source.supportsLatest) { - EnumSet.of(SortOrder.POPULARITY, SortOrder.NEWEST, SortOrder.RELEVANCE) - } else { - EnumSet.of(SortOrder.POPULARITY, SortOrder.RELEVANCE) - } - - override var defaultSortOrder: SortOrder - get() = SortOrder.POPULARITY - set(value) = Unit - - override val filterCapabilities: MangaListFilterCapabilities - get() = - MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = true, - isSearchWithFiltersSupported = true, - ) - - override suspend fun getList( - offset: Int, - order: SortOrder?, - filter: MangaListFilter?, - ): List = - withContext(Dispatchers.IO) { - val page = - synchronized(paginationLock) { - if (offset == 0) { - currentPage = 1 - lastOffset = 0 - hasMorePages = true - } else if (offset > lastOffset) { - if (!hasMorePages) return@withContext emptyList() - currentPage++ - lastOffset = offset - } - currentPage - } - - val query = filter?.query - val mangasPage = - try { - when { - !query.isNullOrBlank() || filter?.isEmpty() == false -> { - val mihonFilters = - try { - external.getFilterList() - } catch (_: Throwable) { - FilterList() - } - FilterMapper.decode(mihonFilters, filter) - external.getSearchManga(page, query ?: "", mihonFilters) - } - - (order ?: defaultSortOrder).isLatest() && source.supportsLatest -> { - external.getLatestUpdates(page) - } - - else -> { - external.getPopularManga(page) - } - } - } catch (e: Throwable) { - throw mapException(e) - } - hasMorePages = mangasPage.hasNextPage - val httpSource = external as? HttpSource - mangasPage.mangas.map { sManga -> - sManga.toManga( - source = source, - fallbackUrl = httpSource?.getMangaUrl(sManga).orEmpty(), - ) - } - } - - override suspend fun getDetailsImpl(manga: Manga): Manga = - withContext(Dispatchers.IO) { - val original = manga.toSManga() - val update = - try { - external.getMangaUpdate(original, emptyList(), fetchDetails = true, fetchChapters = true) - } catch (e: Throwable) { - throw mapException(e) - } - val details = update.manga.toManga(source, fallbackUrl = manga.url, fallbackTitle = manga.title) - val primaryChapters = - update.chapters.asReversed().mapIndexed { index, chapter -> - chapter.toMangaChapter(source, details.title, index) - } - val siblings = runtime?.getSiblingSources(source)?.filter { it.sourceId != source.sourceId }.orEmpty() - val allChapters = - if (siblings.isEmpty()) { - primaryChapters - } else { - val siblingChapters = - siblings - .map { sibling -> - async { - runCatching { - val siblingUpdate = - sibling.catalogueSource.getMangaUpdate( - original, - emptyList(), - fetchDetails = false, - fetchChapters = true, - ) - siblingUpdate.chapters.asReversed().mapIndexed { index, chapter -> - chapter.toMangaChapter(sibling, details.title, index) - } - }.getOrDefault(emptyList()) - } - }.awaitAll() - .flatten() - primaryChapters + siblingChapters - } - details.copy( - chapters = allChapters, - source = source, - ) - } - - override suspend fun getPagesImpl(chapter: MangaChapter): List = - withContext(Dispatchers.IO) { - val chapterSource = (chapter.source as? TachiyomiMangaSource)?.catalogueSource ?: external - val targetSource = chapter.source as? TachiyomiMangaSource ?: source - val pageList = - try { - chapterSource.getPageList(chapter.toSChapter()) - } catch (e: Throwable) { - throw mapException(e) - } - pageList.map { page -> - val resolved = - page.imageUrl - ?: (chapterSource as? HttpSource)?.getImageUrl(page) - ?: page.url - page.imageUrl = resolved - pageCache.put(pageCacheKey(targetSource, resolved), page) - page.toMangaPage(targetSource, resolved) - } - } - - override suspend fun getPageUrl(page: MangaPage): String { - if (external !is HttpSource) return page.url - return getPageRequest(page).url.toString() - } - - override suspend fun getPageRequest(page: MangaPage): Request { - val pageSource = (page.source as? TachiyomiMangaSource)?.catalogueSource ?: external - val targetSource = page.source as? TachiyomiMangaSource ?: source - val httpSource = pageSource as? HttpSource ?: return super.getPageRequest(page) - val tachiyomiPage = - pageCache[pageCacheKey(targetSource, page.url)] - ?: Page(index = 0, url = page.url, imageUrl = page.url) - return withContext(Dispatchers.IO) { - val imageRequest = - try { - httpSource.getImageRequest(tachiyomiPage) - } catch (e: Throwable) { - throw mapException(e) - } - imageRequest - .newBuilder() - .tag(tsuki.model.MangaSource::class.java, targetSource) - .build() - } - } - - override suspend fun getPageResponse( - page: MangaPage, - okHttp: OkHttpClient, - interceptor: Interceptor, - ): Response { - val pageSource = (page.source as? TachiyomiMangaSource)?.catalogueSource ?: external - val targetSource = page.source as? TachiyomiMangaSource ?: source - val httpSource = pageSource as? HttpSource ?: return super.getPageResponse(page, okHttp, interceptor) - val tachiyomiPage = - pageCache[pageCacheKey(targetSource, page.url)] - ?: Page(index = 0, url = page.url, imageUrl = page.url) - return withContext(Dispatchers.IO) { - try { - httpSource.getImage(tachiyomiPage) - } catch (e: Throwable) { - throw mapException(e) - } - } - } - - override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions() - - override suspend fun getExternalFilters(): Any = filterListLazy.get() - - fun getBrowserUrl(): String? = externalSettings.browserUrl(appContext, source) - - fun getSettingsPreferences() = externalSettings.preferences(appContext, source) - - fun refreshDomainOverride() { - externalSettings.refreshDomainOverride(appContext, source) - } - - fun isSlowdownEnabled(): Boolean = externalSettings.isSlowdownEnabled(appContext, source) - - override suspend fun getRelatedMangaImpl(seed: Manga): List { - val httpSource = external as? HttpSource ?: return emptyList() - return if (!httpSource.supportsRelatedMangas || httpSource.disableRelatedMangas) { - emptyList() - } else { - runCatchingCancellable { - withContext(Dispatchers.IO) { - httpSource.fetchRelatedMangaList(seed.toSManga()).map { it.toManga(source) } - } - }.getOrDefault(emptyList()) - } - } - - private fun mapException(error: Throwable): IOException { - val httpSource = external as? HttpSource - if (httpSource != null && error.hasMessage("cloudflare bypass failed")) { - return CloudFlareProtectedException( - url = externalSettings.browserUrl(appContext, source) ?: httpSource.getHomeUrl(), - source = source, - headers = httpSource.headers, - ) - } - if (error is IOException) return error - val causeMessage = error.localizedMessage ?: error.message - val message = - if (!causeMessage.isNullOrBlank()) { - appContext.getString(org.draken.usagi.R.string.plugin_incompatible_with_cause, causeMessage) - } else { - appContext.getString(org.draken.usagi.R.string.plugin_incompatible) - } - return IOException(message, error) - } - - private fun SortOrder.isLatest(): Boolean = this == SortOrder.NEWEST || this == SortOrder.UPDATED - - private fun Throwable.hasMessage(value: String): Boolean { - var current: Throwable? = this - while (current != null) { - if (current.message?.contains(value, true) == true) return true - current = current.cause - } - return false - } - - private companion object { - val pageCache = LruCache(500) - - fun pageCacheKey( - source: TachiyomiMangaSource, - url: String, - ): String = "${source.name}:$url" - } -} diff --git a/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt b/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt index fccadec0..923a77da 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt @@ -182,4 +182,29 @@ abstract class BaseActivity : } protected fun hasViewBinding() = ::viewBinding.isInitialized + + @CallSuper + override fun onDestroy() { + window?.peekDecorView()?.let { decor -> + decor.handler?.removeCallbacksAndMessages(null) + cleanViewTree(decor) + } + if (hasViewBinding()) { + viewBinding.root.handler?.removeCallbacksAndMessages(null) + cleanViewTree(viewBinding.root) + } + super.onDestroy() + } + + private fun cleanViewTree(view: View) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + view.tooltipText = null + } + view.clearAnimation() + if (view is android.view.ViewGroup) { + for (i in 0 until view.childCount) { + cleanViewTree(view.getChildAt(i)) + } + } + } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt b/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt index 62f09439..4c56ad90 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt @@ -49,8 +49,9 @@ class ViewBadge( } override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) + owner.lifecycle.removeObserver(this) clearBadge() + super.onDestroy(owner) } fun setMaxCharacterCount(value: Int) { diff --git a/app/src/main/kotlin/org/draken/usagi/download/ui/worker/DownloadSlowdownDispatcher.kt b/app/src/main/kotlin/org/draken/usagi/download/ui/worker/DownloadSlowdownDispatcher.kt index 8e64c645..396518f9 100644 --- a/app/src/main/kotlin/org/draken/usagi/download/ui/worker/DownloadSlowdownDispatcher.kt +++ b/app/src/main/kotlin/org/draken/usagi/download/ui/worker/DownloadSlowdownDispatcher.kt @@ -5,7 +5,6 @@ import androidx.collection.MutableObjectLongMap import kotlinx.coroutines.delay import org.draken.usagi.core.parser.MangaParserRepository import org.draken.usagi.core.parser.MangaRepository -import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository import tsuki.model.MangaSource import javax.inject.Inject import javax.inject.Singleton @@ -35,7 +34,7 @@ class DownloadSlowdownDispatcher private fun isSlowdownEnabled(source: MangaSource): Boolean = when (val repo = mangaRepositoryFactory.create(source)) { is MangaParserRepository -> repo.isSlowdownEnabled() - is ExternalMangaRepository -> repo.isSlowdownEnabled() + is org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository -> repo.isSlowdownEnabled() else -> false } } diff --git a/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt index dbbe1ef3..db452bf8 100644 --- a/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/explore/data/MangaSourcesRepository.kt @@ -19,14 +19,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.external.ExtRuntime import org.draken.tsukimix.core.util.canonicalLanguageCode import org.draken.usagi.BuildConfig import org.draken.usagi.core.LocalizedAppContext import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.db.dao.MangaSourcesDao import org.draken.usagi.core.db.entity.MangaSourceEntity -import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata import org.draken.usagi.core.model.MangaSourceInfo import org.draken.usagi.core.model.MangaSourceRegistry import org.draken.usagi.core.model.PluginMangaSource @@ -50,6 +49,7 @@ import tsuki.util.mapToSet import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import org.draken.usagi.core.model.DirectExternalPluginMetadata as Metadata import org.draken.usagi.core.model.MangaSource as createMangaSource @Singleton @@ -59,7 +59,7 @@ class MangaSourcesRepository @LocalizedAppContext private val context: Context, private val db: MangaDatabase, private val settings: AppSettings, - private val tachiyomiRuntime: dagger.Lazy? = null, + private val extRuntime: dagger.Lazy? = null, ) { private var assimilatedVersion = -1 private val dao: MangaSourcesDao @@ -67,7 +67,7 @@ class MangaSourcesRepository init { processLifecycleScope.launch(Dispatchers.IO) { - runCatching { tachiyomiRuntime?.get()?.ensureReady() } + runCatching { extRuntime?.get()?.ensureReady() } assimilateNewSources() merge(MangaSourceRegistry.updates, observeExternalSources()).collect { assimilateNewSources() @@ -171,8 +171,8 @@ class MangaSourcesRepository if (ps != null) { ps.jarName == plugin } else { - val ext = it.unwrap() as? org.draken.tsukimix.core.parser.tachiyomi.model.Manga - ext?.pkgName == plugin || DirectTachiyomiPluginMetadata.get(ext?.pkgName.orEmpty()) == plugin + val ext = it.unwrap() as? org.draken.tsukimix.core.parser.external.model.Manga + ext?.pkgName == plugin || Metadata.get(ext?.pkgName.orEmpty()) == plugin } } } @@ -416,7 +416,7 @@ class MangaSourcesRepository ) { launch(Dispatchers.Default) { try { - tachiyomiRuntime?.get()?.ensureReady(forceRefresh = true) + extRuntime?.get()?.ensureReady(forceRefresh = true) } catch (_: Throwable) { } } diff --git a/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt index f21ab22d..caea06b6 100644 --- a/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/explore/ui/ExploreViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.external.ExtRuntime import org.draken.usagi.R import org.draken.usagi.core.model.MangaSourceInfo import org.draken.usagi.core.os.AppShortcutManager @@ -50,7 +50,7 @@ class ExploreViewModel private val suggestionRepository: SuggestionRepository, private val exploreRepository: ExploreRepository, private val sourcesRepository: MangaSourcesRepository, - private val tachiyomiRuntime: TachiyomiRuntime, + private val extRuntime: ExtRuntime, private val shortcutManager: AppShortcutManager, private val updatePluginsProvider: UpdatePluginsProvider, ) : BaseViewModel() { @@ -115,11 +115,11 @@ class ExploreViewModel fun disableSources(sources: Collection) { launchJob(Dispatchers.Default) { val rollback = sourcesRepository.setSourcesEnabled(sources, isEnabled = false) - tachiyomiRuntime.ensureReady(forceRefresh = true) + extRuntime.ensureReady(forceRefresh = true) val synchronizedRollback = ReversibleHandle { rollback.reverse() - tachiyomiRuntime.ensureReady(forceRefresh = true) + extRuntime.ensureReady(forceRefresh = true) } val message = if (sources.size == 1) R.string.source_disabled else R.string.sources_disabled onActionDone.call(ReversibleAction(message, synchronizedRollback)) diff --git a/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt b/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt index 244bfb4e..322f99fe 100644 --- a/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt +++ b/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt @@ -11,7 +11,7 @@ import org.draken.usagi.core.parser.external.ExternalMangaSource import org.draken.usagi.core.parser.favicon.faviconUri import tsuki.model.MangaSource import tsuki.model.MangaTag -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource +import org.draken.tsukimix.core.parser.external.model.Manga as ExternalSource sealed interface ListFilterOption { @get:StringRes diff --git a/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt index 38561f94..84a1083c 100644 --- a/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt @@ -151,6 +151,12 @@ abstract class MangaListFragment : } override fun onDestroyView() { + viewBinding?.recyclerView?.let { rv -> + paginationListener?.let { rv.removeOnScrollListener(it) } + rv.adapter = null + rv.fastScroller.setFastScrollListener(null) + } + viewBinding?.swipeRefreshLayout?.setOnRefreshListener(null) listAdapter = null paginationListener = null selectionController = null diff --git a/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt b/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt index 75ade787..e1074166 100644 --- a/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt @@ -48,7 +48,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.draken.tsukimix.core.parser.tachiyomi.RuntimeInitializer import org.draken.usagi.R import org.draken.usagi.backups.ui.periodical.PeriodicalBackupService import org.draken.usagi.browser.AdListUpdateService @@ -101,9 +100,6 @@ class MainActivity : @Inject lateinit var settings: AppSettings - @Inject - lateinit var runtimeInit: RuntimeInitializer - private val viewModel by viewModels() private val searchSuggestionViewModel by viewModels() private val voiceInputLauncher = @@ -207,15 +203,6 @@ class MainActivity : initSearch() } - override fun onPostResume() { - super.onPostResume() - window.decorView.post { - lifecycleScope.launch { - runtimeInit.initialize() - } - } - } - override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) adjustSearchUI(viewBinding.searchView.isShowing) diff --git a/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt index 880e9ebb..f4bd1127 100644 --- a/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt @@ -24,7 +24,6 @@ import org.draken.usagi.core.model.distinctById import org.draken.usagi.core.parser.MangaDataRepository import org.draken.usagi.core.parser.MangaParserRepository import org.draken.usagi.core.parser.MangaRepository -import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.prefs.ListMode import org.draken.usagi.core.util.ext.MutableEventFlow @@ -238,7 +237,7 @@ open class RemoteListViewModel fun getBrowserUrl(): String? = when (val repo = repository) { is MangaParserRepository -> "https://${repo.domain}" - is ExternalMangaRepository -> repo.getBrowserUrl() + is org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository -> repo.getBrowserUrl() else -> null } } diff --git a/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt index 967664f5..4f92fab6 100644 --- a/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.external.ExtRuntime import org.draken.usagi.R import org.draken.usagi.core.model.LocalMangaSource import org.draken.usagi.core.model.MangaSource @@ -58,7 +58,7 @@ import tsuki.model.SortOrder import javax.inject.Inject import kotlin.math.absoluteValue import com.google.android.material.R as materialR -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource +import org.draken.tsukimix.core.parser.external.model.Manga as ExternalSource import org.draken.usagi.filter.ui.external.sheet.FilterSheetFragment as ExternalSheetFragment @AndroidEntryPoint @@ -80,7 +80,9 @@ class MangaListActivity : private lateinit var source: MangaSource @Inject - lateinit var tachiyomiRuntime: TachiyomiRuntime + lateinit var extRuntime: ExtRuntime + + private var filterBadge: ViewBadge? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -108,6 +110,15 @@ class MangaListActivity : } } + override fun onDestroy() { + if (viewBinding.containerFilterHeader != null) { + viewBinding.appbar.removeOnOffsetChangedListener(this) + } + viewBinding.buttonOrder?.setOnClickListener(null) + filterBadge = null + super.onDestroy() + } + override fun isNsfwContent(): Flow = flowOf(source.isNsfw()) override fun onOffsetChanged( @@ -213,7 +224,7 @@ class MangaListActivity : } } - private fun resolve(source: MangaSource): MangaSource = (source as? ExternalSource)?.let(tachiyomiRuntime::resolve) ?: source + private fun resolve(source: MangaSource): MangaSource = (source as? ExternalSource)?.let(extRuntime::resolve) ?: source private fun initFilter(filterOwner: FilterCoordinator.Owner) { if (viewBinding.containerSide != null) { @@ -229,8 +240,11 @@ class MangaListActivity : val filter = filterOwner.filterCoordinator val chipSort = viewBinding.buttonOrder if (chipSort != null) { - val filterBadge = ViewBadge(chipSort, this) - filterBadge.setMaxCharacterCount(0) + val badge = + filterBadge ?: ViewBadge(chipSort, this).also { + it.setMaxCharacterCount(0) + filterBadge = it + } val isDynamic = filter.isDynamicFilter filter.observe().observe(this) { snapshot -> if (isDynamic) { @@ -239,11 +253,11 @@ class MangaListActivity : ?: snapshot.sortLabel ?: getString(snapshot.sortOrder.titleRes) chipSort.isVisible = true - filterBadge.counter = + badge.counter = if (snapshot.listFilter.tags.any { !it.key.startsWith(FilterMapper.SORT_KEY_PREFIX) }) 1 else 0 } else { chipSort.setTextAndVisible(snapshot.sortOrder.titleRes) - filterBadge.counter = if (snapshot.listFilter.hasNonSearchOptions()) 1 else 0 + badge.counter = if (snapshot.listFilter.hasNonSearchOptions()) 1 else 0 } } } else { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/SettingsActivity.kt b/app/src/main/kotlin/org/draken/usagi/settings/SettingsActivity.kt index f2b6861d..a57e4b57 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/SettingsActivity.kt @@ -53,6 +53,10 @@ class SettingsActivity : get() = viewBinding.containerMaster != null private val viewModel: SettingsSearchViewModel by viewModels() + private val backStackListener = + androidx.fragment.app.FragmentManager.OnBackStackChangedListener { + invalidateOptionsMenu() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -71,9 +75,12 @@ class SettingsActivity : } viewModel.isSearchActive.observe(this, ::toggleSearchMode) viewModel.onNavigateToPreference.observeEvent(this, ::navigateToPreference) - supportFragmentManager.addOnBackStackChangedListener { - invalidateOptionsMenu() - } + supportFragmentManager.addOnBackStackChangedListener(backStackListener) + } + + override fun onDestroy() { + supportFragmentManager.removeOnBackStackChangedListener(backStackListener) + super.onDestroy() } override fun onApplyWindowInsets( diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt index dbb55945..4c046127 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt @@ -9,12 +9,12 @@ import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.preferenceKey -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionSourceSettings +import org.draken.tsukimix.core.parser.external.ExtensionSourceSettings import org.draken.usagi.R import org.draken.usagi.core.parser.EmptyMangaRepository import org.draken.usagi.core.parser.MangaParserRepository import org.draken.usagi.core.parser.MangaRepository -import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository +import org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository import org.draken.usagi.core.prefs.SourceSettings import org.draken.usagi.settings.utils.AutoCompleteTextViewPreference import org.draken.usagi.settings.utils.EditTextBindListener diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt index 64143a35..56072802 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsFragment.kt @@ -10,7 +10,7 @@ import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.filterNotNull -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.external.ExtRuntime import org.draken.usagi.R import org.draken.usagi.core.exceptions.resolve.SnackbarErrorObserver import org.draken.usagi.core.model.getTitle @@ -19,7 +19,6 @@ import org.draken.usagi.core.nav.AppRouter import org.draken.usagi.core.nav.router import org.draken.usagi.core.parser.EmptyMangaRepository import org.draken.usagi.core.parser.MangaParserRepository -import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.prefs.SourceSettings import org.draken.usagi.core.ui.BasePreferenceFragment @@ -37,7 +36,7 @@ class SourceSettingsFragment : private val viewModel: SourceSettingsViewModel by viewModels() @Inject - lateinit var tachiyomiRuntime: TachiyomiRuntime + lateinit var extRuntime: ExtRuntime override fun onResume() { super.onResume() @@ -54,13 +53,13 @@ class SourceSettingsFragment : preferenceManager.sharedPreferencesName = SourceSettings.prefsName(viewModel.source) addPreferencesFromResource(R.xml.pref_source) addPreferencesFromRepository(viewModel.repository) - (viewModel.repository as? ExternalMangaRepository)?.source?.let { - tachiyomiRuntime.addLangToPref(preferenceScreen, it, getString(R.string.language), viewModel::publish) + (viewModel.repository as? org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository)?.source?.let { + extRuntime.addLangToPref(preferenceScreen, it, getString(R.string.language), viewModel::publish) } val isValidSource = viewModel.repository !is EmptyMangaRepository findPreference(KEY_ENABLE)?.run { - isVisible = isValidSource && !settings.isAllSourcesEnabled && viewModel.repository !is ExternalMangaRepository + isVisible = isValidSource && !settings.isAllSourcesEnabled onPreferenceChangeListener = this@SourceSettingsFragment } findPreference(KEY_AUTH)?.run { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt index 4100a505..e2bbfecf 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsViewModel.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import okhttp3.HttpUrl -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.external.ExtRuntime import org.draken.usagi.R import org.draken.usagi.core.model.MangaSource import org.draken.usagi.core.model.MangaSourceRegistry @@ -17,7 +17,6 @@ import org.draken.usagi.core.network.cookies.MutableCookieJar import org.draken.usagi.core.parser.CachingMangaRepository import org.draken.usagi.core.parser.MangaParserRepository import org.draken.usagi.core.parser.MangaRepository -import org.draken.usagi.core.parser.tachiyomi.ExternalMangaRepository import org.draken.usagi.core.prefs.SourceSettings import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.core.ui.util.ReversibleAction @@ -27,7 +26,7 @@ import org.draken.usagi.explore.data.MangaSourcesRepository import tsuki.MangaParserAuthProvider import tsuki.exception.AuthRequiredException import javax.inject.Inject -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as ExternalSource +import org.draken.tsukimix.core.parser.external.model.Manga as ExternalSource @HiltViewModel class SourceSettingsViewModel @@ -37,7 +36,7 @@ class SourceSettingsViewModel mangaRepositoryFactory: MangaRepository.Factory, private val cookieJar: MutableCookieJar, private val mangaSourcesRepository: MangaSourcesRepository, - private val tachiyomiRuntime: TachiyomiRuntime, + private val extRuntime: ExtRuntime, ) : BaseViewModel(), SharedPreferences.OnSharedPreferenceChangeListener { val source = MangaSource(savedStateHandle.get(AppRouter.KEY_SOURCE)).resolve() @@ -58,7 +57,7 @@ class SourceSettingsViewModel loadUsername(repository.getAuthProvider()) } - is ExternalMangaRepository -> { + is org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository -> { browserUrl.value = repository.getBrowserUrl() repository.getSettingsPreferences().registerOnSharedPreferenceChangeListener(this) } @@ -71,7 +70,7 @@ class SourceSettingsViewModel repository.getConfig().unsubscribe(this) } - is ExternalMangaRepository -> { + is org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository -> { repository.getSettingsPreferences().unregisterOnSharedPreferenceChangeListener(this) } } @@ -92,7 +91,7 @@ class SourceSettingsViewModel browserUrl.value = "https://${repository.domain}" } } - if (repository is ExternalMangaRepository && key == SourceSettings.KEY_DOMAIN) { + if (repository is org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository && key == SourceSettings.KEY_DOMAIN) { repository.refreshDomainOverride() browserUrl.value = repository.getBrowserUrl() } @@ -122,13 +121,13 @@ class SourceSettingsViewModel fun setEnabled(value: Boolean) { launchJob(Dispatchers.Default) { mangaSourcesRepository.setSourcesEnabled(setOf(source), value) - tachiyomiRuntime.ensureReady(forceRefresh = true) + extRuntime.ensureReady(forceRefresh = true) } } fun publish() { val e = MangaSourceRegistry.sources.filterNot { it is ExternalSource } - MangaSourceRegistry.publish(e + tachiyomiRuntime.getActiveSources()) + MangaSourceRegistry.publish(e + extRuntime.getActiveSources()) } private fun loadUsername(authProvider: MangaParserAuthProvider?) { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt index 0f7b4833..851037c8 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourcesSettingsViewModel.kt @@ -3,9 +3,6 @@ package org.draken.usagi.settings.sources import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager -import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED -import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -14,8 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider -import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager +import org.draken.tsukimix.core.parser.external.ExtensionProvider +import org.draken.tsukimix.core.parser.external.NativeExtManager import org.draken.usagi.core.ui.BaseViewModel import org.draken.usagi.explore.data.MangaSourcesRepository import javax.inject.Inject @@ -27,7 +24,7 @@ class SourcesSettingsViewModel sourcesRepository: MangaSourcesRepository, private val catalogProvider: ExtensionProvider, private val directManager: NativeExtManager, - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, ) : BaseViewModel() { private val linksHandlerActivity = ComponentName(context, "org.draken.usagi.details.ui.DetailsByLinkActivity") @@ -35,52 +32,47 @@ class SourcesSettingsViewModel sourcesRepository .observeManageableSourcesCount() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0 to 0) - - private val externalPluginCountState = MutableStateFlow(calculateExternalCount()) - val externalPluginCount = externalPluginCountState - + val externalPluginCount = MutableStateFlow(calcExternalCount()) val availableSourcesCount = sourcesRepository .observeAvailableSourcesCount() .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) - val isLinksEnabled = MutableStateFlow(isLinksEnabled()) init { refreshExternalCounts() } - private fun calculateExternalCount(): Int { + private fun calcExternalCount(): Int { val cached = catalogProvider.getSavedRepositories().map { catalogProvider.canonicalKey(it) } - val installed = directManager.installed.value.map { catalogProvider.canonicalKey(it.repositoryUrl) } - return (cached + installed).filter { it.isNotBlank() }.toSet().size + val inst = directManager.installed.value.map { catalogProvider.canonicalKey(it.repositoryUrl) } + return (cached + inst).filter { it.isNotBlank() }.toSet().size } fun refreshExternalCounts() { launchJob(Dispatchers.Default) { - val artifacts = catalogProvider.loadSavedCached() - val repos = - ( - artifacts.map { catalogProvider.canonicalKey(it.repositoryUrl) } + - directManager.installed.value.map { catalogProvider.canonicalKey(it.repositoryUrl) } - ).filter { it.isNotBlank() } - .toSet() - externalPluginCountState.value = repos.size + val arts = catalogProvider.loadSavedCached() + val artKeys = arts.map { catalogProvider.canonicalKey(it.repositoryUrl) } + val instKeys = + directManager.installed.value.map { + catalogProvider.canonicalKey(it.repositoryUrl) + } + val repos = (artKeys + instKeys).filter { it.isNotBlank() }.toSet() + externalPluginCount.value = repos.size } } fun setLinksEnabled(isEnabled: Boolean) { context.packageManager.setComponentEnabledSetting( linksHandlerActivity, - if (isEnabled) COMPONENT_ENABLED_STATE_ENABLED else COMPONENT_ENABLED_STATE_DISABLED, + if (isEnabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP, ) - isLinksEnabled.value = isLinksEnabled() + isLinksEnabled.value = isEnabled } - private fun isLinksEnabled(): Boolean { - val state = context.packageManager.getComponentEnabledSetting(linksHandlerActivity) - return state == COMPONENT_ENABLED_STATE_ENABLED || state == COMPONENT_ENABLED_STATE_DEFAULT - } + private fun isLinksEnabled(): Boolean = + context.packageManager.getComponentEnabledSetting(linksHandlerActivity) == + PackageManager.COMPONENT_ENABLED_STATE_ENABLED } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt index b6803d48..1511b8b9 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItem.kt @@ -3,10 +3,12 @@ package org.draken.usagi.settings.sources.catalog import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiCatalogSource -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact +import org.draken.tsukimix.core.parser.external.model.ExtArtifact +import org.draken.tsukimix.core.parser.external.model.ExtInstalled +import org.draken.tsukimix.core.parser.external.model.ExtSource import org.draken.usagi.R +import org.draken.usagi.core.model.DirectExternalPluginMetadata +import org.draken.usagi.core.model.PluginMangaSource import org.draken.usagi.core.model.getSummary import org.draken.usagi.core.model.titleResId import org.draken.usagi.core.model.unwrap @@ -22,21 +24,17 @@ sealed interface SourceCatalogItem : ListModel { val isMultiLanguage: Boolean = false, ) : SourceCatalogItem { fun description(context: Context): String? { - if (!isMultiLanguage) { - return source.getSummary(context) - } + if (!isMultiLanguage) return source.getSummary(context) val type = context.getString(source.contentType.titleResId) val lang = context.getString(R.string.various_languages) - val unwrapped = source.unwrap() - val pluginLabel = - (source as? org.draken.usagi.core.model.PluginMangaSource)?.jarName?.removeSuffix(".jar")?.removeSuffix(".apk") - ?: when (unwrapped) { - is org.draken.tsukimix.core.parser.tachiyomi.model.Manga -> { - if (unwrapped.isPreInstalled) { + val pLabel = + (source as? PluginMangaSource)?.jarName?.removeSuffix(".jar")?.removeSuffix(".apk") + ?: when (val u = source.unwrap()) { + is org.draken.tsukimix.core.parser.external.model.Manga -> { + if (u.isPreInstalled) { context.getString(R.string.external_source) } else { - org.draken.usagi.core.model.DirectTachiyomiPluginMetadata - .get(unwrapped.pkgName) + DirectExternalPluginMetadata.get(u.pkgName) ?: context.getString(R.string.external_source) } } @@ -49,40 +47,31 @@ sealed interface SourceCatalogItem : ListModel { null } } - return if (pluginLabel != null) { - "$type, $lang • $pluginLabel" + return if (pLabel != null) { + "$type, $lang • $pLabel" } else { context.getString(R.string.source_summary_pattern, type, lang) } } - override fun areItemsTheSame(other: ListModel): Boolean = other is Source && other.source == source + override fun areItemsTheSame(other: ListModel) = other is Source && other.source == source } - data class Tachiyomi( - val source: TachiyomiCatalogSource, - val artifact: TachiyomiExtensionArtifact, - val installed: DirectTachiyomiInstalled?, + data class Extension( + val source: ExtSource, + val artifact: ExtArtifact, + val installed: ExtInstalled?, val isLoaded: Boolean, val isPreInstalledApk: Boolean, val isMultiLanguage: Boolean = false, val isInstalling: Boolean = false, val customPluginName: String? = null, ) : SourceCatalogItem { - val isInstalled: Boolean get() = installed != null - - val hasUpdate: Boolean - get() { - val availableVersion = artifact.versionCode ?: return false - val installedVersion = installed?.versionCode ?: return false - return availableVersion > installedVersion - } - - val contentType: ContentType - get() = source.contentType - val isNsfw: Boolean get() = contentType == ContentType.HENTAI - - val displayName: String get() = source.name + val isInstalled get() = installed != null + val hasUpdate get() = (artifact.versionCode ?: 0) > (installed?.versionCode ?: 0) + val contentType get() = source.contentType + val isNsfw get() = contentType == ContentType.HENTAI + val displayName get() = source.name fun description(context: Context): String { val lang = @@ -91,67 +80,55 @@ sealed interface SourceCatalogItem : ListModel { } else { languageDisplayName(context) } - val details = - listOf( - contentTypeLabel(context), - lang, - ).joinToString(", ") - val label = - if (isPreInstalledApk) { - context.getString(R.string.external_source) - } else { - pluginName + val typeLabel = + when (contentType) { + ContentType.MANGA -> context.getString(R.string.content_type_manga) + ContentType.HENTAI -> context.getString(R.string.content_type_hentai) + else -> context.getString(R.string.unknown) } - return "$details • $label" + val label = if (isPreInstalledApk) context.getString(R.string.external_source) else pluginName + return "$typeLabel, $lang • $label" } - private fun contentTypeLabel(context: Context): String = - when (contentType) { - ContentType.MANGA -> context.getString(R.string.content_type_manga) - ContentType.HENTAI -> context.getString(R.string.content_type_hentai) - else -> context.getString(R.string.unknown) - } - private fun languageDisplayName(context: Context): String { if (source.language.equals("all", true)) return context.getString(R.string.various_languages) - val locale = Locale.forLanguageTag(source.language) - return locale.getDisplayName(locale).takeIf { it.isNotBlank() && it != source.language } ?: source.language + val loc = Locale.forLanguageTag(source.language) + return loc.getDisplayName(loc).takeIf { it.isNotBlank() && it != source.language } ?: source.language } private val pluginName: String get() = customPluginName?.trim()?.takeIf { it.isNotBlank() } - ?: org.draken.usagi.core.model.DirectTachiyomiPluginMetadata - .get(artifact.packageName) + ?: DirectExternalPluginMetadata.get(artifact.packageName) ?: if (artifact.repositoryUrl.startsWith("local:") || artifact.repositoryUrl.startsWith("installed:")) { installed ?.name - ?.removePrefix("Tachiyomi: ") - ?.removePrefix("Tachiyomi - ") + ?.removePrefix("Extension: ") + ?.removePrefix("Extension - ") ?.trim() ?.takeIf { it.isNotBlank() } ?: artifact.name - .removePrefix("Tachiyomi: ") - .removePrefix("Tachiyomi - ") + .removePrefix("Extension: ") + .removePrefix("Extension - ") .trim() } else { runCatching { val uri = URI(artifact.repositoryUrl) val host = uri.host?.lowercase(Locale.ROOT).orEmpty() - val segments = + val segs = uri.path .trim('/') .split('/') .filter { it.isNotBlank() } when { host.endsWith(".github.io") -> host.removeSuffix(".github.io") - segments.isNotEmpty() -> segments.first().replaceFirstChar { it.titlecase(Locale.ROOT) } + segs.isNotEmpty() -> segs.first().replaceFirstChar { it.titlecase(Locale.ROOT) } else -> host.ifBlank { artifact.repositoryUrl } } }.getOrDefault(artifact.repositoryUrl) } - override fun areItemsTheSame(other: ListModel): Boolean = other is Tachiyomi && source.id == other.source.id + override fun areItemsTheSame(other: ListModel) = other is Extension && source.id == other.source.id } data class Hint( @@ -159,6 +136,6 @@ sealed interface SourceCatalogItem : ListModel { @field:StringRes val title: Int, @field:StringRes val text: Int, ) : SourceCatalogItem { - override fun areItemsTheSame(other: ListModel): Boolean = other is Hint && other.title == title + override fun areItemsTheSame(other: ListModel) = other is Hint && other.title == title } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt index 27c47dda..a1735ec2 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourceCatalogItemAD.kt @@ -22,17 +22,26 @@ import androidx.appcompat.R as appcompatR fun sourceCatalogItemSourceAD(listener: OnListItemClickListener) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSourceCatalogBinding.inflate(layoutInflater, parent, false) }, + { inf, p -> ItemSourceCatalogBinding.inflate(inf, p, false) }, ) { binding.imageViewAdd.setOnClickListener { v -> listener.onItemLongClick(item, v) } binding.root.setOnClickListener { v -> listener.onItemClick(item, v) } - val basePadding = context.getThemeDimensionPixelOffset(appcompatR.attr.listPreferredItemPaddingEnd, binding.root.paddingStart) - binding.root.updatePaddingRelative(end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0)) + val pad = + context.getThemeDimensionPixelOffset( + appcompatR.attr.listPreferredItemPaddingEnd, + binding.root.paddingStart, + ) + val margin = context.resources.getDimensionPixelOffset(R.dimen.margin_small) + binding.root.updatePaddingRelative(end = (pad - margin).coerceAtLeast(0)) bind { binding.textViewTitle.text = item.source.getTitle(context) binding.textViewDescription.text = item.description(context) - binding.textViewDescription.drawableStart = if (item.source.isBroken) ContextCompat.getDrawable(context, R.drawable.ic_off_small) else null - FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) + binding.textViewDescription.drawableStart = + if (item.source.isBroken) { + ContextCompat.getDrawable(context, R.drawable.ic_off_small) + } else { + null + } binding.imageViewIcon.setImageAsync(item.source) binding.imageViewAdd.isVisible = true binding.imageViewAdd.setImageResource(R.drawable.ic_add) @@ -40,13 +49,13 @@ fun sourceCatalogItemSourceAD(listener: OnListItemClickListener Unit, - onInstall: (SourceCatalogItem.Tachiyomi, android.view.View) -> Unit, - onUninstall: (SourceCatalogItem.Tachiyomi, android.view.View) -> Unit, - onSideload: (SourceCatalogItem.Tachiyomi, android.view.View) -> Unit, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSourceCatalogBinding.inflate(layoutInflater, parent, false) }, +fun sourceCatalogItemExtensionAD( + onClick: (SourceCatalogItem.Extension, android.view.View) -> Unit, + onInstall: (SourceCatalogItem.Extension, android.view.View) -> Unit, + onUninstall: (SourceCatalogItem.Extension, android.view.View) -> Unit, + onSideload: (SourceCatalogItem.Extension, android.view.View) -> Unit, +) = adapterDelegateViewBinding( + { inf, p -> ItemSourceCatalogBinding.inflate(inf, p, false) }, ) { binding.root.setOnClickListener { v -> onClick(item, v) } binding.imageViewAdd.setOnClickListener { v -> @@ -57,29 +66,32 @@ fun sourceCatalogItemTachiyomiAD( } } binding.imageViewAdd.setOnLongClickListener { v -> - if (item.isPreInstalledApk) { - true - } else { - onSideload(item, v) - true - } + if (!item.isPreInstalledApk) onSideload(item, v) + true + } + val pad = + context.getThemeDimensionPixelOffset( + appcompatR.attr.listPreferredItemPaddingEnd, + binding.root.paddingStart, + ) + val margin = context.resources.getDimensionPixelOffset(R.dimen.margin_small) + binding.root.updatePaddingRelative(end = (pad - margin).coerceAtLeast(0)) + onViewRecycled { + (binding.imageViewAdd.drawable as? Animatable)?.stop() } - val basePadding = context.getThemeDimensionPixelOffset(appcompatR.attr.listPreferredItemPaddingEnd, binding.root.paddingStart) - binding.root.updatePaddingRelative(end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0)) bind { - val iconSize = context.resources.getDimensionPixelSize(R.dimen.card_indicator_size) + val size = context.resources.getDimensionPixelSize(R.dimen.card_indicator_size) binding.imageViewIcon.layoutParams = binding.imageViewIcon.layoutParams.apply { - width = iconSize - height = iconSize + width = size + height = size } binding.imageViewIcon.scaleType = ImageView.ScaleType.CENTER_CROP - val fallback = FaviconDrawable(context, R.style.FaviconDrawable, item.artifact.packageName) - - binding.imageViewIcon.errorDrawable = fallback - binding.imageViewIcon.fallbackDrawable = fallback + val fb = FaviconDrawable(context, R.style.FaviconDrawable, item.artifact.packageName) + binding.imageViewIcon.errorDrawable = fb + binding.imageViewIcon.fallbackDrawable = fb if (item.artifact.iconUrl.isNullOrBlank()) { - binding.imageViewIcon.setImageDrawable(fallback) + binding.imageViewIcon.setImageDrawable(fb) } else { binding.imageViewIcon.setImageAsync(item.artifact.iconUrl) } @@ -88,15 +100,15 @@ fun sourceCatalogItemTachiyomiAD( binding.textViewDescription.text = item.description(context) binding.textViewDescription.drawableStart = null - val isInstalled = item.isInstalled || item.isLoaded || item.isPreInstalledApk + val isInst = item.isInstalled || item.isLoaded || item.isPreInstalledApk (binding.imageViewAdd.drawable as? Animatable)?.stop() if (item.isInstalling) { binding.imageViewAdd.isEnabled = false binding.imageViewAdd.setImageDrawable( - CircularProgressDrawable(context).also { - it.setStyle(CircularProgressDrawable.DEFAULT) - it.setColorSchemeColors(context.getThemeColor(appcompatR.attr.colorControlNormal)) - it.start() + CircularProgressDrawable(context).apply { + setStyle(CircularProgressDrawable.DEFAULT) + setColorSchemeColors(context.getThemeColor(appcompatR.attr.colorControlNormal)) + start() }, ) binding.imageViewAdd.contentDescription = context.getString(R.string.loading_) @@ -104,16 +116,16 @@ fun sourceCatalogItemTachiyomiAD( binding.imageViewAdd.isEnabled = true binding.imageViewAdd.setImageResource( when { - item.hasUpdate -> R.drawable.ic_updated - isInstalled -> R.drawable.ic_delete + isInst && item.hasUpdate -> R.drawable.ic_updated + isInst -> R.drawable.ic_delete else -> R.drawable.ic_download }, ) binding.imageViewAdd.contentDescription = context.getString( when { - item.hasUpdate -> R.string.download - isInstalled -> R.string.delete + isInst && item.hasUpdate -> R.string.update + isInst -> R.string.delete else -> R.string.add }, ) @@ -123,7 +135,7 @@ fun sourceCatalogItemTachiyomiAD( fun sourceCatalogItemHintAD() = adapterDelegateViewBinding( - { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, + { inf, p -> ItemEmptyHintBinding.inflate(inf, p, false) }, ) { binding.buttonRetry.isVisible = false bind { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt index 18dff260..65c6a2ed 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt @@ -12,16 +12,12 @@ import android.os.Build import android.os.Bundle import android.os.Environment import android.view.Menu -import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.graphics.Insets -import androidx.core.net.toUri -import androidx.core.os.ConfigurationCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope @@ -42,28 +38,24 @@ import org.draken.usagi.core.ui.widgets.ChipsView import org.draken.usagi.core.ui.widgets.ChipsView.ChipModel import org.draken.usagi.core.util.ext.observe import org.draken.usagi.core.util.ext.observeEvent -import org.draken.usagi.core.util.ext.toLocale import org.draken.usagi.databinding.ActivitySourcesCatalogBinding import org.draken.usagi.list.ui.adapter.TypedListSpacingDecoration import org.draken.usagi.main.ui.owners.AppBarOwner import tsuki.model.ContentType -import java.util.Locale @AndroidEntryPoint class SourcesCatalogActivity : BaseActivity(), OnListItemClickListener, AppBarOwner, - MenuItem.OnActionExpandListener, ChipsView.OnChipClickListener { - override val appBar: AppBarLayout - get() = viewBinding.appbar + override val appBar: AppBarLayout get() = viewBinding.appbar private val viewModel by viewModels() private val downloadManager by lazy { getSystemService(DOWNLOAD_SERVICE) as DownloadManager } private val sideloadDownloadIds = mutableSetOf() private var fadingAppbarMediator: FadingAppbarMediator? = null - private var navigationBarBottomInset = 0 + private var navBarBottomInset = 0 private val downloadReceiver = object : BroadcastReceiver() { override fun onReceive( @@ -71,10 +63,8 @@ class SourcesCatalogActivity : intent: Intent, ) { if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return - val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) - if (downloadId != 0L && sideloadDownloadIds.remove(downloadId)) { - openDownloadedApk(downloadId) - } + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) + if (id != 0L && sideloadDownloadIds.remove(id)) openDownloadedApk(id) } } @@ -83,53 +73,58 @@ class SourcesCatalogActivity : setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) if (viewModel.isScopedMode) { - val repoName = intent.getStringExtra(EXTRA_REPOSITORY_NAME) ?: viewModel.scopedRepositoryUrl - title = repoName + title = intent.getStringExtra(EXTRA_REPOSITORY_NAME) ?: viewModel.scopedRepositoryUrl } - val sourcesAdapter = + val adapter = SourcesCatalogAdapter( nativeListener = this, - onTachiyomiClick = { item, _ -> openTachiyomiSource(item) }, - onTachiyomiInstall = { item, _ -> installTachiyomi(item) }, - onTachiyomiUninstall = { item, _ -> uninstallTachiyomi(item) }, - onTachiyomiSideload = ::showTachiyomiSideloadMenu, + onExtensionClick = { item, _ -> openSource(item) }, + onExtensionInstall = { item, _ -> installExtension(item) }, + onExtensionUninstall = { item, _ -> uninstallExtension(item) }, + onExtensionSideload = ::showSideloadMenu, ) - with(viewBinding.recyclerView) { setHasFixedSize(true) addItemDecoration(TypedListSpacingDecoration(context, false)) - adapter = sourcesAdapter + this.adapter = adapter } viewBinding.chipsFilter.onChipClickListener = this fadingAppbarMediator = FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbar).also { it.bind() } - viewModel.content.observe(this, sourcesAdapter) + viewModel.content.observe(this, adapter) + viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) + combine(viewModel.appliedFilter, viewModel.hasNewSources, viewModel.contentTypes, ::Triple).observe(this) { + updateFilters(it.first, it.second, it.third) + } + addMenuProvider(SourcesCatalogMenuProvider(this, viewModel), this, androidx.lifecycle.Lifecycle.State.RESUMED) + } + + override fun onStart() { + super.onStart() ContextCompat.registerReceiver( this, downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), ContextCompat.RECEIVER_EXPORTED, ) - viewModel.onActionDone.observeEvent( - this, - ReversibleActionObserver(viewBinding.recyclerView), - ) - combine(viewModel.appliedFilter, viewModel.hasNewSources, viewModel.contentTypes, ::Triple).observe(this) { - updateFilers(it.first, it.second, it.third) - } - addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } override fun onResume() { super.onResume() - viewModel.refreshTachiyomiRuntime() + viewModel.refreshExtensionRuntime() + } + + override fun onStop() { + runCatching { unregisterReceiver(downloadReceiver) } + super.onStop() } override fun onDestroy() { + currentSnackbar?.dismiss() + currentSnackbar = null fadingAppbarMediator?.unbind() fadingAppbarMediator = null viewBinding.chipsFilter.onChipClickListener = null viewBinding.recyclerView.adapter = null - runCatching { unregisterReceiver(downloadReceiver) } super.onDestroy() } @@ -138,7 +133,7 @@ class SourcesCatalogActivity : insets: WindowInsetsCompat, ): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - navigationBarBottomInset = bars.bottom + navBarBottomInset = bars.bottom viewBinding.recyclerView.updatePadding( left = bars.left, right = bars.right, @@ -170,9 +165,7 @@ class SourcesCatalogActivity : override fun onItemClick( item: SourceCatalogItem.Source, view: View, - ) { - router.openList(item.source, null, null) - } + ) = router.openList(item.source, null, null) override fun onItemLongClick( item: SourceCatalogItem.Source, @@ -182,25 +175,16 @@ class SourcesCatalogActivity : return false } - private fun installTachiyomi(item: SourceCatalogItem.Tachiyomi) { + private fun installExtension(item: SourceCatalogItem.Extension) { if (item.isLoaded && !item.hasUpdate) return lifecycleScope.launch { - val success = viewModel.installTachiyomi(item) - if (!success) { - showInsetSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_LONG) - } - } - } - - private fun unloadTachiyomi(item: SourceCatalogItem.Tachiyomi) { - lifecycleScope.launch { - if (!viewModel.unloadTachiyomi(item)) { - showInsetSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_SHORT) + if (!viewModel.installExtension(item)) { + showSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_LONG) } } } - private fun uninstallTachiyomi(item: SourceCatalogItem.Tachiyomi) { + private fun uninstallExtension(item: SourceCatalogItem.Extension) { if (item.isPreInstalledApk) { val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -212,119 +196,73 @@ class SourcesCatalogActivity : startActivity(Intent(action, Uri.fromParts("package", item.artifact.packageName, null))) } else { lifecycleScope.launch { - val success = viewModel.uninstallTachiyomi(item) - if (!success) { - showInsetSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_SHORT) + if (!viewModel.uninstallExtension(item)) { + showSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_SHORT) } } } } - private fun openTachiyomiSource(item: SourceCatalogItem.Tachiyomi) { + private fun openSource(item: SourceCatalogItem.Extension) { lifecycleScope.launch { - var source = runCatching { viewModel.getImportedTachiyomiSource(item) }.getOrNull() - if (source == null && !item.isInstalled && !item.isLoaded && !item.isPreInstalledApk) { - val installed = viewModel.installTachiyomi(item) - if (installed) { - source = runCatching { viewModel.getImportedTachiyomiSource(item) }.getOrNull() - } - } + val source = viewModel.openExtensionSource(item) if (source != null) { - viewModel.enableTachiyomiSource(source) router.openList(source, null, null) } else { - showExternalSourceUnsupported() + showSnackbar(getString(R.string.unsupported_source), Snackbar.LENGTH_SHORT) } } } - private fun showExternalSourceUnsupported() { - showInsetSnackbar(getString(R.string.unsupported_source), Snackbar.LENGTH_SHORT) - } - - private fun showTachiyomiSideloadMenu( - item: SourceCatalogItem.Tachiyomi, + private fun showSideloadMenu( + item: SourceCatalogItem.Extension, anchor: View, ) { - val menu = PopupMenu(this, anchor) - menu.menu.add(Menu.NONE, Menu.NONE, 0, R.string.download) - menu.setOnMenuItemClickListener { - downloadTachiyomiApk(item) - true - } - menu.show() + PopupMenu(this, anchor) + .apply { + menu.add(Menu.NONE, Menu.NONE, 0, R.string.download) + setOnMenuItemClickListener { + downloadApk(item) + true + } + }.show() } - private fun downloadTachiyomiApk(item: SourceCatalogItem.Tachiyomi) { - val apkUrl = - item.artifact.apkUrl - ?.trim() - ?.takeIf { it.isNotEmpty() } - if (apkUrl == null) { - showInsetSnackbar(getString(R.string.operation_not_supported), Snackbar.LENGTH_LONG) - return - } - - val uri = apkUrl.toUri() - val fileName = - uri.lastPathSegment - ?.takeIf { it.endsWith(".apk", ignoreCase = true) } - ?: "${item.artifact.packageName}-${item.artifact.versionName ?: item.artifact.versionCode ?: "latest"}.apk" - val request = - DownloadManager - .Request(uri) - .setTitle(item.displayName) - .setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, fileName) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setMimeType(APK_MIME_TYPE) - val downloadId = - runCatching { downloadManager.enqueue(request) }.getOrElse { - showInsetSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + private fun downloadApk(item: SourceCatalogItem.Extension) { + val req = + viewModel.createDownloadRequest(item, getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) ?: run { + showSnackbar(getString(R.string.operation_not_supported), Snackbar.LENGTH_LONG) return } - sideloadDownloadIds += downloadId - showInsetSnackbar(getString(R.string.download), Snackbar.LENGTH_SHORT) + val id = + runCatching { downloadManager.enqueue(req) }.getOrElse { + showSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + return + } + sideloadDownloadIds += id + showSnackbar(getString(R.string.download), Snackbar.LENGTH_SHORT) } @SuppressLint("RequestInstallPackagesPolicy") @Suppress("DEPRECATION") private fun openDownloadedApk(downloadId: Long) { - val apkUri = downloadManager.getUriForDownloadedFile(downloadId) - if (apkUri == null) { - showInsetSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) - return - } - val installerIntent = + val apkUri = + downloadManager.getUriForDownloadedFile(downloadId) ?: run { + showSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + return + } + val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION setDataAndType(apkUri, APK_MIME_TYPE) putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) } - runCatching { startActivity(installerIntent) } - .onFailure { error -> - if (error is ActivityNotFoundException) { - showInsetSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) - } - } - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - val sq = - (item.actionView as? SearchView) - ?.query - ?.trim() - ?.toString() - .orEmpty() - viewModel.performSearch(sq) - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - viewModel.performSearch(null) - return true + runCatching { startActivity(intent) }.onFailure { + if (it is ActivityNotFoundException) showSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + } } - private fun updateFilers( + private fun updateFilters( appliedFilter: SourcesCatalogFilter, hasNewSources: Boolean, contentTypes: List, @@ -333,7 +271,11 @@ class SourcesCatalogActivity : if (!viewModel.isScopedMode) { chips += ChipModel( - title = appliedFilter.plugin?.let { key -> viewModel.plugins.firstOrNull { it.key == key }?.label ?: key.removeSuffix(".jar") } ?: getString(R.string.any), + title = + appliedFilter.plugin?.let { key -> + viewModel.plugins.firstOrNull { it.first == key }?.second + ?: key.removeSuffix(".jar") + } ?: getString(R.string.any), icon = R.drawable.ic_services, isDropdown = true, data = "plugins", @@ -341,7 +283,7 @@ class SourcesCatalogActivity : } chips += ChipModel( - title = localeDisplayName(appliedFilter.locale), + title = viewModel.localeDisplayName(appliedFilter.locale), icon = R.drawable.ic_language, isDropdown = true, ) @@ -355,81 +297,53 @@ class SourcesCatalogActivity : ) } contentTypes.mapTo(chips) { type -> - ChipModel( - title = getString(type.titleResId), - isChecked = type in appliedFilter.types, - data = type, - ) + ChipModel(title = getString(type.titleResId), isChecked = type in appliedFilter.types, data = type) } viewBinding.chipsFilter.setChips(chips) } - private fun showInsetSnackbar( + private var currentSnackbar: Snackbar? = null + + private fun showSnackbar( message: CharSequence, duration: Int, ) { - showInsetSnackbar(Snackbar.make(viewBinding.recyclerView, message, duration)) - } - - private fun showInsetSnackbar(snackbar: Snackbar) { - val view = snackbar.view - val params = view.layoutParams as? ViewGroup.MarginLayoutParams - if (params != null) { - params.bottomMargin += navigationBarBottomInset - view.layoutParams = params + currentSnackbar?.dismiss() + val sb = Snackbar.make(viewBinding.recyclerView, message, duration) + (sb.view.layoutParams as? ViewGroup.MarginLayoutParams)?.let { + it.bottomMargin += navBarBottomInset + sb.view.layoutParams = it } - snackbar.show() + currentSnackbar = sb + sb.show() } private fun showLocalesMenu(anchor: View) { - val locales = viewModel.locales.value.sortedWith(compareBy { localeDisplayName(it) }) - val menu = PopupMenu(this, anchor) - for ((i, locale) in locales.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i, localeDisplayName(locale)) - } - menu.setOnMenuItemClickListener { - viewModel.setLocale(locales.getOrNull(it.order)) - true - } - menu.show() - } - - private fun localeDisplayName(value: String?): String { - val code = value?.trim().orEmpty() - if (code.isEmpty()) return getString(R.string.all_languages) - if (code.equals("all", true)) return getString(R.string.various_languages) - - val locale = code.replace('_', '-').toLocale() - val displayLocale = ConfigurationCompat.getLocales(resources.configuration)[0] ?: Locale.getDefault() - val localizedName = locale.getDisplayLanguage(displayLocale).trim() - if (!localizedName.isLocaleCode(locale)) return localizedName - - val englishName = locale.getDisplayLanguage(Locale.ENGLISH).trim() - return englishName.takeUnless { it.isLocaleCode(locale) } ?: code.ifEmpty { getString(R.string.unknown) } - } - - private fun String.isLocaleCode(locale: Locale): Boolean { - val normalized = trim().lowercase(Locale.ROOT) - return normalized.isEmpty() || normalized == locale.language.lowercase(Locale.ROOT) || normalized == locale.toLanguageTag().lowercase(Locale.ROOT) + val locales = viewModel.locales.value.sortedWith(compareBy { viewModel.localeDisplayName(it) }) + PopupMenu(this, anchor) + .apply { + locales.forEachIndexed { i, loc -> menu.add(Menu.NONE, Menu.NONE, i, viewModel.localeDisplayName(loc)) } + setOnMenuItemClickListener { + viewModel.setLocale(locales.getOrNull(it.order)) + true + } + }.show() } private fun showPluginsMenu(anchor: View) { - val menu = PopupMenu(this, anchor) - menu.menu.add(Menu.NONE, Menu.NONE, 0, getString(R.string.any)) - for ((i, plugin) in viewModel.plugins.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i + 1, plugin.label) - } - menu.setOnMenuItemClickListener { - val p = if (it.order == 0) null else viewModel.plugins[it.order - 1].key - viewModel.setPlugin(p) - - true - } - menu.show() + PopupMenu(this, anchor) + .apply { + menu.add(Menu.NONE, Menu.NONE, 0, getString(R.string.any)) + viewModel.plugins.forEachIndexed { i, (_, label) -> menu.add(Menu.NONE, Menu.NONE, i + 1, label) } + setOnMenuItemClickListener { + viewModel.setPlugin(if (it.order == 0) null else viewModel.plugins[it.order - 1].first) + true + } + }.show() } companion object { - const val APK_MIME_TYPE = "application/vnd.android.package-archive" + const val APK_MIME_TYPE = SourcesCatalogViewModel.APK_MIME_TYPE const val EXTRA_REPOSITORY_URL = SourcesCatalogViewModel.EXTRA_REPOSITORY_URL const val EXTRA_REPOSITORY_NAME = SourcesCatalogViewModel.EXTRA_REPOSITORY_NAME } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt index 75b78588..9cdc4ed3 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogAdapter.kt @@ -12,21 +12,21 @@ import org.draken.usagi.list.ui.model.ListModel class SourcesCatalogAdapter( nativeListener: OnListItemClickListener, - onTachiyomiClick: (SourceCatalogItem.Tachiyomi, View) -> Unit, - onTachiyomiInstall: (SourceCatalogItem.Tachiyomi, View) -> Unit, - onTachiyomiUninstall: (SourceCatalogItem.Tachiyomi, View) -> Unit, - onTachiyomiSideload: (SourceCatalogItem.Tachiyomi, View) -> Unit, + onExtensionClick: (SourceCatalogItem.Extension, View) -> Unit, + onExtensionInstall: (SourceCatalogItem.Extension, View) -> Unit, + onExtensionUninstall: (SourceCatalogItem.Extension, View) -> Unit, + onExtensionSideload: (SourceCatalogItem.Extension, View) -> Unit, ) : BaseListAdapter(), FastScroller.SectionIndexer { init { addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(nativeListener)) addDelegate( ListItemType.INFO, - sourceCatalogItemTachiyomiAD( - onTachiyomiClick, - onTachiyomiInstall, - onTachiyomiUninstall, - onTachiyomiSideload, + sourceCatalogItemExtensionAD( + onExtensionClick, + onExtensionInstall, + onExtensionUninstall, + onExtensionSideload, ), ) addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD()) @@ -39,7 +39,7 @@ class SourcesCatalogAdapter( ): CharSequence? = when (val item = items.getOrNull(position)) { is SourceCatalogItem.Source -> item.source.getTitle(context).take(1) - is SourceCatalogItem.Tachiyomi -> item.displayName.take(1) + is SourceCatalogItem.Extension -> item.displayName.take(1) else -> null } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt index 3bfe3416..a63f098c 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogFilter.kt @@ -8,8 +8,3 @@ data class SourcesCatalogFilter( val isNewOnly: Boolean, val plugin: String?, ) - -data class SourceCatalogPlugin( - val key: String, - val label: String, -) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogMenuProvider.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogMenuProvider.kt index 6b5ef4d1..2214e725 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogMenuProvider.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogMenuProvider.kt @@ -1,6 +1,5 @@ package org.draken.usagi.settings.sources.catalog -import android.app.Activity import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -10,9 +9,8 @@ import org.draken.usagi.R import org.draken.usagi.main.ui.owners.AppBarOwner class SourcesCatalogMenuProvider( - private val activity: Activity, + private val appBarOwner: AppBarOwner?, private val viewModel: SourcesCatalogViewModel, - private val expandListener: MenuItem.OnActionExpandListener, ) : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { @@ -32,13 +30,21 @@ class SourcesCatalogMenuProvider( override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false override fun onMenuItemActionExpand(item: MenuItem): Boolean { - (activity as? AppBarOwner)?.appBar?.setExpanded(true, true) - return expandListener.onMenuItemActionExpand(item) + appBarOwner?.appBar?.setExpanded(true, true) + val query = + (item.actionView as? SearchView) + ?.query + ?.trim() + ?.toString() + .orEmpty() + viewModel.performSearch(query) + return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - (item.actionView as SearchView).setQuery("", false) - return expandListener.onMenuItemActionCollapse(item) + (item.actionView as? SearchView)?.setQuery("", false) + viewModel.performSearch(null) + return true } override fun onQueryTextSubmit(query: String?): Boolean = false diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt index d3893fc5..4d1bc340 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -1,9 +1,15 @@ package org.draken.usagi.settings.sources.catalog +import android.app.DownloadManager +import android.content.Context +import android.net.Uri import androidx.annotation.WorkerThread +import androidx.core.net.toUri +import androidx.core.os.ConfigurationCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -13,18 +19,19 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider -import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime -import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiCatalogSource -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact +import org.draken.tsukimix.core.parser.external.ExtRuntime +import org.draken.tsukimix.core.parser.external.ExtensionProvider +import org.draken.tsukimix.core.parser.external.NativeExtManager +import org.draken.tsukimix.core.parser.external.model.ExtArtifact +import org.draken.tsukimix.core.parser.external.model.ExtInstalled +import org.draken.tsukimix.core.parser.external.model.ExtSource import org.draken.tsukimix.core.util.canonicalLanguageCode import org.draken.usagi.R import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.db.TABLE_SOURCES -import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata +import org.draken.usagi.core.model.DirectExternalPluginMetadata import org.draken.usagi.core.model.MangaSourceInfo +import org.draken.usagi.core.model.PluginMangaSource import org.draken.usagi.core.model.unwrap import org.draken.usagi.core.parser.external.ExternalMangaSource import org.draken.usagi.core.prefs.AppSettings @@ -33,83 +40,92 @@ import org.draken.usagi.core.ui.util.ReversibleAction import org.draken.usagi.core.util.ext.MutableEventFlow import org.draken.usagi.core.util.ext.call import org.draken.usagi.core.util.ext.mapSortedByCount +import org.draken.usagi.core.util.ext.toLocale import org.draken.usagi.explore.data.MangaSourcesRepository import org.draken.usagi.explore.data.SourcesSortOrder import org.draken.usagi.list.ui.model.ListModel import org.draken.usagi.list.ui.model.LoadingState import tsuki.model.ContentType import tsuki.model.MangaSource +import java.io.File import java.util.EnumSet import java.util.Locale import javax.inject.Inject -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionManager as InstalledTachiyomiExtensionManager -import org.draken.tsukimix.core.parser.tachiyomi.model.Manga as TachiyomiMangaSource +import org.draken.tsukimix.core.parser.external.ExtensionManager as InstalledExtManager +import org.draken.tsukimix.core.parser.external.model.Manga as ExtMangaSource @HiltViewModel class SourcesCatalogViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + @ApplicationContext private val context: Context, private val repository: MangaSourcesRepository, db: MangaDatabase, private val settings: AppSettings, private val directManager: NativeExtManager, - private val installedTachiyomiManager: InstalledTachiyomiExtensionManager, - private val tachiyomiRuntime: TachiyomiRuntime, + private val installedExtManager: InstalledExtManager, + private val runtime: ExtRuntime, private val catalogProvider: ExtensionProvider, ) : BaseViewModel() { val scopedRepositoryUrl: String? = savedStateHandle.get(EXTRA_REPOSITORY_URL)?.takeIf { it.isNotBlank() } - val isScopedMode: Boolean get() = scopedRepositoryUrl != null - + val isScopedMode get() = scopedRepositoryUrl != null val onActionDone = MutableEventFlow() - private val tachiyomiCatalog = MutableStateFlow>(emptyList()) + private val extCatalog = MutableStateFlow>(emptyList()) private val isInitialLoading = MutableStateFlow(true) private val searchQuery = MutableStateFlow(null) private val installingPackages = MutableStateFlow>(emptySet()) val locales = MutableStateFlow>(setOf(null)) - val appliedFilter = MutableStateFlow( - SourcesCatalogFilter( - types = emptySet(), - locale = null, - isNewOnly = false, - plugin = null, - ), + SourcesCatalogFilter(types = emptySet(), locale = null, isNewOnly = false, plugin = null), ) - val hasNewSources = repository .observeHasNewSources() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - val plugins: List + val plugins: List> get() = - buildMap { - repository.allMangaSources.forEach { source -> - val plugin = - (source as? org.draken.usagi.core.model.PluginMangaSource) - ?: (source as? MangaSourceInfo)?.mangaSource as? org.draken.usagi.core.model.PluginMangaSource - plugin?.let { put(it.jarName, SourceCatalogPlugin(it.jarName, it.jarName.removeSuffix(".jar"))) } + buildMap { + repository.allMangaSources.forEach { s -> + val p = (s as? PluginMangaSource) ?: (s as? MangaSourceInfo)?.mangaSource as? PluginMangaSource + p?.let { put(it.jarName, it.jarName.removeSuffix(".jar")) } } - directManager.installed.value.forEach { record -> - val pluginName = - DirectTachiyomiPluginMetadata.get(record.packageName) - ?: record.name - .removePrefix("Tachiyomi: ") - .removePrefix("Tachiyomi - ") + directManager.installed.value.forEach { r -> + val repoName = DirectExternalPluginMetadata.get(r.packageName) + if (repoName != null) { + put(repoName, repoName) + } else { + val name = + r.name + .removePrefix("Extension: ") + .removePrefix("Extension - ") .trim() - put(record.packageName, SourceCatalogPlugin(record.packageName, pluginName)) + put(r.packageName, name) + } } - }.values.sortedBy { it.label.lowercase(Locale.ROOT) } + }.toList().sortedBy { it.second.lowercase(Locale.ROOT) } val contentTypes = MutableStateFlow>(emptyList()) - private val tachiyomiState = - combine(directManager.installed, tachiyomiRuntime.sources, installedTachiyomiManager.sources, isInitialLoading, installingPackages) { installed, loaded, preInstalled, loading, installing -> - CatalogTachiyomiState(installed, loaded.map { it.sourceId }.toSet(), preInstalled.filter { it.isPreInstalled }, loading, installing) + private val extState = + combine( + directManager.installed, + runtime.sources, + installedExtManager.sources, + isInitialLoading, + installingPackages, + ) { inst, loaded, pre, load, installing -> + CatalogExtState( + inst, + loaded.map { it.sourceId }.toSet(), + pre.filter { it.isPreInstalled }, + load, + installing, + ) } val content: StateFlow> = @@ -117,28 +133,31 @@ class SourcesCatalogViewModel searchQuery, appliedFilter, db.invalidationTracker.createFlow(TABLE_SOURCES), - tachiyomiCatalog, - tachiyomiState, - ) { query, filter, _, artifacts, (installed, loaded, preInstalled, loading, installing) -> - if (loading) listOf(LoadingState) else buildSourcesList(filter, query, artifacts, installed, loaded, preInstalled, installing) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) + extCatalog, + extState, + ) { q, filter, _, arts, (inst, loaded, pre, load, installing) -> + if (load) { + listOf(LoadingState) + } else { + buildSourcesList(filter, q, arts, inst, loaded, pre, installing) + } + }.stateIn( + viewModelScope + Dispatchers.Default, + SharingStarted.Eagerly, + listOf(LoadingState), + ) init { repository.clearNewSourcesBadge() launchJob(Dispatchers.Default) { - val cachedCatalog = catalogProvider.loadSavedCached() - tachiyomiCatalog.value = cachedCatalog + val cached = catalogProvider.loadSavedCached() + extCatalog.value = cached contentTypes.value = getContentTypes(settings.isNsfwContentDisabled) isInitialLoading.value = false - + launch { runCatching { runtime.ensureReady() } } launch { - runCatching { tachiyomiRuntime.ensureReady() } - } - launch { - val refreshedCatalog = catalogProvider.loadSaved() - if (refreshedCatalog != cachedCatalog) { - tachiyomiCatalog.value = refreshedCatalog - } + val ref = catalogProvider.loadSaved() + if (ref != cached) extCatalog.value = ref } } } @@ -151,108 +170,155 @@ class SourcesCatalogViewModel appliedFilter.value = appliedFilter.value.copy(locale = value) } - fun refreshTachiyomiRuntime() { - launchJob(Dispatchers.Default) { - runCatching { tachiyomiRuntime.ensureReady(forceRefresh = true) } + fun refreshExtensionRuntime() { + launchJob(Dispatchers.Default) { runCatching { runtime.ensureReady(forceRefresh = true) } } + } + + fun createDownloadRequest( + item: SourceCatalogItem.Extension, + destinationDir: File?, + ): DownloadManager.Request? { + val apkUrl = + item.artifact.apkUrl + ?.trim() + ?.takeIf { it.isNotEmpty() } ?: return null + val uri = apkUrl.toUri() + val name = + uri.lastPathSegment?.takeIf { it.endsWith(".apk", true) } + ?: "${item.artifact.packageName}-${item.artifact.versionName + ?: item.artifact.versionCode ?: "latest"}.apk" + val req = + DownloadManager + .Request(uri) + .setTitle(item.displayName) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setMimeType(APK_MIME_TYPE) + if (destinationDir != null) req.setDestinationUri(Uri.fromFile(File(destinationDir, name))) + return req + } + + suspend fun openExtensionSource(item: SourceCatalogItem.Extension): MangaSource? { + var source = runCatching { getImportedExtensionSource(item) }.getOrNull() + if (source == null && !item.isInstalled && !item.isLoaded && !item.isPreInstalledApk) { + if (installExtension(item)) source = runCatching { getImportedExtensionSource(item) }.getOrNull() } + if (source != null) enableExtensionSource(source) + return source + } + + fun localeDisplayName(value: String?): String { + val code = value?.trim().orEmpty() + if (code.isEmpty()) return context.getString(R.string.all_languages) + if (code.equals("all", true)) return context.getString(R.string.various_languages) + val loc = code.replace('_', '-').toLocale() + val disp = ConfigurationCompat.getLocales(context.resources.configuration)[0] ?: Locale.getDefault() + val name = loc.getDisplayLanguage(disp).trim() + if (!name.isLocaleCode(loc)) return name + val en = loc.getDisplayLanguage(Locale.ENGLISH).trim() + return en.takeUnless { it.isLocaleCode(loc) } ?: code.ifEmpty { context.getString(R.string.unknown) } + } + + private fun String.isLocaleCode(loc: Locale): Boolean { + val norm = trim().lowercase(Locale.ROOT) + return norm.isEmpty() || + norm == loc.language.lowercase(Locale.ROOT) || + norm == loc.toLanguageTag().lowercase(Locale.ROOT) } - private fun scopedArtifacts(artifacts: List): List { + private fun scopedArtifacts(artifacts: List): List { val scoped = scopedRepositoryUrl ?: return emptyList() - val normScoped = catalogProvider.normalizeUrl(scoped) ?: scoped - val filtered = artifacts.filter { (catalogProvider.normalizeUrl(it.repositoryUrl) ?: it.repositoryUrl) == normScoped } + val norm = catalogProvider.normalizeUrl(scoped) ?: scoped + val filtered = artifacts.filter { (catalogProvider.normalizeUrl(it.repositoryUrl) ?: it.repositoryUrl) == norm } if (filtered.isNotEmpty()) return filtered - val installed = directManager.installed.value.filter { it.repositoryUrl == scoped || it.packageName == scoped.removePrefix("local://") } + val installed = + directManager.installed.value.filter { + it.repositoryUrl == scoped || it.packageName == scoped.removePrefix("local://") + } return installed.map { it.toArtifact() } } - suspend fun installTachiyomi(item: SourceCatalogItem.Tachiyomi): Boolean { - return try { + suspend fun installExtension(item: SourceCatalogItem.Extension): Boolean = + try { installingPackages.update { it + item.artifact.packageName } if (item.isInstalled && !item.hasUpdate && !item.isLoaded) { - val source = getImportedTachiyomiSource(item) ?: return false - repository.setSourcesEnabled(setOf(source), true) - tachiyomiRuntime.ensureReady(forceRefresh = true) + val s = getImportedExtensionSource(item) ?: return false + repository.setSourcesEnabled(setOf(s), true) + runtime.ensureReady(forceRefresh = true) true } else { - val installed = directManager.install(item.artifact) - if (installed) { - tachiyomiRuntime.ensureReady(forceRefresh = true) - val source = getImportedTachiyomiSource(item) - if (source != null) { - repository.setSourcesEnabled(setOf(source), true) - } + val ok = directManager.install(item.artifact) + if (ok) { + runtime.ensureReady(forceRefresh = true) + getImportedExtensionSource(item)?.let { repository.setSourcesEnabled(setOf(it), true) } } - installed + ok } } catch (_: Throwable) { false } finally { installingPackages.update { it - item.artifact.packageName } } - } - suspend fun uninstallTachiyomi(item: SourceCatalogItem.Tachiyomi): Boolean = + suspend fun uninstallExtension(item: SourceCatalogItem.Extension): Boolean = runCatching { - val removed = directManager.remove(item.artifact.packageName) - if (removed) { - tachiyomiRuntime.ensureReady(forceRefresh = true) - } - removed + val ok = directManager.remove(item.artifact.packageName) + if (ok) runtime.ensureReady(forceRefresh = true) + ok }.getOrDefault(false) - suspend fun unloadTachiyomi(item: SourceCatalogItem.Tachiyomi): Boolean { - val source = getImportedTachiyomiSource(item) ?: return false - repository.setSourcesEnabled(setOf(source), false) - tachiyomiRuntime.ensureReady(forceRefresh = true) - return true - } + suspend fun getImportedExtensionSource(item: SourceCatalogItem.Extension): MangaSource? { + runtime.ensureReady() + return runtime.getSourceById(item.source.id) + ?: runtime.getSourceByName(item.source.name) + ?: directManager.sources.value.firstOrNull { it.matches(item) } + ?: installedExtManager.sources.value.firstOrNull { it.matches(item) } + ?: repository.allMangaSources.firstOrNull { s -> + when (val u = s.unwrap()) { + is ExtMangaSource -> { + u.matches(item) || + ( + u.pkgName == item.artifact.packageName && + u.displayName.equals(item.source.name, true) + ) + } - suspend fun getImportedTachiyomiSource(item: SourceCatalogItem.Tachiyomi): MangaSource? { - tachiyomiRuntime.ensureReady() - return tachiyomiRuntime.getSourceById(item.source.id) - ?: tachiyomiRuntime.getSourceByName(item.source.name) - ?: directManager.sources.value.firstOrNull { it.matchesCatalogItem(item) } - ?: installedTachiyomiManager.sources.value.firstOrNull { it.matchesCatalogItem(item) } - ?: repository.allMangaSources.firstOrNull { source -> - when (val unwrapped = source.unwrap()) { - is TachiyomiMangaSource -> unwrapped.matchesCatalogItem(item) || (unwrapped.pkgName == item.artifact.packageName && unwrapped.displayName.equals(item.source.name, ignoreCase = true)) - is ExternalMangaSource -> unwrapped.packageName == item.artifact.packageName - else -> false + is ExternalMangaSource -> { + u.packageName == item.artifact.packageName + } + + else -> { + false + } } } } - suspend fun enableTachiyomiSource(source: MangaSource) { + suspend fun enableExtensionSource(source: MangaSource) { repository.setSourcesEnabled(setOf(source), true) - tachiyomiRuntime.ensureReady(forceRefresh = true) + runtime.ensureReady(forceRefresh = true) } - private fun TachiyomiMangaSource.matchesCatalogItem(item: SourceCatalogItem.Tachiyomi): Boolean = matchesCatalogSource(item.artifact, item.source) + private fun ExtMangaSource.matches(item: SourceCatalogItem.Extension) = matchesCatalog(item.artifact, item.source) - private fun TachiyomiMangaSource.matchesCatalogSource( - artifact: TachiyomiExtensionArtifact, - source: TachiyomiCatalogSource, - ): Boolean = - pkgName == artifact.packageName && - displayName.equals(source.name, ignoreCase = true) && - canonicalLanguageCode(locale) == canonicalLanguageCode(source.language) + private fun ExtMangaSource.matchesCatalog( + art: ExtArtifact, + src: ExtSource, + ) = pkgName == art.packageName && + displayName.equals(src.name, true) && + canonicalLanguageCode(locale) == canonicalLanguageCode(src.language) - private data class CatalogTachiyomiState( - val installed: List, + private data class CatalogExtState( + val installed: List, val loadedSourceIds: Set, - val preInstalledSources: List, + val preInstalledSources: List, val loading: Boolean, val installingPackages: Set, ) fun addSource(source: MangaSource) { launchJob(Dispatchers.Default) { - val allVariants = - repository.allMangaSources - .filter { it.title.equals(source.title, ignoreCase = true) } - .ifEmpty { listOf(source) } - val rollback = repository.setSourcesEnabled(allVariants, true) + val all = repository.allMangaSources.filter { it.title.equals(source.title, true) }.ifEmpty { listOf(source) } + val rollback = repository.setSourcesEnabled(all, true) onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) } } @@ -261,11 +327,13 @@ class SourcesCatalogViewModel value: ContentType, isAdd: Boolean, ) { - val filter = appliedFilter.value - val types = EnumSet.noneOf(ContentType::class.java) - types.addAll(filter.types) - if (isAdd) types.add(value) else types.remove(value) - appliedFilter.value = filter.copy(types = types) + val f = appliedFilter.value + val types = + EnumSet.noneOf(ContentType::class.java).apply { + addAll(f.types) + if (isAdd) add(value) else remove(value) + } + appliedFilter.value = f.copy(types = types) } fun setNewOnly(value: Boolean) { @@ -279,14 +347,14 @@ class SourcesCatalogViewModel private suspend fun buildSourcesList( filter: SourcesCatalogFilter, query: String?, - artifacts: List, - installed: List, - loadedSourceIds: Set, - preInstalledSources: List, - installingPackages: Set, + artifacts: List, + installed: List, + loadedIds: Set, + pre: List, + installing: Set, ): List { if (!isScopedMode) { - val allDisabledSources = + val sources = repository.queryParserSources( isDisabledOnly = true, isNewOnly = filter.isNewOnly, @@ -297,150 +365,161 @@ class SourcesCatalogViewModel plugin = filter.plugin, sortOrder = SourcesSortOrder.ALPHABETIC, ) - val allGrouped = - allDisabledSources.groupBy { source: MangaSource -> - val plugin = - (source as? org.draken.usagi.core.model.PluginMangaSource)?.jarName - ?: (source as? MangaSourceInfo)?.mangaSource as? org.draken.usagi.core.model.PluginMangaSource - (plugin ?: "") to source.title + val grouped = + sources.groupBy { s: MangaSource -> + val ps = (s as? PluginMangaSource) ?: (s as? MangaSourceInfo)?.mangaSource as? PluginMangaSource + ps?.jarName.orEmpty() to s.title } - - val newLocales = + locales.value = buildSet { add(null) - for ((_, variants) in allGrouped) { - if (variants.size > 1 || variants.any { it.locale.equals("all", true) }) { + grouped.values.forEach { v -> + if (v.size > 1 || v.any { it.locale.equals("all", true) }) { add("all") } else { - variants.forEach { add(canonicalLanguageCode(it.locale)) } + v.forEach { add(canonicalLanguageCode(it.locale)) } } } } - if (locales.value != newLocales) { - locales.value = newLocales - } - - val result = ArrayList(allGrouped.size) - for ((_, variants) in allGrouped) { - val isMultiLanguage = variants.size > 1 || variants.any { it.locale.equals("all", true) } + val res = ArrayList(grouped.size) + for ((_, variants) in grouped) { + val multi = variants.size > 1 || variants.any { it.locale.equals("all", true) } if (filter.locale != null) { if (filter.locale.equals("all", true)) { - if (!isMultiLanguage) continue + if (!multi) continue } else { - if (isMultiLanguage) continue - val filterLang = canonicalLanguageCode(filter.locale) - if (variants.none { canonicalLanguageCode(it.locale) == filterLang }) continue + val hasLocale = + variants.any { + canonicalLanguageCode(it.locale) == canonicalLanguageCode(filter.locale) + } + if (multi || !hasLocale) continue } } - val preferred = - variants.firstOrNull { it.locale.equals(Locale.getDefault().language, true) } + val defaultLang = Locale.getDefault().language + val pref = + variants.firstOrNull { it.locale.equals(defaultLang, true) } ?: variants.firstOrNull { it.locale.equals("en", true) } ?: variants.first() - if (!query.isNullOrBlank() && !preferred.title.contains(query, true) && !preferred.name.contains(query, true)) continue - result.add(SourceCatalogItem.Source(preferred, isMultiLanguage = isMultiLanguage)) + if (!query.isNullOrBlank() && + !pref.title.contains(query, true) && + !pref.name.contains(query, true) + ) { + continue + } + res.add(SourceCatalogItem.Source(pref, isMultiLanguage = multi)) } - return if (result.isEmpty()) { - listOf( + return res.ifEmpty { + val title = if (query == null) { - SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.no_manga_sources, R.string.no_manga_sources_catalog_text) + R.string.no_manga_sources } else { - SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.nothing_found, R.string.no_manga_sources_found) - }, - ) - } else { - result + R.string.nothing_found + } + val sub = + if (query == null) { + R.string.no_manga_sources_catalog_text + } else { + R.string.no_manga_sources_found + } + listOf(SourceCatalogItem.Hint(R.drawable.ic_empty_feed, title, sub)) } } val scoped = scopedArtifacts(artifacts) - val newLocales = + locales.value = buildSet { add(null) - for (artifact in scoped) { - if (artifact.sources.size > 1 || artifact.sources.any { it.language.equals("all", true) }) { + scoped.forEach { a -> + val isMulti = + a.sources.size > 1 || + a.sources.any { it.language.equals("all", true) } + if (isMulti) { add("all") } else { - artifact.sources.forEach { add(canonicalLanguageCode(it.language)) } + a.sources.forEach { add(canonicalLanguageCode(it.language)) } } } } - if (locales.value != newLocales) { - locales.value = newLocales - } - val installedByPackage = installed.associateBy { it.packageName } - val tachiyomiSources = ArrayList() - for (artifact in scoped) { - val isMultiLanguage = artifact.sources.size > 1 || artifact.sources.any { it.language.equals("all", true) } + val instMap = installed.associateBy { it.packageName } + val extSources = ArrayList() + for (art in scoped) { + val multi = + art.sources.size > 1 || + art.sources.any { it.language.equals("all", true) } if (filter.locale != null) { if (filter.locale.equals("all", true)) { - if (!isMultiLanguage) continue + if (!multi) continue } else { - if (isMultiLanguage) continue - val filterLang = canonicalLanguageCode(filter.locale) - if (artifact.sources.none { canonicalLanguageCode(it.language) == filterLang }) continue + val hasLang = + art.sources.any { + canonicalLanguageCode(it.language) == canonicalLanguageCode(filter.locale) + } + if (multi || !hasLang) continue } } - val candidate = - artifact.sources.firstOrNull { matchesLocale(it.language, Locale.getDefault().language) } - ?: artifact.sources.firstOrNull { it.language.equals("en", true) } - ?: artifact.sources.firstOrNull() - ?: continue - val type = candidate.contentType - if (settings.isNsfwContentDisabled && type == ContentType.HENTAI) continue - if (filter.types.isNotEmpty() && type !in filter.types) continue - if (!query.isNullOrBlank() && !candidate.name.contains(query, true) && !artifact.name.contains(query, true) && !artifact.packageName.contains(query, true)) continue - - val customPluginName = - catalogProvider.repositoryName(artifact.repositoryUrl) - ?: DirectTachiyomiPluginMetadata - .get(artifact.packageName) - tachiyomiSources.add( - SourceCatalogItem.Tachiyomi( - source = candidate, - artifact = artifact, - installed = installedByPackage[artifact.packageName], - isLoaded = candidate.id in loadedSourceIds, - isPreInstalledApk = preInstalledSources.any { installedSource -> installedSource.matchesCatalogSource(artifact, candidate) }, - isMultiLanguage = isMultiLanguage, - isInstalling = artifact.packageName in installingPackages, - customPluginName = customPluginName, + val defLang = Locale.getDefault().language + val c = + art.sources.firstOrNull { + canonicalLanguageCode(it.language) == canonicalLanguageCode(defLang) + } ?: art.sources.firstOrNull { it.language.equals("en", true) } + ?: art.sources.firstOrNull() ?: continue + if (settings.isNsfwContentDisabled && c.contentType == ContentType.HENTAI) continue + if (filter.types.isNotEmpty() && c.contentType !in filter.types) continue + if (!query.isNullOrBlank() && + !c.name.contains(query, true) && + !art.name.contains(query, true) && + !art.packageName.contains(query, true) + ) { + continue + } + val custom = + catalogProvider.repositoryName(art.repositoryUrl) + ?: DirectExternalPluginMetadata.get(art.packageName) + extSources.add( + SourceCatalogItem.Extension( + c, + art, + instMap[art.packageName], + c.id in loadedIds, + pre.any { it.matchesCatalog(art, c) }, + multi, + art.packageName in installing, + custom, ), ) } - return if (tachiyomiSources.isEmpty()) { - listOf( + return extSources.ifEmpty { + val title = if (query == null) { - SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.no_manga_sources, R.string.no_manga_sources_catalog_text) + R.string.no_manga_sources } else { - SourceCatalogItem.Hint(R.drawable.ic_empty_feed, R.string.nothing_found, R.string.no_manga_sources_found) - }, - ) - } else { - tachiyomiSources + R.string.nothing_found + } + val sub = + if (query == null) { + R.string.no_manga_sources_catalog_text + } else { + R.string.no_manga_sources_found + } + listOf(SourceCatalogItem.Hint(R.drawable.ic_empty_feed, title, sub)) } } - @WorkerThread - private fun matchesLocale( - sourceLanguage: String, - filterLocale: String?, - ): Boolean { - if (filterLocale == null) return true - return canonicalLanguageCode(sourceLanguage) == canonicalLanguageCode(filterLocale) - } - @WorkerThread private fun getContentTypes(isNsfwDisabled: Boolean): List { - if (!isScopedMode) { - val result = repository.allMangaSources.mapSortedByCount { it.contentType }.toMutableList() - return if (isNsfwDisabled) result.filterNot { it == ContentType.HENTAI } else result - } - val scoped = scopedArtifacts(tachiyomiCatalog.value) - val result = scoped.flatMap { it.sources }.mapSortedByCount { it.contentType }.toMutableList() - return if (isNsfwDisabled) result.filterNot { it == ContentType.HENTAI } else result + val all = + if (!isScopedMode) { + repository.allMangaSources.mapSortedByCount { it.contentType } + } else { + scopedArtifacts(extCatalog.value) + .flatMap { it.sources } + .mapSortedByCount { it.contentType } + } + return if (isNsfwDisabled) all.filterNot { it == ContentType.HENTAI } else all } companion object { + const val APK_MIME_TYPE = "application/vnd.android.package-archive" const val EXTRA_REPOSITORY_URL = "repository_url" const val EXTRA_REPOSITORY_NAME = "repository_name" } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt index 4a8b7a06..1430b9e5 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesListProducer.kt @@ -92,7 +92,6 @@ class SourcesListProducer isAvailable = !isNsfwDisabled || !it.isNsfw(), isPinned = it.name in pinned, isDisableAvailable = isDisableAvailable, - uninstallPackageName = null, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) @@ -116,7 +115,6 @@ class SourcesListProducer isAvailable = false, isPinned = it.name in pinned, isDisableAvailable = isDisableAvailable, - uninstallPackageName = null, ) } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt index d017fd03..475f2424 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageFragment.kt @@ -19,6 +19,7 @@ import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.draken.usagi.R +import org.draken.usagi.core.model.externalPackageName import org.draken.usagi.core.nav.AppRouter import org.draken.usagi.core.nav.router import org.draken.usagi.core.os.AppShortcutManager @@ -144,7 +145,7 @@ class SourcesManageFragment : item: SourceConfigItem.SourceItem, isEnabled: Boolean, ) { - val packageName = item.uninstallPackageName + val packageName = item.source.externalPackageName() if (!isEnabled && packageName != null) { uninstallExternalPackage(packageName) } else { diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt index 0e963411..25041c06 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/SourcesManageViewModel.kt @@ -4,7 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime +import org.draken.tsukimix.core.parser.external.ExtRuntime import org.draken.usagi.R import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.db.removeObserverAsync @@ -28,7 +28,7 @@ class SourcesManageViewModel private val settings: AppSettings, private val repository: MangaSourcesRepository, private val listProducer: SourcesListProducer, - private val tachiyomiRuntime: TachiyomiRuntime, + private val extRuntime: ExtRuntime, ) : BaseViewModel() { val content = listProducer.list val onActionDone = MutableEventFlow() @@ -78,12 +78,12 @@ class SourcesManageViewModel ) { launchJob(Dispatchers.Default) { val rollback = repository.setSourcesEnabled(setOf(source), isEnabled) - tachiyomiRuntime.ensureReady(forceRefresh = true) + extRuntime.ensureReady(forceRefresh = true) if (!isEnabled) { val synchronizedRollback = ReversibleHandle { rollback.reverse() - tachiyomiRuntime.ensureReady(forceRefresh = true) + extRuntime.ensureReady(forceRefresh = true) } onActionDone.call(ReversibleAction(R.string.source_disabled, synchronizedRollback)) } @@ -134,7 +134,7 @@ class SourcesManageViewModel fun disableAll() { launchJob(Dispatchers.Default) { repository.disableAllSources() - tachiyomiRuntime.ensureReady(forceRefresh = true) + extRuntime.ensureReady(forceRefresh = true) } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt index b13d393f..34af9f4f 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginManageAdapter.kt @@ -32,126 +32,102 @@ import com.google.android.material.R as materialR class PluginManageAdapter( onRenameClick: (PluginManageItem.Plugin) -> Unit, onUpdateClick: (PluginManageItem.Plugin) -> Unit, - onTachiyomiRenameClick: (PluginManageItem.Tachiyomi) -> Unit, - onTachiyomiLongClick: (PluginManageItem.Tachiyomi) -> Unit, - onTachiyomiClick: (PluginManageItem.Tachiyomi) -> Unit, + onExtRenameClick: (PluginManageItem.Extension) -> Unit, + onExtLongClick: (PluginManageItem.Extension) -> Unit, + onExtClick: (PluginManageItem.Extension) -> Unit, onLongClick: (PluginManageItem.Plugin) -> Unit, onClick: (PluginManageItem.Plugin) -> Unit, isSelected: (PluginManageItem.Plugin) -> Boolean, - isTachiyomiSelected: (PluginManageItem.Tachiyomi) -> Boolean, + isExtSelected: (PluginManageItem.Extension) -> Boolean, ) : BaseListAdapter() { init { - addDelegate(ListItemType.STATE_LOADING, loadingItemDelegate()) - addDelegate(ListItemType.CHAPTER_LIST, pluginItemDelegate(onRenameClick, onUpdateClick, onLongClick, onClick, isSelected)) - addDelegate(ListItemType.INFO, tachiyomiItemDelegate(onTachiyomiRenameClick, onTachiyomiLongClick, onTachiyomiClick, isTachiyomiSelected)) - addDelegate(ListItemType.HINT_EMPTY, pluginPlaceholderDelegate()) + addDelegate(ListItemType.STATE_LOADING, loadingAD()) + addDelegate(ListItemType.CHAPTER_LIST, pluginAD(onRenameClick, onUpdateClick, onLongClick, onClick, isSelected)) + addDelegate(ListItemType.INFO, extAD(onExtRenameClick, onExtLongClick, onExtClick, isExtSelected)) + addDelegate(ListItemType.HINT_EMPTY, placeholderAD()) } @SuppressLint("ClickableViewAccessibility") - private fun pluginItemDelegate( - onRenameClick: (PluginManageItem.Plugin) -> Unit, - onUpdateClick: (PluginManageItem.Plugin) -> Unit, - onLongClick: (PluginManageItem.Plugin) -> Unit, + private fun pluginAD( + onRename: (PluginManageItem.Plugin) -> Unit, + onUpdate: (PluginManageItem.Plugin) -> Unit, + onLong: (PluginManageItem.Plugin) -> Unit, onClick: (PluginManageItem.Plugin) -> Unit, - isSelected: (PluginManageItem.Plugin) -> Boolean, + selected: (PluginManageItem.Plugin) -> Boolean, ) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, + { inf, p -> ItemSourceConfigBinding.inflate(inf, p, false) }, ) { binding.imageViewIcon.background = null - binding.imageViewMenu.isVisible = true binding.imageViewMenu.setImageResource(R.drawable.ic_edit) - binding.imageViewMenu.contentDescription = context.getString(R.string.rename) - binding.imageViewMenu.setOnClickListener { onRenameClick(item) } + binding.imageViewMenu.setOnClickListener { onRename(item) } binding.imageViewRemove.isVisible = false binding.imageViewAdd.setImageResource(R.drawable.ic_download) - binding.imageViewAdd.contentDescription = context.getString(R.string.update) - itemView.setOnLongClickListener { - onLongClick(item) + onLong(item) true } itemView.setOnClickListener { onClick(item) } bind { - itemView.isSelected = isSelected(item) - binding.textViewTitle.background = null - binding.textViewTitle.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - binding.textViewDescription.background = null - binding.textViewDescription.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - - val avatarUrl = item.repository?.takeIf { it.isNotBlank() }?.let(::githubAvatarUrl) - val fallback = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.displayName) - binding.imageViewIcon.errorDrawable = fallback - binding.imageViewIcon.fallbackDrawable = fallback - if (avatarUrl.isNullOrBlank()) { + itemView.isSelected = selected(item) + resetViews(binding) + val avatar = item.repository?.takeIf { it.isNotBlank() }?.let(::githubAvatarUrl) + val fb = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.displayName) + binding.imageViewIcon.errorDrawable = fb + binding.imageViewIcon.fallbackDrawable = fb + if (avatar.isNullOrBlank()) { binding.imageViewIcon.setImageResource(R.drawable.ic_services) } else { - applyRemoteIconShape(binding.imageViewIcon, context) - binding.imageViewIcon.setImageAsync(avatarUrl) + applyIconShape(binding.imageViewIcon, context) + binding.imageViewIcon.setImageAsync(avatar) } binding.textViewTitle.text = item.displayName - binding.textViewDescription.text = - buildList { - item.repository?.takeIf { it.isNotBlank() }?.let { add(repositoryLabel(it)) } ?: add(item.name) - item.installedTag?.takeIf { it.isNotBlank() }?.let(::add) - }.joinToString(" • ") + listOfNotNull( + item.repository?.takeIf { it.isNotBlank() }?.let(::repoLabel) ?: item.name, + item.installedTag?.takeIf { it.isNotBlank() }, + ).joinToString(" • ") binding.imageViewAdd.isVisible = item.hasUpdate - binding.imageViewAdd.setOnClickListener(if (item.hasUpdate) View.OnClickListener { onUpdateClick(item) } else null) + binding.imageViewAdd.setOnClickListener(if (item.hasUpdate) View.OnClickListener { onUpdate(item) } else null) } } - private fun tachiyomiItemDelegate( - onRenameClick: (PluginManageItem.Tachiyomi) -> Unit, - onLongClick: (PluginManageItem.Tachiyomi) -> Unit, - onClick: (PluginManageItem.Tachiyomi) -> Unit, - isSelected: (PluginManageItem.Tachiyomi) -> Boolean, - ) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, + private fun extAD( + onRename: (PluginManageItem.Extension) -> Unit, + onLong: (PluginManageItem.Extension) -> Unit, + onClick: (PluginManageItem.Extension) -> Unit, + selected: (PluginManageItem.Extension) -> Boolean, + ) = adapterDelegateViewBinding( + { inf, p -> ItemSourceConfigBinding.inflate(inf, p, false) }, ) { binding.imageViewIcon.background = null + binding.imageViewMenu.isVisible = true binding.imageViewMenu.setImageResource(R.drawable.ic_edit) - binding.imageViewMenu.contentDescription = context.getString(R.string.rename) - binding.imageViewRemove.isVisible = false binding.imageViewAdd.isVisible = false itemView.setOnLongClickListener { - onLongClick(item) + onLong(item) true } itemView.setOnClickListener { onClick(item) } bind { - itemView.isSelected = isSelected(item) - binding.textViewTitle.background = null - binding.textViewTitle.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - binding.textViewDescription.background = null - binding.textViewDescription.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - - applyRemoteIconShape(binding.imageViewIcon, context) - - if (item.isLocal) { - val fallback = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.displayName) - binding.imageViewIcon.errorDrawable = fallback - binding.imageViewIcon.fallbackDrawable = fallback - val iconUrl = item.installed.firstOrNull()?.iconUrl - if (!iconUrl.isNullOrBlank()) { - binding.imageViewIcon.setImageAsync(iconUrl) - } else { - binding.imageViewIcon.setImageDrawable(fallback) - } + itemView.isSelected = selected(item) + resetViews(binding) + applyIconShape(binding.imageViewIcon, context) + val iconLabel = if (item.isLocal) item.displayName else item.repositoryLabel + val fb = FaviconDrawable(context, R.style.FaviconDrawable_Small, iconLabel) + binding.imageViewIcon.errorDrawable = fb + binding.imageViewIcon.fallbackDrawable = fb + val icon = if (item.isLocal) item.installed.firstOrNull()?.iconUrl else githubAvatarUrl(item.repositoryLabel) + if (icon.isNullOrBlank()) { + binding.imageViewIcon.setImageDrawable(fb) } else { - val fallback = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.repositoryLabel) - binding.imageViewIcon.errorDrawable = fallback - binding.imageViewIcon.fallbackDrawable = fallback - val avatarUrl = githubAvatarUrl(item.repositoryLabel) - binding.imageViewIcon.setImageAsync(avatarUrl) + binding.imageViewIcon.setImageAsync(icon) } - binding.imageViewMenu.isVisible = true - binding.imageViewMenu.setOnClickListener { onRenameClick(item) } - + binding.imageViewMenu.setOnClickListener { onRename(item) } binding.textViewTitle.text = item.displayName binding.textViewDescription.text = buildList { @@ -162,35 +138,36 @@ class PluginManageAdapter( } } - private fun loadingItemDelegate() = + private fun loadingAD() = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, + { inf, p -> ItemSourceConfigBinding.inflate(inf, p, false) }, ) { binding.imageViewMenu.isVisible = false binding.imageViewRemove.isVisible = false binding.imageViewAdd.isVisible = false itemView.isClickable = false - itemView.isFocusable = false - + onViewRecycled { + (binding.imageViewIcon.background as? Animatable)?.stop() + (binding.textViewTitle.background as? Animatable)?.stop() + (binding.textViewDescription.background as? Animatable)?.stop() + } bind { itemView.isSelected = false - val density = context.resources.displayMetrics.density + val d = context.resources.displayMetrics.density binding.imageViewIcon.setImageDrawable(null) - binding.imageViewIcon.background = ShimmerPlaceholderDrawable(context, 8f * density) - + binding.imageViewIcon.background = ShimmerDrawable(context, 8f * d) binding.textViewTitle.text = " " - binding.textViewTitle.layoutParams.width = (180 * density).toInt() - binding.textViewTitle.background = ShimmerPlaceholderDrawable(context, 4f * density) - + binding.textViewTitle.layoutParams.width = (180 * d).toInt() + binding.textViewTitle.background = ShimmerDrawable(context, 4f * d) binding.textViewDescription.text = " " - binding.textViewDescription.layoutParams.width = (120 * density).toInt() - binding.textViewDescription.background = ShimmerPlaceholderDrawable(context, 4f * density) + binding.textViewDescription.layoutParams.width = (120 * d).toInt() + binding.textViewDescription.background = ShimmerDrawable(context, 4f * d) } } - private fun pluginPlaceholderDelegate() = + private fun placeholderAD() = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemEmptyHintBinding.inflate(layoutInflater, parent, false) }, + { inf, p -> ItemEmptyHintBinding.inflate(inf, p, false) }, ) { binding.icon.setImageResource(R.drawable.ic_empty_feed) bind { @@ -199,93 +176,110 @@ class PluginManageAdapter( } } - private class ShimmerPlaceholderDrawable( + private class ShimmerDrawable( context: Context, - private val cornerRadius: Float = 8f, + private val radius: Float = 8f, ) : Drawable(), Animatable, TimeAnimator.TimeListener { - private val colorLow = context.getThemeColor(materialR.attr.colorSurfaceContainerLowest, Color.DKGRAY) - private val colorHigh = context.getThemeColor(materialR.attr.colorSurfaceContainerHighest, Color.LTGRAY) + private val c1 = context.getThemeColor(materialR.attr.colorSurfaceContainerLowest, Color.DKGRAY) + private val c2 = context.getThemeColor(materialR.attr.colorSurfaceContainerHighest, Color.LTGRAY) private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val timeAnimator = TimeAnimator().apply { setTimeListener(this@ShimmerPlaceholderDrawable) } + private val anim = TimeAnimator().apply { setTimeListener(this@ShimmerDrawable) } private var phase = 0f override fun draw(canvas: Canvas) { if (!isRunning) start() - val bounds = bounds - if (bounds.width() <= 0 || bounds.height() <= 0) return - - val w = bounds.width().toFloat() - val h = bounds.height().toFloat() - val bandWidth = w * 0.7f - val x = (w + bandWidth * 2) * phase - bandWidth - + val b = bounds + if (b.width() <= 0 || b.height() <= 0) return + val w = b.width().toFloat() + val bw = w * 0.7f + val x = (w + bw * 2) * phase - bw paint.shader = LinearGradient( x, 0f, - x + bandWidth, + x + bw, 0f, - intArrayOf(colorLow, colorHigh, colorLow), + intArrayOf(c1, c2, c1), floatArrayOf(0f, 0.5f, 1f), Shader.TileMode.CLAMP, ) - canvas.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint) + canvas.drawRoundRect(0f, 0f, w, b.height().toFloat(), radius, radius, paint) } override fun onTimeUpdate( - animation: TimeAnimator?, - totalTime: Long, - deltaTime: Long, + a: TimeAnimator?, + total: Long, + dt: Long, ) { - phase = (totalTime % 1200L) / 1200f + phase = (total % 1200L) / 1200f invalidateSelf() } override fun start() { - if (!timeAnimator.isRunning) timeAnimator.start() + if (!anim.isRunning) anim.start() } override fun stop() { - timeAnimator.cancel() + anim.cancel() } - override fun isRunning(): Boolean = timeAnimator.isRunning + override fun isRunning() = anim.isRunning + + override fun setVisible( + v: Boolean, + r: Boolean, + ): Boolean { + val changed = super.setVisible(v, r) + if (v) start() else stop() + return changed + } override fun setAlpha(alpha: Int) { paint.alpha = alpha } - override fun setColorFilter(colorFilter: ColorFilter?) { - paint.colorFilter = colorFilter + override fun setColorFilter(cf: ColorFilter?) { + paint.colorFilter = cf } @Deprecated("Deprecated in Java") - override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + override fun getOpacity() = PixelFormat.TRANSLUCENT } - private fun githubAvatarUrl(repository: String): String? { - val owner = repositoryLabel(repository).substringBefore('/').trim() - return owner.takeIf { it.isNotBlank() }?.let { "https://github.com/$it.png" } - } + companion object { + private fun resetViews(b: ItemSourceConfigBinding) { + b.textViewTitle.background = null + b.textViewTitle.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + b.textViewDescription.background = null + b.textViewDescription.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + } - private fun applyRemoteIconShape( - imageView: org.draken.usagi.core.ui.image.FaviconView, - context: Context, - ) { - imageView.shapeAppearanceModel = - imageView.shapeAppearanceModel - .toBuilder() - .setAllCorners(CornerFamily.ROUNDED, context.resources.getDimension(R.dimen.margin_small)) - .build() - } + fun githubAvatarUrl(repo: String): String? = + repoLabel(repo) + .substringBefore('/') + .trim() + .takeIf { it.isNotBlank() } + ?.let { "https://github.com/$it.png" } - private fun repositoryLabel(repository: String): String = - repository - .trim() - .removeSuffix("/") - .removePrefix("https://github.com/") - .removePrefix("http://github.com/") - .removePrefix("github.com/") + fun applyIconShape( + iv: org.draken.usagi.core.ui.image.FaviconView, + ctx: Context, + ) { + iv.shapeAppearanceModel = + iv.shapeAppearanceModel + .toBuilder() + .setAllCorners(CornerFamily.ROUNDED, ctx.resources.getDimension(R.dimen.margin_small)) + .build() + } + + fun repoLabel(repo: String): String = + repo + .trim() + .removeSuffix("/") + .removePrefix("https://github.com/") + .removePrefix("http://github.com/") + .removePrefix("github.com/") + } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt index c0a3d567..28796f1b 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageFragment.kt @@ -31,6 +31,7 @@ import org.draken.usagi.core.ui.util.RecyclerViewOwner import org.draken.usagi.core.util.ext.addMenuProvider import org.draken.usagi.core.util.ext.container import org.draken.usagi.core.util.ext.end +import org.draken.usagi.core.util.ext.observeEvent import org.draken.usagi.core.util.ext.start import org.draken.usagi.databinding.DialogImportBinding import org.draken.usagi.databinding.FragmentSettingsSourcesBinding @@ -47,19 +48,14 @@ class PluginsManageFragment : private var pluginsAdapter: PluginManageAdapter? = null private val launcher = - registerForActivityResult( - ActivityResultContracts.OpenDocument(), - ) { uri -> + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null && isAdded) { - viewLifecycleOwnerLiveData.value?.lifecycleScope?.launch { - viewModel - .importPlugin( - uri = uri, - getOriginalName = { DocumentFile.fromSingleUri(requireContext().applicationContext, it)?.name }, - askName = { askText(R.string.set_plugin_name, it, R.string.plugin_name) }, - askOverwrite = ::askOverwrite, - )?.let(::showImportResult) - } + viewModel.importPlugin( + uri = uri, + getOriginalName = { DocumentFile.fromSingleUri(requireContext().applicationContext, it)?.name }, + askName = { askText(R.string.set_plugin_name, it, R.string.plugin_name) }, + askOverwrite = ::askOverwrite, + ) } } @@ -69,7 +65,7 @@ class PluginsManageFragment : override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, - ): FragmentSettingsSourcesBinding = FragmentSettingsSourcesBinding.inflate(inflater, container, false) + ) = FragmentSettingsSourcesBinding.inflate(inflater, container, false) @SuppressLint("NotifyDataSetChanged") override fun onViewBindingCreated( @@ -80,14 +76,14 @@ class PluginsManageFragment : pluginsAdapter = PluginManageAdapter( onRenameClick = ::onRenameClick, - onUpdateClick = ::onUpdateClick, - onTachiyomiRenameClick = ::onTachiyomiRenameClick, - onTachiyomiLongClick = ::onTachiyomiLongClick, - onTachiyomiClick = ::onTachiyomiClick, - onLongClick = ::onLongClick, - onClick = ::onClick, - isSelected = { item -> viewModel.isSelected(item.name) }, - isTachiyomiSelected = viewModel::isTachiyomiSelected, + onUpdateClick = viewModel::updatePlugin, + onExtRenameClick = ::onExtRenameClick, + onExtLongClick = viewModel::toggleExtensionSelection, + onExtClick = ::onExtClick, + onLongClick = { viewModel.toggleSelection(it.name) }, + onClick = { if (viewModel.selectedPlugins.value.isNotEmpty()) viewModel.toggleSelection(it.name) }, + isSelected = { viewModel.isSelected(it.name) }, + isExtSelected = viewModel::isExtensionSelected, ) with(binding.recyclerView) { setHasFixedSize(true) @@ -95,36 +91,29 @@ class PluginsManageFragment : adapter = pluginsAdapter } - val onBackPressedCallback = + val onBack = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - viewModel.clearSelection() - } + override fun handleOnBackPressed() = viewModel.clearSelection() } - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBack) + binding.fabImport.setOnClickListener { showImportDialog() } - binding.fabImport.setOnClickListener { - showImportDialog() - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.content.collect { pluginsAdapter?.emit(it) } + viewLifecycleOwner.lifecycleScope.launch { viewModel.content.collect { pluginsAdapter?.emit(it) } } + viewModel.operationResult.observeEvent(viewLifecycleOwner) { result -> + viewBinding?.root?.let { Snackbar.make(it, result.messageResId, Snackbar.LENGTH_SHORT).show() } } - viewLifecycleOwner.lifecycleScope.launch { viewModel.selectedPlugins.collect { selected -> - val isSelectionMode = selected.isNotEmpty() - onBackPressedCallback.isEnabled = isSelectionMode - - val actionBar = (activity as? androidx.appcompat.app.AppCompatActivity)?.supportActionBar - if (isSelectionMode) { + val isSelection = selected.isNotEmpty() + onBack.isEnabled = isSelection + val bar = (activity as? androidx.appcompat.app.AppCompatActivity)?.supportActionBar + if (isSelection) { (activity as? SettingsActivity)?.setSectionTitle(selected.size.toString()) - actionBar?.setHomeAsUpIndicator(androidx.appcompat.R.drawable.abc_ic_clear_material) + bar?.setHomeAsUpIndicator(androidx.appcompat.R.drawable.abc_ic_clear_material) } else { (activity as? SettingsActivity)?.setSectionTitle(getString(R.string.manage_plugins)) - actionBar?.setHomeAsUpIndicator(null) + bar?.setHomeAsUpIndicator(null) } - activity?.invalidateOptionsMenu() pluginsAdapter?.notifyDataSetChanged() } @@ -134,8 +123,8 @@ class PluginsManageFragment : PluginsMenuProvider( appBarOwner = activity as? AppBarOwner, isSelectionMode = { viewModel.selectedPlugins.value.isNotEmpty() }, - onClearSelection = { viewModel.clearSelection() }, - onDeleteClick = ::showDeleteSelectedConfirm, + onClearSelection = viewModel::clearSelection, + onDeleteClick = ::showDeleteConfirm, onSearchQueryChanged = viewModel::setQuery, ), ) @@ -145,15 +134,12 @@ class PluginsManageFragment : v: View, insets: WindowInsetsCompat, ): WindowInsetsCompat { - val barsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val isTablet = !resources.getBoolean(R.bool.is_tablet) val isMaster = container?.id == R.id.container_master - v.setPaddingRelative( - if (isTablet && !isMaster) 0 else barsInsets.start(v), - 0, - if (isTablet && isMaster) 0 else barsInsets.end(v), - barsInsets.bottom, - ) + val startPad = if (isTablet && !isMaster) 0 else bars.start(v) + val endPad = if (isTablet && isMaster) 0 else bars.end(v) + v.setPaddingRelative(startPad, 0, endPad, bars.bottom) return WindowInsetsCompat .Builder(insets) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) @@ -168,156 +154,88 @@ class PluginsManageFragment : } override fun onDestroyView() { - val actionBar = (activity as? androidx.appcompat.app.AppCompatActivity)?.supportActionBar - actionBar?.setHomeAsUpIndicator(null) + (activity as? androidx.appcompat.app.AppCompatActivity)?.supportActionBar?.setHomeAsUpIndicator(null) + viewBinding?.recyclerView?.adapter = null pluginsAdapter = null super.onDestroyView() } private fun showImportDialog() { - val binding = DialogImportBinding.inflate(layoutInflater) - binding.buttonFile.title = getString(R.string.load_from_storage) - binding.buttonFile.subtitle = getString(R.string.load_storage_summary) - binding.buttonFile.setIconResource(R.drawable.ic_storage) - binding.buttonDir.title = getString(R.string.import_from_github) - binding.buttonDir.subtitle = getString(R.string.import_github_summary) + val b = DialogImportBinding.inflate(layoutInflater) + b.buttonFile.title = getString(R.string.load_from_storage) + b.buttonFile.subtitle = getString(R.string.load_storage_summary) + b.buttonFile.setIconResource(R.drawable.ic_storage) + b.buttonDir.title = getString(R.string.import_from_github) + b.buttonDir.subtitle = getString(R.string.import_github_summary) + b.buttonDir.setIconResource(R.drawable.ic_open_external) - binding.buttonDir.setIconResource(R.drawable.ic_open_external) val dialog = buildAlertDialog(requireContext()) { setTitle(R.string._import) - setView(binding.root) + setView(b.root) setNegativeButton(android.R.string.cancel, null) } - binding.buttonFile.setOnClickListener { + b.buttonFile.setOnClickListener { dialog.dismiss() - launcher.launch(SUPPORTED_MIME_TYPES) + launcher.launch(PluginFileLoader.SUPPORTED_MIME_TYPES) } - binding.buttonDir.setOnClickListener { + b.buttonDir.setOnClickListener { dialog.dismiss() - viewLifecycleOwner.lifecycleScope.launch { - viewModel - .importUrl( - askInput = { askText(R.string.import_from_github, "", R.string.import_github_summary) }, - askOverwrite = ::askOverwrite, - )?.let(::showImportResult) - } + viewModel.importUrl( + askInput = { askText(R.string.import_from_github, "", R.string.import_github_summary) }, + askOverwrite = ::askOverwrite, + ) } - dialog.show() } private fun onRenameClick(item: PluginManageItem.Plugin) { viewLifecycleOwner.lifecycleScope.launch { - val newName = askText(R.string.rename, item.displayName, R.string.plugin_name) - if (!newName.isNullOrBlank()) { - val success = viewModel.rename(item, newName) - val binding = viewBinding ?: return@launch - Snackbar - .make( - binding.recyclerView, - if (success) R.string.load_success else R.string.load_failed, - Snackbar.LENGTH_SHORT, - ).show() - } + val name = askText(R.string.rename, item.displayName, R.string.plugin_name) + if (!name.isNullOrBlank()) viewModel.rename(item, name) } } - private fun onClick(item: PluginManageItem.Plugin) { - if (viewModel.selectedPlugins.value.isNotEmpty()) { - viewModel.toggleSelection(item.name) - } - } - - private fun onLongClick(item: PluginManageItem.Plugin) { - viewModel.toggleSelection(item.name) - } - - private fun onTachiyomiClick(item: PluginManageItem.Tachiyomi) { + private fun onExtClick(item: PluginManageItem.Extension) { if (viewModel.selectedPlugins.value.isNotEmpty()) { - viewModel.toggleTachiyomiSelection(item) + viewModel.toggleExtensionSelection(item) } else { router.openPluginCatalog(item.repositoryUrl, item.displayName) } } - private fun onTachiyomiLongClick(item: PluginManageItem.Tachiyomi) { - viewModel.toggleTachiyomiSelection(item) - } - - private fun showDeleteSelectedConfirm() { + private fun showDeleteConfirm() { val count = viewModel.selectedPlugins.value.size - val itemsText = resources.getQuantityString(R.plurals.items, count, count) + val items = resources.getQuantityString(R.plurals.items, count, count) buildAlertDialog(requireContext()) { setTitle(R.string.delete_plugin) - setMessage(getString(R.string.confirm_delete_plugin, itemsText)) + setMessage(getString(R.string.confirm_delete_plugin, items)) setNegativeButton(android.R.string.cancel, null) - setPositiveButton(R.string.delete) { _, _ -> - viewLifecycleOwner.lifecycleScope.launch { - val success = viewModel.delete() - val binding = viewBinding ?: return@launch - Snackbar - .make( - binding.root, - if (success) R.string.removal_completed else R.string.load_failed, - Snackbar.LENGTH_SHORT, - ).show() - } - } + setPositiveButton(R.string.delete) { _, _ -> viewModel.delete() } }.show() } - private fun onTachiyomiRenameClick(item: PluginManageItem.Tachiyomi) { - viewLifecycleOwner.lifecycleScope.launch { - val newName = askText(R.string.rename, item.displayName, R.string.plugin_name) - if (!newName.isNullOrBlank()) { - val success = viewModel.renameTachiyomi(item, newName) - val binding = viewBinding ?: return@launch - Snackbar - .make( - binding.root, - if (success) R.string.load_success else R.string.load_failed, - Snackbar.LENGTH_SHORT, - ).show() - } - } - } - - private fun onUpdateClick(item: PluginManageItem.Plugin) { + private fun onExtRenameClick(item: PluginManageItem.Extension) { viewLifecycleOwner.lifecycleScope.launch { - val success = viewModel.updatePlugin(item) - val binding = viewBinding ?: return@launch - Snackbar - .make( - binding.root, - if (success) R.string.load_success else R.string.load_failed, - Snackbar.LENGTH_SHORT, - ).show() + val name = askText(R.string.rename, item.displayName, R.string.plugin_name) + if (!name.isNullOrBlank()) viewModel.renameExtension(item, name) } } private suspend fun askOverwrite(fileName: String): Boolean = withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> - val dialog = + val d = buildAlertDialog(requireContext(), true) { setIcon(R.drawable.ic_replace) setTitle(R.string.overwrite_plugin) setMessage(getString(R.string.overwrite_plugin_summary, fileName)) - setNegativeButton(android.R.string.cancel) { _, _ -> - if (cont.isActive) cont.resume(false) - } - setPositiveButton(R.string.overwrite) { _, _ -> - if (cont.isActive) cont.resume(true) - } + setNegativeButton(android.R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(false) } + setPositiveButton(R.string.overwrite) { _, _ -> if (cont.isActive) cont.resume(true) } } - dialog.setOnCancelListener { - if (cont.isActive) cont.resume(false) - } - cont.invokeOnCancellation { - dialog.dismiss() - } - dialog.show() + d.setOnCancelListener { if (cont.isActive) cont.resume(false) } + cont.invokeOnCancellation { d.dismiss() } + d.show() } } @@ -329,48 +247,24 @@ class PluginsManageFragment : withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> lateinit var input: android.widget.EditText - val dialog = + val d = buildAlertDialog(requireContext()) { input = setEditText(InputType.TYPE_CLASS_TEXT, singleLine = true) input.setText(defaultValue) - if (hintRes != null) { - input.hint = getString(hintRes) - } + if (hintRes != null) input.hint = getString(hintRes) setTitle(titleRes) - setNegativeButton(android.R.string.cancel) { _, _ -> - if (cont.isActive) cont.resume(null) - } - setPositiveButton(android.R.string.ok) { _, _ -> - if (cont.isActive) cont.resume(input.text?.toString()) - } + setNegativeButton(android.R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) } + setPositiveButton(android.R.string.ok) { _, _ -> if (cont.isActive) cont.resume(input.text?.toString()) } } - dialog.setOnCancelListener { - if (cont.isActive) cont.resume(null) - } - dialog.setOnDismissListener { - input.setCursorVisible(false) - } + d.setOnCancelListener { if (cont.isActive) cont.resume(null) } + d.setOnDismissListener { input.setCursorVisible(false) } cont.invokeOnCancellation { input.post { input.setCursorVisible(false) - if (dialog.isShowing) dialog.dismiss() + if (d.isShowing) d.dismiss() } } - dialog.show() + d.show() } } - - private fun showImportResult(isSuccess: Boolean) { - val binding = viewBinding ?: return - Snackbar - .make( - binding.root, - if (isSuccess) R.string.load_success else R.string.load_failed, - Snackbar.LENGTH_LONG, - ).show() - } - - private companion object { - val SUPPORTED_MIME_TYPES = PluginFileLoader.SUPPORTED_MIME_TYPES - } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt index 4dacdcee..6eb36cb4 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/PluginsManageViewModel.kt @@ -2,6 +2,7 @@ package org.draken.usagi.settings.sources.manage.plugins import android.content.Context import android.net.Uri +import androidx.annotation.StringRes import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -11,24 +12,39 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.draken.tsukimix.core.parser.tachiyomi.ExtensionProvider -import org.draken.tsukimix.core.parser.tachiyomi.NativeExtManager -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiRuntime -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact +import org.draken.tsukimix.core.parser.external.ExtRuntime +import org.draken.tsukimix.core.parser.external.ExtensionProvider +import org.draken.tsukimix.core.parser.external.NativeExtManager +import org.draken.tsukimix.core.parser.external.model.ExtArtifact import org.draken.usagi.R import org.draken.usagi.core.db.MangaDatabase -import org.draken.usagi.core.model.DirectTachiyomiPluginMetadata +import org.draken.usagi.core.model.DirectExternalPluginMetadata import org.draken.usagi.core.model.PluginKeyResolver import org.draken.usagi.core.parser.MangaDynamicRepository import org.draken.usagi.core.parser.PluginFileLoader import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.core.ui.BaseViewModel +import org.draken.usagi.core.util.ext.MutableEventFlow +import org.draken.usagi.core.util.ext.call import org.draken.usagi.filter.data.SavedFiltersRepository import org.draken.usagi.settings.sources.manage.plugins.model.PluginManageItem import tsuki.util.runCatchingCancellable import java.io.File +import java.net.URI import javax.inject.Inject +sealed class OperationResult( + @StringRes val messageResId: Int, +) { + class Success( + @StringRes messageResId: Int, + ) : OperationResult(messageResId) + + class Failure( + @StringRes messageResId: Int, + ) : OperationResult(messageResId) +} + @HiltViewModel class PluginsManageViewModel @Inject @@ -41,23 +57,20 @@ class PluginsManageViewModel private val mangaDynamicRepository: MangaDynamicRepository, private val pluginKeyResolver: PluginKeyResolver, private val directManager: NativeExtManager, - private val tachiyomiRuntime: TachiyomiRuntime, + private val runtime: ExtRuntime, private val catalogProvider: ExtensionProvider, ) : BaseViewModel() { val content = MutableStateFlow>(emptyList()) val selectedPlugins = MutableStateFlow>(emptySet()) + val operationResult = MutableEventFlow() - @Volatile - private var pluginsSnapshot = emptyList() + @Volatile private var pluginsSnapshot = emptyList() - @Volatile - private var tachiyomiSnapshot = emptyList() + @Volatile private var extensionSnapshot = emptyList() - @Volatile - private var query = "" + @Volatile private var query = "" - @Volatile - private var pendingImportUrl: String? = null + @Volatile private var pendingImportUrl: String? = null init { refresh() @@ -65,30 +78,27 @@ class PluginsManageViewModel fun refresh() { launchJob(Dispatchers.Default) { - val localPlugins = loadPluginsLocal() - pluginsSnapshot = localPlugins - refreshTachiyomiItems(catalogProvider.loadSavedCached()) + pluginsSnapshot = loadPluginsLocal() + refreshExtensionItems(catalogProvider.loadSavedCached()) publishFiltered() launch { - runCatching { tachiyomiRuntime.ensureReady() } - val refreshed = catalogProvider.loadSaved() - refreshTachiyomiItems(refreshed) + runCatching { runtime.ensureReady() } + refreshExtensionItems(catalogProvider.loadSaved()) } - if (localPlugins.isNotEmpty()) { + if (pluginsSnapshot.isNotEmpty()) { launch { - val updatedPlugins = + pluginsSnapshot = coroutineScope { - localPlugins - .map { plugin -> + pluginsSnapshot + .map { p -> async { - val repo = plugin.repository ?: return@async plugin - val latest = updatePluginsProvider.requestTag(repo) ?: return@async plugin - plugin.copy(latestTag = latest) + val repo = p.repository ?: return@async p + val tag = updatePluginsProvider.requestTag(repo) ?: return@async p + p.copy(latestTag = tag) } }.awaitAll() } - pluginsSnapshot = updatedPlugins publishFiltered() } } @@ -102,28 +112,10 @@ class PluginsManageViewModel fun runAutoUpdate() { if (settings.isAutoPluginsEnabled) { - launchJob(Dispatchers.Default) { - updatePluginsProvider.runAutoUpdate(settings) - } + launchJob(Dispatchers.Default) { updatePluginsProvider.runAutoUpdate(settings) } } } - suspend fun resolveRelease( - input: String, - name: String? = null, - ): ExternalPluginDto? = - withContext(Dispatchers.Default) { - val repository = updatePluginsProvider.resolve(input) ?: return@withContext null - updatePluginsProvider.requestRelease(repository, name) - } - - suspend fun resolveGithubReleases(input: String): List = - withContext(Dispatchers.Default) { - val repository = updatePluginsProvider.resolve(input) ?: return@withContext emptyList() - val tag = updatePluginsProvider.requestTag(repository) ?: return@withContext emptyList() - updatePluginsProvider.requestPlugins(repository, tag) - } - suspend fun importFromUri( uri: Uri, fileName: String, @@ -131,262 +123,301 @@ class PluginsManageViewModel withContext(Dispatchers.Default) { val safeName = PluginFileLoader.resolve(fileName) runCatchingCancellable { - val pluginsDir = mangaDynamicRepository.getDir() - PluginFileLoader.copyFromUri(context, uri, File(pluginsDir, safeName)) + val dir = mangaDynamicRepository.getDir() + PluginFileLoader.copyFromUri(context, uri, File(dir, safeName)) updatePluginsProvider.clearDto(safeName) - reloadPlugins(pluginsDir) + reloadPlugins(dir) }.isSuccess }.also { if (it) refresh() } - suspend fun importFromGithub( - release: ExternalPluginDto, - fileName: String = release.fileName, - ): Boolean = - updatePluginsProvider - .installPlugin(release, PluginFileLoader.resolve(fileName)) - .also { if (it) refresh() } - - suspend fun importPlugin( + fun importPlugin( uri: Uri, getOriginalName: (Uri) -> String?, askName: suspend (String) -> String?, askOverwrite: suspend (String) -> Boolean, - ): Boolean? = - withContext(Dispatchers.Default) { - val originalName = getOriginalName(uri) ?: "plugin_${System.currentTimeMillis()}.jar" - val tempDir = File(context.cacheDir, "imports").also { it.mkdirs() } - val tempFile = File(tempDir, originalName) + ) { + launchJob(Dispatchers.Default) { + val rawName = getOriginalName(uri) ?: "plugin_${System.currentTimeMillis()}.jar" + val tempFile = File(context.cacheDir.resolve("imports").also { it.mkdirs() }, rawName) try { PluginFileLoader.copyFromUri(context, uri, tempFile) - val tachiyomiInstalled = directManager.installLocal(tempFile, originalName) - if (tachiyomiInstalled) { - tachiyomiRuntime.ensureReady(forceRefresh = true) + if (directManager.installLocal(tempFile, rawName)) { + runtime.ensureReady(forceRefresh = true) refresh() - return@withContext true + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob } } finally { tempFile.delete() } - - val pluginName = askName(originalName.removeSuffix(".jar"))?.trim().orEmpty() - if (pluginName.isBlank()) return@withContext null - - val fileName = PluginFileLoader.resolve(pluginName) - if (isInstalled(fileName) && !askOverwrite(fileName)) return@withContext null - - importFromUri(uri, fileName) + val name = askName(rawName.removeSuffix(".jar"))?.trim().orEmpty() + if (name.isBlank()) return@launchJob + val safeName = PluginFileLoader.resolve(name) + if (isInstalled(safeName) && !askOverwrite(safeName)) return@launchJob + val ok = importFromUri(uri, safeName) + operationResult.call( + if (ok) { + OperationResult.Success(R.string.load_success) + } else { + OperationResult.Failure(R.string.load_failed) + }, + ) } + } - suspend fun importUrl( + fun importUrl( askInput: suspend () -> String?, askOverwrite: suspend (String) -> Boolean, - ): Boolean? = - withContext(Dispatchers.Default) { - val input = askInput()?.trim()?.takeIf { it.isNotBlank() } ?: return@withContext null + ) { + launchJob(Dispatchers.Default) { + val input = askInput()?.trim()?.takeIf { it.isNotBlank() } ?: return@launchJob pendingImportUrl = input publishFiltered() try { - val trimmed = input.trim() - // 1. Direct .jar/.apk file URL or direct release download URL - if (trimmed.endsWith(".jar", ignoreCase = true) || - trimmed.contains(".jar?", ignoreCase = true) || - trimmed.endsWith(".apk", ignoreCase = true) || - trimmed.contains(".apk?", ignoreCase = true) || - trimmed.contains("/releases/download/") - ) { - if (updatePluginsProvider.importFromUrl(trimmed)) { - refresh() - return@withContext true - } + if (updatePluginsProvider.importFromUrl(input)) { + refresh() + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob } - - // 2. Fast check: Tsuki/Kotatsu plugin repository on GitHub (single latest release with .jar plugin) - val releases = resolveGithubReleases(trimmed) - val select = releases.firstOrNull() - val isTsukiPlugin = - select != null && - !select.fileName.startsWith("tachiyomi-", ignoreCase = true) && - !select.fileName.startsWith("eu.kanade.tachiyomi", ignoreCase = true) - - if (isTsukiPlugin) { - val name = PluginFileLoader.resolve(select.fileName) - if (isInstalled(name) && !askOverwrite(name)) return@withContext null - if (importFromGithub(select, name)) { + val releases = + updatePluginsProvider + .resolve(input) + ?.let { repo -> + updatePluginsProvider.requestTag(repo)?.let { tag -> updatePluginsProvider.requestPlugins(repo, tag) } + }.orEmpty() + if (releases.isNotEmpty() && releases.size <= 3) { + val select = releases.first() + val safeName = PluginFileLoader.resolve(select.fileName) + if (isInstalled(safeName) && !askOverwrite(safeName)) return@launchJob + if (updatePluginsProvider.installPlugin(select, safeName)) { refresh() - return@withContext true + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob } } - - // 3. Tachiyomi/Mihon extensions repository - val artifacts = catalogProvider.load(trimmed) + val artifacts = catalogProvider.load(input) if (artifacts.isNotEmpty()) { artifacts.forEach { catalogProvider.restorePackage(it.packageName) } - catalogProvider.saveRepository(trimmed) - refreshTachiyomiItems(catalogProvider.loadSaved() + artifacts) - return@withContext true + catalogProvider.saveRepository(input) + refreshExtensionItems(catalogProvider.loadSaved() + artifacts) + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob } - - // 4. Fallback: Any other GitHub release asset - if (select != null) { - val name = PluginFileLoader.resolve(select.fileName) - if (isInstalled(name) && !askOverwrite(name)) return@withContext null - if (importFromGithub(select, name)) { - refresh() - return@withContext true - } - } - - if (updatePluginsProvider.importFromUrl(trimmed)) { - refresh() - return@withContext true - } - - return@withContext false + operationResult.call(OperationResult.Failure(R.string.load_failed)) } finally { pendingImportUrl = null publishFiltered() } } + } - suspend fun renameTachiyomi( - item: PluginManageItem.Tachiyomi, + fun renameExtension( + item: PluginManageItem.Extension, name: String, - ): Boolean = - withContext(Dispatchers.Default) { + ) { + launchJob(Dispatchers.Default) { catalogProvider.setRepositoryName(item.repositoryUrl, name) - DirectTachiyomiPluginMetadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } + DirectExternalPluginMetadata.update(directManager.installed.value) { + catalogProvider.repositoryName(it) + } refresh() - true + operationResult.call(OperationResult.Success(R.string.load_success)) } + } - suspend fun updatePlugin(item: PluginManageItem.Plugin): Boolean { - val repository = item.repository ?: return false - val release = resolveRelease(repository, item.name) ?: return false - return if (release.tag == item.installedTag) { - refresh() - true - } else { - importFromGithub(release, item.name) + fun updatePlugin(item: PluginManageItem.Plugin) { + launchJob(Dispatchers.Default) { + val repo = + item.repository ?: run { + operationResult.call(OperationResult.Failure(R.string.load_failed)) + return@launchJob + } + val rel = updatePluginsProvider.requestRelease(repo, item.name) + val ok = + if (rel != null && rel.tag != item.installedTag) { + updatePluginsProvider.installPlugin(rel, item.name) + } else { + rel != null + } + if (ok) refresh() + operationResult.call( + if (ok) { + OperationResult.Success(R.string.load_success) + } else { + OperationResult.Failure(R.string.load_failed) + }, + ) } } fun toggleSelection(jarName: String) { - val current = selectedPlugins.value - selectedPlugins.value = if (jarName in current) current - jarName else current + jarName + val c = selectedPlugins.value + selectedPlugins.value = if (jarName in c) c - jarName else c + jarName } - fun toggleTachiyomiSelection(item: PluginManageItem.Tachiyomi) { - toggleSelection(tachiyomiSelectionKey(item.repositoryUrl)) - } + fun toggleExtensionSelection(item: PluginManageItem.Extension) = toggleSelection(PREFIX + item.repositoryUrl) - fun isTachiyomiSelected(item: PluginManageItem.Tachiyomi): Boolean = tachiyomiSelectionKey(item.repositoryUrl) in selectedPlugins.value + fun isExtensionSelected(item: PluginManageItem.Extension) = (PREFIX + item.repositoryUrl) in selectedPlugins.value fun clearSelection() { selectedPlugins.value = emptySet() } - fun isSelected(jarName: String): Boolean = jarName in selectedPlugins.value + fun isSelected(jarName: String) = jarName in selectedPlugins.value - suspend fun delete(): Boolean = - withContext(Dispatchers.Default) { + fun delete() { + launchJob(Dispatchers.Default) { val select = selectedPlugins.value - if (select.isEmpty()) return@withContext false - var allSuccess = true - var hasLocalPlugins = false + if (select.isEmpty()) return@launchJob + var ok = true + var hasLocal = false for (key in select) { - if (key.startsWith(TACHIYOMI_SELECTION_PREFIX)) { - val repositoryUrl = key.removePrefix(TACHIYOMI_SELECTION_PREFIX) - val item = tachiyomiSnapshot.firstOrNull { it.repositoryUrl == repositoryUrl } - if (item == null) { - allSuccess = false - continue - } - val installedPackages = - (item.installed.map { it.packageName } + listOfNotNull(item.repositoryUrl.removePrefix("local://").takeIf { item.isLocal })).distinct() - installedPackages.forEach { directManager.remove(it) } + if (key.startsWith(PREFIX)) { + val url = key.removePrefix(PREFIX) + val item = + extensionSnapshot.firstOrNull { it.repositoryUrl == url } ?: run { + ok = false + continue + } + val pkgs = + ( + item.installed.map { it.packageName } + + listOfNotNull(url.removePrefix("local://").takeIf { item.isLocal }) + ).distinct() + pkgs.forEach { directManager.remove(it) } item.artifacts.forEach { catalogProvider.restorePackage(it.packageName) } - catalogProvider.removeRepository(repositoryUrl) + catalogProvider.removeRepository(url) } else { - hasLocalPlugins = true + hasLocal = true try { mangaDynamicRepository.deletePlugin(key) updatePluginsProvider.clearDto(key) } catch (_: Throwable) { - allSuccess = false + ok = false } } } selectedPlugins.value = emptySet() - if (hasLocalPlugins) reloadPlugins(mangaDynamicRepository.getDir()) - tachiyomiRuntime.ensureReady(forceRefresh = true) - allSuccess - }.also { if (it) refresh() } + if (hasLocal) reloadPlugins(mangaDynamicRepository.getDir()) + runtime.ensureReady(forceRefresh = true) + if (ok) refresh() + operationResult.call( + if (ok) { + OperationResult.Success(R.string.removal_completed) + } else { + OperationResult.Failure(R.string.load_failed) + }, + ) + } + } - suspend fun rename( + fun rename( item: PluginManageItem.Plugin, newRawName: String, - ): Boolean = - withContext(Dispatchers.Default) { + ) { + launchJob(Dispatchers.Default) { val name = PluginFileLoader.resolve(newRawName) - if (name == item.name) return@withContext true - val pluginsDir = mangaDynamicRepository.getDir() - val old = File(pluginsDir, item.name) - val new = File(pluginsDir, name) - if (new.exists()) return@withContext false - runCatchingCancellable { - if (old.exists() && old.renameTo(new)) { - updatePluginsProvider.renameDto(item.name, name) - reloadPlugins(pluginsDir) - true + if (name == item.name) { + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob + } + val dir = mangaDynamicRepository.getDir() + val old = File(dir, item.name) + val new = File(dir, name) + if (new.exists()) { + operationResult.call(OperationResult.Failure(R.string.load_failed)) + return@launchJob + } + val ok = + runCatchingCancellable { + if (old.exists() && old.renameTo(new)) { + updatePluginsProvider.renameDto(item.name, name) + reloadPlugins(dir) + true + } else { + false + } + }.getOrDefault(false) + if (ok) refresh() + operationResult.call( + if (ok) { + OperationResult.Success(R.string.load_success) } else { - false - } - }.getOrDefault(false) - }.also { if (it) refresh() } + OperationResult.Failure(R.string.load_failed) + }, + ) + } + } - fun isInstalled(fileName: String): Boolean = File(mangaDynamicRepository.getDir(), PluginFileLoader.resolve(fileName)).exists() + fun isInstalled(fileName: String) = File(mangaDynamicRepository.getDir(), PluginFileLoader.resolve(fileName)).exists() - private fun refreshTachiyomiItems(artifacts: List) { + private fun refreshExtensionItems(artifacts: List) { val failures = directManager.failed.value val installed = directManager.installed.value.distinctBy { it.packageName } val uniqueArtifacts = artifacts.distinctBy { it.packageName } - val artifactRepositoryByPackage = uniqueArtifacts.associate { it.packageName to canonicalRepository(it.repositoryUrl) } - val artifactsByRepository = uniqueArtifacts.groupBy { canonicalRepository(it.repositoryUrl) } - val installedByRepository = - installed.groupBy { record -> - record.repositoryUrl - .takeIf { it.isNotBlank() } - ?.let(::canonicalRepository) - ?: artifactRepositoryByPackage[record.packageName] - ?: INSTALLED_REPOSITORY_FALLBACK - } - val repositories = (artifactsByRepository.keys + installedByRepository.keys).distinct() - val items = - repositories.map { repositoryUrl -> - val repositoryArtifacts = artifactsByRepository[repositoryUrl].orEmpty() - val repositoryInstalled = installedByRepository[repositoryUrl].orEmpty() - val packageNames = (repositoryArtifacts.map { it.packageName } + repositoryInstalled.map { it.packageName }).toSet() - PluginManageItem.Tachiyomi( - repositoryUrl = repositoryUrl, - artifacts = repositoryArtifacts, - installed = repositoryInstalled, - failures = failures.filter { it.packageName in packageNames }, - customName = catalogProvider.repositoryName(repositoryUrl), - ) + val artifactRepoByPkg = uniqueArtifacts.associate { it.packageName to canonicalRepo(it.repositoryUrl) } + val artsByRepo = uniqueArtifacts.groupBy { canonicalRepo(it.repositoryUrl) } + val instByRepo = + installed.groupBy { r -> + r.repositoryUrl.takeIf { it.isNotBlank() }?.let(::canonicalRepo) + ?: artifactRepoByPkg[r.packageName] + ?: "installed://direct" } - DirectTachiyomiPluginMetadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } - tachiyomiSnapshot = items.sortedBy { it.displayName.lowercase() } + val repos = (artsByRepo.keys + instByRepo.keys).distinct() + extensionSnapshot = + repos + .map { url -> + val repoArts = artsByRepo[url].orEmpty() + val repoInst = instByRepo[url].orEmpty() + val pkgs = (repoArts.map { it.packageName } + repoInst.map { it.packageName }).toSet() + val isLocal = url.startsWith("local:") || url.startsWith("installed:") + val label = if (isLocal) context.getString(R.string.local_storage) else parseRepoLabel(url) + val custom = catalogProvider.repositoryName(url) + val defaultDisp = + if (isLocal) { + repoInst.firstOrNull()?.name?.takeIf { it.isNotBlank() } + } else { + label.substringBefore('/').ifBlank { context.getString(R.string.external_source) } + } ?: label.substringBefore('/').ifBlank { context.getString(R.string.external_source) } + val disp = custom?.trim()?.takeIf { it.isNotBlank() } ?: defaultDisp + PluginManageItem.Extension( + url, + label, + disp, + repoArts, + repoInst, + failures.filter { it.packageName in pkgs }, + custom, + ) + }.sortedBy { it.displayName.lowercase() } + DirectExternalPluginMetadata.update(directManager.installed.value) { + catalogProvider.repositoryName(it) + } publishFiltered() } - private fun canonicalRepository(value: String): String = catalogProvider.normalizeUrl(value) ?: value.trim().removeSuffix("/") + private fun parseRepoLabel(url: String) = + runCatching { + val uri = URI(url) + val segs = + uri.path + .trim('/') + .split('/') + .filter { it.isNotBlank() } + when (uri.host?.lowercase()) { + "raw.githubusercontent.com", "github.com", "www.github.com" -> segs.take(2).joinToString("/") + else -> uri.host.orEmpty().ifBlank { url } + } + }.getOrDefault(url) + + private fun canonicalRepo(v: String) = catalogProvider.normalizeUrl(v) ?: v.trim().removeSuffix("/") private fun publishFiltered() { - val all: List = + val all = buildList { pendingImportUrl?.let { add(PluginManageItem.Loading(it)) } addAll(pluginsSnapshot) - addAll(tachiyomiSnapshot) + addAll(extensionSnapshot) } if (all.isEmpty()) { content.value = listOf(PluginManageItem.Placeholder(R.string.no_plugins, R.string.no_plugins_summary)) @@ -397,54 +428,47 @@ class PluginsManageViewModel content.value = all return } - val filtered = - all.filter { item -> - when (item) { - is PluginManageItem.Loading -> { - true - } + content.value = + all + .filter { item -> + when (item) { + is PluginManageItem.Loading -> { + true + } - is PluginManageItem.Plugin -> { - item.name.contains(q, true) || item.repository?.contains(q, true) == true - } + is PluginManageItem.Plugin -> { + item.name.contains(q, true) || item.repository?.contains(q, true) == true + } - is PluginManageItem.Tachiyomi -> { - item.displayName.contains(q, true) || item.repositoryLabel.contains(q, true) || item.repositoryUrl.contains(q, true) - } + is PluginManageItem.Extension -> { + item.displayName.contains(q, true) || + item.repositoryLabel.contains(q, true) || + item.repositoryUrl.contains(q, true) + } - is PluginManageItem.Placeholder -> { - false + is PluginManageItem.Placeholder -> { + false + } } - } - } - content.value = filtered.ifEmpty { listOf(PluginManageItem.Placeholder(R.string.nothing_found, null)) } + }.ifEmpty { listOf(PluginManageItem.Placeholder(R.string.nothing_found, null)) } } private fun loadPluginsLocal(): List { val plugins = mangaDynamicRepository.get().sorted() if (plugins.isEmpty()) return emptyList() val meta = updatePluginsProvider.readAndCleanDto(plugins.toSet()) - - return plugins.map { fileName -> - val itemMeta = meta[fileName] - PluginManageItem.Plugin( - name = fileName, - repository = itemMeta?.repository, - installedTag = itemMeta?.tag, - latestTag = null, - ) + return plugins.map { f -> + val m = meta[f] + PluginManageItem.Plugin(f, m?.repository, m?.tag, null) } } - private fun tachiyomiSelectionKey(repositoryUrl: String): String = TACHIYOMI_SELECTION_PREFIX + repositoryUrl - - private suspend fun reloadPlugins(pluginsDir: File) { - mangaDynamicRepository.load(pluginsDir) + private suspend fun reloadPlugins(dir: File) { + mangaDynamicRepository.load(dir) pluginKeyResolver.normalize(database, savedFiltersRepository) } private companion object { - const val TACHIYOMI_SELECTION_PREFIX = "tachiyomi:" - const val INSTALLED_REPOSITORY_FALLBACK = "installed://direct" + const val PREFIX = "ext_repo:" } } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt index 5f2d0b87..294c65d0 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt @@ -6,15 +6,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext -import okhttp3.Call -import okhttp3.Callback import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.model.PluginKeyResolver import org.draken.usagi.core.network.BaseHttpClient @@ -24,6 +20,7 @@ import org.draken.usagi.core.prefs.AppSettings import org.draken.usagi.filter.data.SavedFiltersRepository import org.json.JSONArray import org.json.JSONObject +import tsuki.util.await import tsuki.util.runCatchingCancellable import java.io.File import java.io.IOException @@ -42,9 +39,7 @@ class UpdatePluginsProvider private val pluginKeyResolver: PluginKeyResolver, ) { private val mutex = Mutex() - private val prefs by lazy { - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } + private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } suspend fun runAutoUpdate(settings: AppSettings) { if (!settings.isAutoPluginsEnabled || !mutex.tryLock()) return @@ -91,12 +86,10 @@ class UpdatePluginsProvider ): Boolean = withContext(Dispatchers.Default) { runCatchingCancellable { - val pluginsDir = mangaDynamicRepository.getDir() - val outFile = File(pluginsDir, fileName) - val ok = replacePlugin(release.downloadUrl, outFile) - if (!ok) throw IOException() + val dir = mangaDynamicRepository.getDir() + if (!replacePlugin(release.downloadUrl, File(dir, fileName))) throw IOException() saveDto(fileName, release.repository, release.tag) - reloadPlugins(pluginsDir) + reloadPlugins(dir) }.isSuccess } @@ -105,120 +98,82 @@ class UpdatePluginsProvider pluginKeyResolver.normalize(database, savedFiltersRepository) } - private suspend fun Call.await(): Response = - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cancel() } - enqueue( - object : Callback { - override fun onFailure( - call: Call, - e: IOException, - ) { - if (!continuation.isCancelled) continuation.resumeWith(Result.failure(e)) - } - - override fun onResponse( - call: Call, - response: Response, - ) { - continuation.resumeWith(Result.success(response)) - } - }, - ) - } - suspend fun requestRelease( repository: String, name: String? = null, ): ExternalPluginDto? { val tag = requestTag(repository) ?: return null val releases = requestPlugins(repository, tag) - if (releases.isEmpty()) return null if (name != null) releases.find { it.fileName == name }?.let { return it } return releases.firstOrNull() } - suspend fun requestTag(repository: String): String? { - return runCatchingCancellable { - val request = + suspend fun requestTag(repository: String): String? = + runCatchingCancellable { + val req = Request .Builder() .get() .url("https://github.com/$repository/releases/latest") .build() - okHttpClient.newCall(request).await().use { response -> - if (!response.isSuccessful) return null - val pathSegments = response.request.url.pathSegments - val tagIndex = pathSegments.indexOf("tag") - val tag = - if (tagIndex >= 0) { - pathSegments.getOrNull(tagIndex + 1) - } else { - pathSegments.lastOrNull() - } - tag?.takeIf { it.isNotBlank() } + okHttpClient.newCall(req).await().use { resp -> + if (!resp.isSuccessful) return null + val segs = resp.request.url.pathSegments + val idx = segs.indexOf("tag") + (if (idx >= 0) segs.getOrNull(idx + 1) else segs.lastOrNull())?.takeIf { it.isNotBlank() } } }.getOrNull() - } suspend fun requestPlugins( repository: String, tag: String, - ): List { - return runCatchingCancellable { - val (owner, repoName) = splitRepository(repository) ?: return emptyList() + ): List = + runCatchingCancellable { + val (owner, repo) = splitRepository(repository) ?: return emptyList() val url = HttpUrl .Builder() .scheme("https") .host("api.github.com") - .addPathSegments("repos/$owner/$repoName/releases/tags/$tag") + .addPathSegments("repos/$owner/$repo/releases/tags/$tag") .build() - val request = + val req = Request .Builder() .get() .url(url) .build() - okHttpClient.newCall(request).await().use { response -> - if (!response.isSuccessful) return emptyList() - val body = response.body.string() + okHttpClient.newCall(req).await().use { resp -> + if (!resp.isSuccessful) return emptyList() + val body = resp.body.string() if (body.isBlank()) return emptyList() - val json = JSONObject(body) - val assets = find(json.optJSONArray("assets")) - assets.map { ExternalPluginDto(repository, tag, it.first, it.second) } + find(JSONObject(body).optJSONArray("assets")).map { ExternalPluginDto(repository, tag, it.first, it.second) } } }.getOrDefault(emptyList()) - } suspend fun importFromUrl(url: String): Boolean { val trimmed = url.trim() - val info = parseDownloadUrl(trimmed) - if (info != null) { - val release = - ExternalPluginDto( - repository = info.repository, - tag = info.tag, - fileName = info.fileName, - downloadUrl = trimmed, - ) - return installPlugin(release, info.fileName) + val match = DOWNLOAD_URL_REGEX.matchEntire(trimmed) + if (match != null) { + val (owner, repo, tag, fileName) = match.destructured + if (owner.isNotBlank() && repo.isNotBlank() && tag.isNotBlank() && fileName.isNotBlank()) { + return installPlugin(ExternalPluginDto("$owner/$repo", tag, fileName, trimmed), fileName) + } } - if (trimmed.endsWith(".jar", ignoreCase = true) || trimmed.contains(".jar?", ignoreCase = true)) { + if (trimmed.endsWith(".jar", true) || trimmed.contains(".jar?", true)) { val rawName = trimmed.substringBefore('?').substringAfterLast('/') - val fileName = PluginFileLoader.resolve(rawName) - val dest = File(mangaDynamicRepository.getDir(), fileName) + val safeName = PluginFileLoader.resolve(rawName) + val dest = File(mangaDynamicRepository.getDir(), safeName) return runCatchingCancellable { - val request = + val req = Request .Builder() .url(trimmed) .header("User-Agent", "Usagi-PluginDownloader/1.0") .build() - okHttpClient.newCall(request).await().use { response -> - if (!response.isSuccessful) throw IOException("HTTP ${response.code}") - val body = response.body - PluginFileLoader.copyFromStream(dest, body.byteStream()) + okHttpClient.newCall(req).await().use { resp -> + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") + PluginFileLoader.copyFromStream(dest, resp.body.byteStream()) } reloadPlugins(mangaDynamicRepository.getDir()) true @@ -227,32 +182,16 @@ class UpdatePluginsProvider return false } - private fun parseDownloadUrl(url: String): ParsedDownloadUrl? { - val match = DOWNLOAD_URL_REGEX.matchEntire(url.trim()) ?: return null - val (owner, repo, tag, fileName) = match.destructured - if (owner.isBlank() || repo.isBlank() || tag.isBlank() || fileName.isBlank()) return null - return ParsedDownloadUrl( - repository = "$owner/$repo", - tag = tag, - fileName = fileName, - ) - } - fun resolve(input: String): String? { val trimmed = input.trim().takeIf { it.isNotEmpty() } ?: return null - return (GITHUB_URL_REGEX.matchEntire(trimmed) ?: REPOSITORY_REGEX.matchEntire(trimmed)) - ?.groupValues - ?.takeIf { it.size >= 3 } - ?.let { "${it[1]}/${it[2]}" } + val match = GITHUB_URL_REGEX.matchEntire(trimmed) ?: REPOSITORY_REGEX.matchEntire(trimmed) + return match?.groupValues?.takeIf { it.size >= 3 }?.let { "${it[1]}/${it[2]}" } } fun splitRepository(repository: String): Pair? { val parts = repository.split('/', limit = 2) - if (parts.size < 2) return null - val owner = parts[0].trim() - val repo = parts[1].trim() - if (owner.isBlank() || repo.isBlank()) return null - return owner to repo + if (parts.size < 2 || parts[0].isBlank() || parts[1].isBlank()) return null + return parts[0].trim() to parts[1].trim() } fun find(assets: JSONArray?): List> { @@ -262,9 +201,7 @@ class UpdatePluginsProvider val asset = assets.optJSONObject(i) ?: continue val name = asset.optString("name") val url = asset.optString("browser_download_url") - if (name.endsWith(".jar", true) && url.isNotBlank()) { - list.add(name to url) - } + if (name.endsWith(".jar", true) && url.isNotBlank()) list.add(name to url) } return list } @@ -274,24 +211,21 @@ class UpdatePluginsProvider dest: File, ): Boolean = runCatchingCancellable { - val request = + val req = Request .Builder() .get() .url(url) .build() - okHttpClient.newCall(request).await().use { response -> - if (!response.isSuccessful) throw IOException() - val body = response.body - PluginFileLoader.copyFromStream(dest, body.byteStream()) + okHttpClient.newCall(req).await().use { resp -> + if (!resp.isSuccessful) throw IOException() + PluginFileLoader.copyFromStream(dest, resp.body.byteStream()) } }.isSuccess fun readAndCleanDto(installedFiles: Set): MutableMap { val meta = readDto() - if (meta.keys.retainAll(installedFiles)) { - writeDto(meta) - } + if (meta.keys.retainAll(installedFiles)) writeDto(meta) return meta } @@ -300,27 +234,18 @@ class UpdatePluginsProvider repository: String, tag: String, ) { - updateDto { - it[fileName] = RemoteReleaseDto(repository, tag) - } + updateDto { it[fileName] = RemoteReleaseDto(repository, tag) } } fun clearDto(fileName: String) { - updateDto { - it.remove(fileName) - } + updateDto { it.remove(fileName) } } fun renameDto( oldName: String, newName: String, ) { - updateDto { - val value = it.remove(oldName) - if (value != null) { - it[newName] = value - } - } + updateDto { it.remove(oldName)?.let { v -> it[newName] = v } } } private fun updateDto(block: (MutableMap) -> Unit) { @@ -331,9 +256,7 @@ class UpdatePluginsProvider fun readDto(): MutableMap { val raw = prefs.getString(PREFS_KEY, null).orEmpty() - if (raw.isBlank()) { - return LinkedHashMap() - } + if (raw.isBlank()) return LinkedHashMap() return runCatching { val json = JSONObject(raw) val out = LinkedHashMap(json.length()) @@ -341,11 +264,9 @@ class UpdatePluginsProvider while (keys.hasNext()) { val key = keys.next() val obj = json.optJSONObject(key) ?: continue - val repository = obj.optString(KEY_REPOSITORY) + val repo = obj.optString(KEY_REPOSITORY) val tag = obj.optString(KEY_TAG) - if (repository.isNotBlank() && tag.isNotBlank()) { - out[key] = RemoteReleaseDto(repository, tag) - } + if (repo.isNotBlank() && tag.isNotBlank()) out[key] = RemoteReleaseDto(repo, tag) } out }.getOrElse { LinkedHashMap() } @@ -354,16 +275,9 @@ class UpdatePluginsProvider fun writeDto(meta: Map) { val json = JSONObject() meta.forEach { (fileName, value) -> - json.put( - fileName, - JSONObject() - .put(KEY_REPOSITORY, value.repository) - .put(KEY_TAG, value.tag), - ) - } - prefs.edit { - putString(PREFS_KEY, json.toString()) + json.put(fileName, JSONObject().put(KEY_REPOSITORY, value.repository).put(KEY_TAG, value.tag)) } + prefs.edit { putString(PREFS_KEY, json.toString()) } } companion object { @@ -383,10 +297,4 @@ class UpdatePluginsProvider RegexOption.IGNORE_CASE, ) } - - private data class ParsedDownloadUrl( - val repository: String, - val tag: String, - val fileName: String, - ) } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt index f081b754..aa57a32d 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/model/PluginManageItem.kt @@ -1,12 +1,10 @@ package org.draken.usagi.settings.sources.manage.plugins.model import androidx.annotation.StringRes -import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiFailure -import org.draken.tsukimix.core.parser.tachiyomi.model.DirectTachiyomiInstalled -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiExtensionArtifact -import org.draken.usagi.R +import org.draken.tsukimix.core.parser.external.model.ExtArtifact +import org.draken.tsukimix.core.parser.external.model.ExtFailure +import org.draken.tsukimix.core.parser.external.model.ExtInstalled import org.draken.usagi.list.ui.model.ListModel -import java.net.URI sealed interface PluginManageItem : ListModel { data class Plugin( @@ -24,53 +22,22 @@ sealed interface PluginManageItem : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is Plugin && name == other.name } - data class Tachiyomi( + data class Extension( val repositoryUrl: String, - val artifacts: List, - val installed: List, - val failures: List, + val repositoryLabel: String, + val displayName: String, + val artifacts: List, + val installed: List, + val failures: List, val customName: String? = null, ) : PluginManageItem { val isLocal: Boolean get() = repositoryUrl.startsWith("local:") || repositoryUrl.startsWith("installed:") - val repositoryLabel: String - get() = - if (isLocal) { - R.string.local_storage.toString() - } else { - runCatching { - val uri = URI(repositoryUrl) - val segments = - uri.path - .trim('/') - .split('/') - .filter { it.isNotBlank() } - when (uri.host?.lowercase()) { - "raw.githubusercontent.com" -> segments.take(2).joinToString("/") - "github.com", "www.github.com" -> segments.take(2).joinToString("/") - else -> uri.host.orEmpty().ifBlank { repositoryUrl } - } - }.getOrDefault(repositoryUrl) - } - - val displayName: String - get() { - customName?.trim()?.takeIf { it.isNotBlank() }?.let { return it } - if (isLocal) { - installed - .firstOrNull() - ?.name - ?.takeIf { it.isNotBlank() } - ?.let { return it } - } - return repositoryLabel.substringBefore('/').ifBlank { "Tachiyomi/Mihon" } - } - val hasFailures: Boolean get() = failures.isNotEmpty() - override fun areItemsTheSame(other: ListModel): Boolean = other is Tachiyomi && repositoryUrl == other.repositoryUrl + override fun areItemsTheSame(other: ListModel): Boolean = other is Extension && repositoryUrl == other.repositoryUrl } data class Placeholder( diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt index f03469e2..65671e5d 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/model/SourceConfigItem.kt @@ -14,7 +14,6 @@ sealed interface SourceConfigItem : ListModel { val isAvailable: Boolean, val isPinned: Boolean, val isDisableAvailable: Boolean, - val uninstallPackageName: String? = null, ) : SourceConfigItem { val isNsfw: Boolean get() = source.isNsfw() diff --git a/app/src/main/res/xml/pref_source.xml b/app/src/main/res/xml/pref_source.xml index 1a6d65fc..462a730f 100644 --- a/app/src/main/res/xml/pref_source.xml +++ b/app/src/main/res/xml/pref_source.xml @@ -8,7 +8,7 @@ android:defaultValue="false" android:key="enable" android:layout="@layout/preference_toggle_header" - android:order="0" + android:order="-100" android:persistent="false" android:title="@string/enable_source" /> diff --git a/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt b/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt index 704ce020..4697a5b9 100644 --- a/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt +++ b/app/src/release/kotlin/org/draken/usagi/core/ui/BaseService.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.LifecycleService abstract class BaseService : LifecycleService() { override fun attachBaseContext(newBase: Context) { - val applicationBase = newBase.applicationContext ?: newBase - super.attachBaseContext(ContextCompat.getContextForLanguage(applicationBase)) + super.attachBaseContext(ContextCompat.getContextForLanguage(newBase)) } } diff --git a/gradle.properties b/gradle.properties index a761838d..839fada0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,3 +31,6 @@ org.gradle.configuration-cache.max-problems=8 android.suppressUnsupportedCompileSdk=37.0 android.suppressUnsupportedOptionWarning=true android.sync.suppressAgpWarnings=UNSUPPORTED_PROJECT_OPTION_USE + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0199f05..56634169 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ testRules = "1.7.0" testRunner = "1.7.0" transition = "1.6.0" tsuki = "1.0.5" -tsukimix = "18f5203691" +tsukimix = "93a458e66d" usagiRpc = "1.0.2" usagi-serialization = "ad8b778c88" viewpager2 = "1.1.0" From a95d8c8bdfcfe1fed0a94007b503773562352e91 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Fri, 28 Aug 2026 17:14:16 +0700 Subject: [PATCH 03/16] Update dependencies, optimize source filters and improve compatibility --- .../draken/usagi/filter/ui/external/FilterMapper.kt | 10 +++++----- gradle/libs.versions.toml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/filter/ui/external/FilterMapper.kt b/app/src/main/kotlin/org/draken/usagi/filter/ui/external/FilterMapper.kt index 51298a64..b3ba18ed 100644 --- a/app/src/main/kotlin/org/draken/usagi/filter/ui/external/FilterMapper.kt +++ b/app/src/main/kotlin/org/draken/usagi/filter/ui/external/FilterMapper.kt @@ -72,7 +72,7 @@ object FilterMapper { } is Filter.CheckBox -> { - val def = (default as? Filter.CheckBox)?.state ?: false + val def = (default.takeIf { it !== filter } as? Filter.CheckBox)?.state ?: false if (filter.state != def) { out += tag( @@ -84,7 +84,7 @@ object FilterMapper { } is Filter.TriState -> { - val def = (default as? Filter.TriState)?.state ?: Filter.TriState.STATE_IGNORE + val def = (default.takeIf { it !== filter } as? Filter.TriState)?.state ?: Filter.TriState.STATE_IGNORE if (filter.state != def) { out += tag( @@ -101,7 +101,7 @@ object FilterMapper { } is Filter.Select<*> -> { - val def = (default as? Filter.Select<*>)?.state ?: 0 + val def = (default.takeIf { it !== filter } as? Filter.Select<*>)?.state ?: 0 if (filter.state != def) { // The sort Select is tagged with the sort prefix so the toolbar can find it. val type = if (path == sortPath) TYPE_SORT else TYPE_SELECT @@ -116,7 +116,7 @@ object FilterMapper { is Filter.Sort -> { val selection = filter.state - val def = (default as? Filter.Sort)?.state + val def = (default.takeIf { it !== filter } as? Filter.Sort)?.state if (selection != def) { val encodedSelection = selection?.let { @@ -137,7 +137,7 @@ object FilterMapper { } is Filter.Text -> { - val def = (default as? Filter.Text)?.state.orEmpty() + val def = (default.takeIf { it !== filter } as? Filter.Text)?.state.orEmpty() if (filter.state != def) { out += tag( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56634169..89900f79 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,9 +51,9 @@ testRules = "1.7.0" testRunner = "1.7.0" transition = "1.6.0" tsuki = "1.0.5" -tsukimix = "93a458e66d" +tsukimix = "76268e5343" usagiRpc = "1.0.2" -usagi-serialization = "ad8b778c88" +usagi-serialization = "01254ce6f1" viewpager2 = "1.1.0" #noinspection GradleDependency webkit = "1.14.0" From 7d1759727516798a3c6fb561d38b3bb982db644f Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Fri, 28 Aug 2026 21:54:43 +0700 Subject: [PATCH 04/16] Auto-resolve target after reboot, update R8 rules (Attempt 2) --- app/proguard-rules.pro | 12 ++++++++++++ .../org/draken/usagi/core/parser/MangaRepository.kt | 12 ++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 233a21f2..ad791aef 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,6 +17,7 @@ -dontwarn com.google.j2objc.annotations.** -dontwarn com.google.re2j.** -dontwarn coil3.PlatformContext +-dontwarn rx.internal.util.unsafe.** -keep class org.draken.usagi.settings.NotificationSettingsLegacyFragment -keep class org.draken.usagi.settings.about.changelog.ChangelogFragment @@ -90,6 +91,17 @@ -keep class rx.functions.Func7 { public protected *; } -keep class rx.functions.Func8 { public protected *; } -keep class rx.functions.Func9 { public protected *; } +-keep class rx.internal.util.unsafe.** { *; } +-keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* { + long producerIndex; + long consumerIndex; +} +-keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueProducerNodeRef { + rx.internal.util.atomic.LinkedQueueNode producerNode; +} +-keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueConsumerNodeRef { + rx.internal.util.atomic.LinkedQueueNode consumerNode; +} -keep class uy.kohesive.injekt.** { public protected *; } -keep class * extends uy.kohesive.injekt.api.TypeReference { *; } -keep class * extends uy.kohesive.injekt.api.FullTypeReference { *; } diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt index f0a9d803..4388d58b 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/MangaRepository.kt @@ -96,7 +96,7 @@ interface MangaRepository { fun create(source: MangaSource): MangaRepository { var target = source.resolve() if (target is UnresolvedMangaSource || MangaSourceRegistry.sources.isEmpty()) { - resolve() + resolve(target) target = source.resolve() } @@ -176,15 +176,15 @@ interface MangaRepository { } } - private fun resolve() { + private fun resolve(target: MangaSource? = null) = synchronized(this) { if (MangaSourceRegistry.sources.isEmpty()) { - runCatching { - mangaRepository.load(mangaRepository.getDir()) - } + runCatching { mangaRepository.load(mangaRepository.getDir()) } + } + if (target is UnresolvedMangaSource && target.name.startsWith("EXTERNAL")) { + runCatching { kotlinx.coroutines.runBlocking { extRuntime.get().ensureReady() } } } } - } } companion object { From c24a564eb13b6a593bc24be0a813f3ec0ed34e4f Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Fri, 28 Aug 2026 21:59:44 +0700 Subject: [PATCH 05/16] Bump version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3cf94f54..9bd208a8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,8 +19,8 @@ android { applicationId 'org.draken.usagi' minSdk = 21 targetSdk = 37 - versionCode = 18 - versionName = '1.0.0-rc1' + versionCode = 19 + versionName = '1.0.0-rc2' generatedDensities = [] testInstrumentationRunner 'org.draken.usagi.HiltTestRunner' ksp { From 695c8dafc51cec344059d4f9c9994e30a54a0c1c Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Mon, 31 Aug 2026 18:35:10 +0700 Subject: [PATCH 06/16] Update dependency --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89900f79..1b9d2482 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ testRules = "1.7.0" testRunner = "1.7.0" transition = "1.6.0" tsuki = "1.0.5" -tsukimix = "76268e5343" +tsukimix = "0a11e605a7" usagiRpc = "1.0.2" usagi-serialization = "01254ce6f1" viewpager2 = "1.1.0" From ca34ed8686d0b96b36ec5ded6e6d32d92499ee82 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Mon, 31 Aug 2026 20:25:17 +0700 Subject: [PATCH 07/16] Clean up --- .../usagi/core/image/ExternalSourceFetcher.kt | 2 +- .../tachiyomi/ExternalMangaRepository.kt | 21 +++++++--- .../org/draken/usagi/core/ui/BaseActivity.kt | 25 ----------- .../org/draken/usagi/core/util/ViewBadge.kt | 3 +- .../draken/usagi/list/ui/MangaListFragment.kt | 6 --- .../usagi/search/ui/MangaListActivity.kt | 9 ---- .../sources/catalog/SourcesCatalogActivity.kt | 30 +++++--------- .../catalog/SourcesCatalogViewModel.kt | 41 +++++++++++++++---- 8 files changed, 61 insertions(+), 76 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt b/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt index c3cfe203..ce66d7c5 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt @@ -24,7 +24,7 @@ class ExternalSourceFetcher( ) : Fetcher { override suspend fun fetch(): FetchResult { val response = - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { httpSource.client .newCall( Request diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt index 22f469a7..5dcbccdf 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt @@ -5,6 +5,7 @@ import androidx.collection.LruCache import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -95,7 +96,7 @@ class ExternalMangaRepository( order: SortOrder?, filter: MangaListFilter?, ): List = - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { val page = synchronized(paginationLock) { if (offset == 0) { @@ -127,6 +128,8 @@ class ExternalMangaRepository( external.getPopularManga(page) } } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { throw mapException(e) } @@ -136,11 +139,13 @@ class ExternalMangaRepository( } override suspend fun getDetailsImpl(manga: Manga): Manga = - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { val original = manga.toSManga() val update = try { external.getMangaUpdate(original, emptyList(), fetchDetails = true, fetchChapters = true) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { throw mapException(e) } @@ -170,12 +175,14 @@ class ExternalMangaRepository( } override suspend fun getPagesImpl(chapter: MangaChapter): List = - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { val src = (chapter.source as? ExtensionMangaSource)?.catalogueSource ?: external val target = chapter.source as? ExtensionMangaSource ?: source val list = try { src.getPageList(chapter.toSChapter()) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { throw mapException(e) } @@ -194,13 +201,15 @@ class ExternalMangaRepository( val target = page.source as? ExtensionMangaSource ?: source val http = src as? HttpSource ?: return super.getPageRequest(page) val cp = pageCache[pageCacheKey(target, page.url)] ?: Page(0, page.url, page.url) - return withContext(Dispatchers.IO) { + return withContext(Dispatchers.Default) { try { http .getImageRequest(cp) .newBuilder() .tag(tsuki.model.MangaSource::class.java, target) .build() + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { throw mapException(e) } @@ -216,9 +225,11 @@ class ExternalMangaRepository( val http = src as? HttpSource ?: return super.getPageResponse(page, okHttp, interceptor) val target = page.source as? ExtensionMangaSource ?: source val cp = pageCache[pageCacheKey(target, page.url)] ?: Page(0, page.url, page.url) - return withContext(Dispatchers.IO) { + return withContext(Dispatchers.Default) { try { http.getImage(cp) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { throw mapException(e) } diff --git a/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt b/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt index 923a77da..fccadec0 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/ui/BaseActivity.kt @@ -182,29 +182,4 @@ abstract class BaseActivity : } protected fun hasViewBinding() = ::viewBinding.isInitialized - - @CallSuper - override fun onDestroy() { - window?.peekDecorView()?.let { decor -> - decor.handler?.removeCallbacksAndMessages(null) - cleanViewTree(decor) - } - if (hasViewBinding()) { - viewBinding.root.handler?.removeCallbacksAndMessages(null) - cleanViewTree(viewBinding.root) - } - super.onDestroy() - } - - private fun cleanViewTree(view: View) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - view.tooltipText = null - } - view.clearAnimation() - if (view is android.view.ViewGroup) { - for (i in 0 until view.childCount) { - cleanViewTree(view.getChildAt(i)) - } - } - } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt b/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt index 4c56ad90..62f09439 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/util/ViewBadge.kt @@ -49,9 +49,8 @@ class ViewBadge( } override fun onDestroy(owner: LifecycleOwner) { - owner.lifecycle.removeObserver(this) - clearBadge() super.onDestroy(owner) + clearBadge() } fun setMaxCharacterCount(value: Int) { diff --git a/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt index 84a1083c..38561f94 100644 --- a/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/list/ui/MangaListFragment.kt @@ -151,12 +151,6 @@ abstract class MangaListFragment : } override fun onDestroyView() { - viewBinding?.recyclerView?.let { rv -> - paginationListener?.let { rv.removeOnScrollListener(it) } - rv.adapter = null - rv.fastScroller.setFastScrollListener(null) - } - viewBinding?.swipeRefreshLayout?.setOnRefreshListener(null) listAdapter = null paginationListener = null selectionController = null diff --git a/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt index 4f92fab6..739cbd60 100644 --- a/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/search/ui/MangaListActivity.kt @@ -110,15 +110,6 @@ class MangaListActivity : } } - override fun onDestroy() { - if (viewBinding.containerFilterHeader != null) { - viewBinding.appbar.removeOnOffsetChangedListener(this) - } - viewBinding.buttonOrder?.setOnClickListener(null) - filterBadge = null - super.onDestroy() - } - override fun isNsfwContent(): Flow = flowOf(source.isNsfw()) override fun onOffsetChanged( diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt index 65c6a2ed..c4da8be7 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt @@ -20,13 +20,11 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch import org.draken.usagi.R import org.draken.usagi.core.model.titleResId import org.draken.usagi.core.nav.router @@ -92,6 +90,12 @@ class SourcesCatalogActivity : fadingAppbarMediator = FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbar).also { it.bind() } viewModel.content.observe(this, adapter) viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) + viewModel.onActionError.observeEvent(this) { resId -> + showSnackbar(getString(resId), Snackbar.LENGTH_LONG) + } + viewModel.onOpenSource.observeEvent(this) { source -> + router.openList(source, null, null) + } combine(viewModel.appliedFilter, viewModel.hasNewSources, viewModel.contentTypes, ::Triple).observe(this) { updateFilters(it.first, it.second, it.third) } @@ -177,11 +181,7 @@ class SourcesCatalogActivity : private fun installExtension(item: SourceCatalogItem.Extension) { if (item.isLoaded && !item.hasUpdate) return - lifecycleScope.launch { - if (!viewModel.installExtension(item)) { - showSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_LONG) - } - } + viewModel.install(item) } private fun uninstallExtension(item: SourceCatalogItem.Extension) { @@ -195,23 +195,13 @@ class SourcesCatalogActivity : } startActivity(Intent(action, Uri.fromParts("package", item.artifact.packageName, null))) } else { - lifecycleScope.launch { - if (!viewModel.uninstallExtension(item)) { - showSnackbar(getString(R.string.load_failed), Snackbar.LENGTH_SHORT) - } - } + viewModel.uninstall(item) } } private fun openSource(item: SourceCatalogItem.Extension) { - lifecycleScope.launch { - val source = viewModel.openExtensionSource(item) - if (source != null) { - router.openList(source, null, null) - } else { - showSnackbar(getString(R.string.unsupported_source), Snackbar.LENGTH_SHORT) - } - } + if (!item.isLoaded && item.isPreInstalledApk) return + viewModel.openSource(item) } private fun showSideloadMenu( diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt index 4d1bc340..713e48b0 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -71,6 +71,8 @@ class SourcesCatalogViewModel val scopedRepositoryUrl: String? = savedStateHandle.get(EXTRA_REPOSITORY_URL)?.takeIf { it.isNotBlank() } val isScopedMode get() = scopedRepositoryUrl != null val onActionDone = MutableEventFlow() + val onActionError = MutableEventFlow() + val onOpenSource = MutableEventFlow() private val extCatalog = MutableStateFlow>(emptyList()) private val isInitialLoading = MutableStateFlow(true) @@ -200,12 +202,23 @@ class SourcesCatalogViewModel suspend fun openExtensionSource(item: SourceCatalogItem.Extension): MangaSource? { var source = runCatching { getImportedExtensionSource(item) }.getOrNull() if (source == null && !item.isInstalled && !item.isLoaded && !item.isPreInstalledApk) { - if (installExtension(item)) source = runCatching { getImportedExtensionSource(item) }.getOrNull() + if (performInstall(item)) source = runCatching { getImportedExtensionSource(item) }.getOrNull() } if (source != null) enableExtensionSource(source) return source } + fun openSource(item: SourceCatalogItem.Extension) { + launchJob { + val source = openExtensionSource(item) + if (source != null) { + onOpenSource.call(source) + } else { + onActionError.call(R.string.unsupported_source) + } + } + } + fun localeDisplayName(value: String?): String { val code = value?.trim().orEmpty() if (code.isEmpty()) return context.getString(R.string.all_languages) @@ -237,7 +250,15 @@ class SourcesCatalogViewModel return installed.map { it.toArtifact() } } - suspend fun installExtension(item: SourceCatalogItem.Extension): Boolean = + fun install(item: SourceCatalogItem.Extension) { + launchJob { + if (!performInstall(item)) { + onActionError.call(R.string.load_failed) + } + } + } + + private suspend fun performInstall(item: SourceCatalogItem.Extension): Boolean = try { installingPackages.update { it + item.artifact.packageName } if (item.isInstalled && !item.hasUpdate && !item.isLoaded) { @@ -259,12 +280,16 @@ class SourcesCatalogViewModel installingPackages.update { it - item.artifact.packageName } } - suspend fun uninstallExtension(item: SourceCatalogItem.Extension): Boolean = - runCatching { - val ok = directManager.remove(item.artifact.packageName) - if (ok) runtime.ensureReady(forceRefresh = true) - ok - }.getOrDefault(false) + fun uninstall(item: SourceCatalogItem.Extension) { + launchJob { + val ok = runCatching { + val removed = directManager.remove(item.artifact.packageName) + if (removed) runtime.ensureReady(forceRefresh = true) + removed + }.getOrDefault(false) + if (!ok) onActionError.call(R.string.load_failed) + } + } suspend fun getImportedExtensionSource(item: SourceCatalogItem.Extension): MangaSource? { runtime.ensureReady() From bdb2756c4faab6816f6a139c391e0d5f7ad219e5 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Tue, 1 Sep 2026 02:04:16 +0700 Subject: [PATCH 08/16] Update deps + spotless code --- .../sources/catalog/SourcesCatalogViewModel.kt | 11 ++++++----- gradle/libs.versions.toml | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt index 713e48b0..0ef7f583 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -282,11 +282,12 @@ class SourcesCatalogViewModel fun uninstall(item: SourceCatalogItem.Extension) { launchJob { - val ok = runCatching { - val removed = directManager.remove(item.artifact.packageName) - if (removed) runtime.ensureReady(forceRefresh = true) - removed - }.getOrDefault(false) + val ok = + runCatching { + val removed = directManager.remove(item.artifact.packageName) + if (removed) runtime.ensureReady(forceRefresh = true) + removed + }.getOrDefault(false) if (!ok) onActionError.call(R.string.load_failed) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b9d2482..19711ecf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ collections = "1.5.0" conscrypt = "2.5.2" constraintlayout = "2.2.1" coreKtx = "1.17.0" -coroutines = "1.10.2" +coroutines = "1.11.0" dagger = "2.57.2" desugar = "2.1.5" diskLruCache = "1.5" @@ -51,7 +51,7 @@ testRules = "1.7.0" testRunner = "1.7.0" transition = "1.6.0" tsuki = "1.0.5" -tsukimix = "0a11e605a7" +tsukimix = "1.0.6" usagiRpc = "1.0.2" usagi-serialization = "01254ce6f1" viewpager2 = "1.1.0" From c0c130f6922749c49b86d34827825b338dcc934a Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 00:24:31 +0700 Subject: [PATCH 09/16] Synchronize webview with okhttp immediately upon refresh --- .../kotlin/org/draken/usagi/browser/BaseBrowserActivity.kt | 3 +++ .../parser/external/tachiyomi/ExternalMangaRepository.kt | 7 ++++++- .../org/draken/usagi/remotelist/ui/RemoteListViewModel.kt | 5 ++++- gradle/libs.versions.toml | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/browser/BaseBrowserActivity.kt b/app/src/main/kotlin/org/draken/usagi/browser/BaseBrowserActivity.kt index c95b510c..0ce3baec 100644 --- a/app/src/main/kotlin/org/draken/usagi/browser/BaseBrowserActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/browser/BaseBrowserActivity.kt @@ -2,6 +2,7 @@ package org.draken.usagi.browser import android.os.Bundle import android.view.View +import android.webkit.CookieManager import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding @@ -81,6 +82,7 @@ abstract class BaseBrowserActivity : } override fun onPause() { + runCatching { CookieManager.getInstance().flush() } viewBinding.webView.onPause() super.onPause() } @@ -93,6 +95,7 @@ abstract class BaseBrowserActivity : override fun onDestroy() { super.onDestroy() if (hasViewBinding()) { + runCatching { CookieManager.getInstance().flush() } viewBinding.webView.stopLoading() viewBinding.webView.destroy() } diff --git a/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt b/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt index 5dcbccdf..a7a6323a 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt @@ -48,7 +48,7 @@ class ExternalMangaRepository( ) : CachingMangaRepository(cache), FilterHost { private val appContext = context.applicationContext - val external = source.catalogueSource + var external = source.catalogueSource private val filterList = suspendLazy(Dispatchers.Default) { withContext(Dispatchers.IO) { @@ -248,6 +248,11 @@ class ExternalMangaRepository( fun isSlowdownEnabled() = externalSettings.isSlowdownEnabled(appContext, source) + fun invalidateSource() { + invalidateCache() + runtime?.refresh(source)?.let { external = it.catalogueSource } + } + override suspend fun getRelatedMangaImpl(seed: Manga): List { val http = external as? HttpSource ?: return emptyList() if (!http.supportsRelatedMangas || http.disableRelatedMangas) return emptyList() diff --git a/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt index f4bd1127..4f581c2a 100644 --- a/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/remotelist/ui/RemoteListViewModel.kt @@ -47,6 +47,7 @@ import org.draken.usagi.local.domain.model.LocalManga import tsuki.model.Manga import tsuki.util.sizeOrZero import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds private const val FILTER_MIN_INTERVAL = 250L @@ -119,7 +120,7 @@ open class RemoteListViewModel init { filterCoordinator .observe() - .debounce(FILTER_MIN_INTERVAL) + .debounce(FILTER_MIN_INTERVAL.milliseconds) .onEach { filterState -> loadingJob?.cancelAndJoin() mangaList.value = null @@ -139,6 +140,8 @@ open class RemoteListViewModel } override fun onRefresh() { + (repository as? org.draken.usagi.core.parser.external.tachiyomi.ExternalMangaRepository)?.invalidateSource() + ?: (repository as? org.draken.usagi.core.parser.CachingMangaRepository)?.invalidateCache() loadList(filterCoordinator.snapshot(), append = false) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19711ecf..615e93c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ testRules = "1.7.0" testRunner = "1.7.0" transition = "1.6.0" tsuki = "1.0.5" -tsukimix = "1.0.6" +tsukimix = "-SNAPSHOT" usagiRpc = "1.0.2" usagi-serialization = "01254ce6f1" viewpager2 = "1.1.0" From dbb05fd86fe704ac75458e4c0eac03ee701c7ee0 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 01:48:54 +0700 Subject: [PATCH 10/16] Hide genres chip with external sources --- .../kotlin/org/draken/usagi/filter/ui/FilterHeaderProducer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/draken/usagi/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/draken/usagi/filter/ui/FilterHeaderProducer.kt index ba3a95eb..08c0b64b 100644 --- a/app/src/main/kotlin/org/draken/usagi/filter/ui/FilterHeaderProducer.kt +++ b/app/src/main/kotlin/org/draken/usagi/filter/ui/FilterHeaderProducer.kt @@ -3,6 +3,7 @@ package org.draken.usagi.filter.ui import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import org.draken.usagi.R +import org.draken.usagi.core.model.isExternalSource import org.draken.usagi.core.model.titleResId import org.draken.usagi.core.ui.widgets.ChipsView import org.draken.usagi.filter.data.PersistableFilter @@ -171,7 +172,7 @@ class FilterHeaderProducer ) } val hasTags = result.any { it.data is MangaTag } - if (hasTags) { + if (hasTags && !source.isExternalSource()) { result.addFirst(moreTagsChip()) } return result From 9b9f4d0b09647dac8f1ecd7298796bf08045b6fa Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 02:42:47 +0700 Subject: [PATCH 11/16] Revert to old language filter list in catalog --- .../catalog/SourcesCatalogViewModel.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt index 0ef7f583..8a518b2f 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -47,6 +47,7 @@ import org.draken.usagi.list.ui.model.ListModel import org.draken.usagi.list.ui.model.LoadingState import tsuki.model.ContentType import tsuki.model.MangaSource +import tsuki.util.toTitleCase import java.io.File import java.util.EnumSet import java.util.Locale @@ -224,11 +225,11 @@ class SourcesCatalogViewModel if (code.isEmpty()) return context.getString(R.string.all_languages) if (code.equals("all", true)) return context.getString(R.string.various_languages) val loc = code.replace('_', '-').toLocale() - val disp = ConfigurationCompat.getLocales(context.resources.configuration)[0] ?: Locale.getDefault() - val name = loc.getDisplayLanguage(disp).trim() - if (!name.isLocaleCode(loc)) return name - val en = loc.getDisplayLanguage(Locale.ENGLISH).trim() - return en.takeUnless { it.isLocaleCode(loc) } ?: code.ifEmpty { context.getString(R.string.unknown) } + val name = loc.getDisplayName(loc).trim() + if (!name.isLocaleCode(loc)) return name.toTitleCase(loc) + val en = loc.getDisplayName(Locale.ENGLISH).trim() + return en.takeUnless { it.isLocaleCode(loc) }?.toTitleCase(Locale.ENGLISH) + ?: code.ifEmpty { context.getString(R.string.unknown) } } private fun String.isLocaleCode(loc: Locale): Boolean { @@ -400,10 +401,10 @@ class SourcesCatalogViewModel buildSet { add(null) grouped.values.forEach { v -> - if (v.size > 1 || v.any { it.locale.equals("all", true) }) { + if (v.size > 1 || v.any { it.locale.isBlank() || it.locale.equals("all", true) }) { add("all") } else { - v.forEach { add(canonicalLanguageCode(it.locale)) } + v.forEach { add(it.locale.ifBlank { "all" }) } } } } @@ -416,7 +417,7 @@ class SourcesCatalogViewModel } else { val hasLocale = variants.any { - canonicalLanguageCode(it.locale) == canonicalLanguageCode(filter.locale) + it.locale.ifBlank { "all" }.equals(filter.locale, true) } if (multi || !hasLocale) continue } @@ -458,11 +459,11 @@ class SourcesCatalogViewModel scoped.forEach { a -> val isMulti = a.sources.size > 1 || - a.sources.any { it.language.equals("all", true) } + a.sources.any { it.language.isBlank() || it.language.equals("all", true) } if (isMulti) { add("all") } else { - a.sources.forEach { add(canonicalLanguageCode(it.language)) } + a.sources.forEach { add(it.language.ifBlank { "all" }) } } } } @@ -478,7 +479,7 @@ class SourcesCatalogViewModel } else { val hasLang = art.sources.any { - canonicalLanguageCode(it.language) == canonicalLanguageCode(filter.locale) + it.language.ifBlank { "all" }.equals(filter.locale, true) } if (multi || !hasLang) continue } From 235efb709b05cf01f46b4d882c74aa54b651c5e3 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 03:35:04 +0700 Subject: [PATCH 12/16] Fix asynchronous catalog bug --- .../catalog/SourcesCatalogViewModel.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt index 8a518b2f..4a8d5fbe 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -382,16 +382,17 @@ class SourcesCatalogViewModel ): List { if (!isScopedMode) { val sources = - repository.queryParserSources( - isDisabledOnly = true, - isNewOnly = filter.isNewOnly, - excludeBroken = false, - types = filter.types, - query = null, - locale = null, - plugin = filter.plugin, - sortOrder = SourcesSortOrder.ALPHABETIC, - ) + repository + .queryParserSources( + isDisabledOnly = true, + isNewOnly = filter.isNewOnly, + excludeBroken = false, + types = filter.types, + query = null, + locale = null, + plugin = filter.plugin, + sortOrder = SourcesSortOrder.ALPHABETIC, + ).filter { source -> repository.getDisabledSources().any { it.name == source.name } } val grouped = sources.groupBy { s: MangaSource -> val ps = (s as? PluginMangaSource) ?: (s as? MangaSourceInfo)?.mangaSource as? PluginMangaSource From 35f2eb93133782588520aebba1321a7e7fa4a999 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 03:56:00 +0700 Subject: [PATCH 13/16] Fix catalog behavior bug --- .../sources/catalog/SourcesCatalogActivity.kt | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt index c4da8be7..759cfa94 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt @@ -76,9 +76,9 @@ class SourcesCatalogActivity : val adapter = SourcesCatalogAdapter( nativeListener = this, - onExtensionClick = { item, _ -> openSource(item) }, - onExtensionInstall = { item, _ -> installExtension(item) }, - onExtensionUninstall = { item, _ -> uninstallExtension(item) }, + onExtensionClick = { item, _ -> viewModel.openSource(item) }, + onExtensionInstall = { item, _ -> install(item) }, + onExtensionUninstall = { item, _ -> uninstall(item) }, onExtensionSideload = ::showSideloadMenu, ) with(viewBinding.recyclerView) { @@ -179,12 +179,12 @@ class SourcesCatalogActivity : return false } - private fun installExtension(item: SourceCatalogItem.Extension) { + private fun install(item: SourceCatalogItem.Extension) { if (item.isLoaded && !item.hasUpdate) return viewModel.install(item) } - private fun uninstallExtension(item: SourceCatalogItem.Extension) { + private fun uninstall(item: SourceCatalogItem.Extension) { if (item.isPreInstalledApk) { val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -199,11 +199,6 @@ class SourcesCatalogActivity : } } - private fun openSource(item: SourceCatalogItem.Extension) { - if (!item.isLoaded && item.isPreInstalledApk) return - viewModel.openSource(item) - } - private fun showSideloadMenu( item: SourceCatalogItem.Extension, anchor: View, From 178345fac323e41a62f1f11dc28d05bde7450124 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 04:15:08 +0700 Subject: [PATCH 14/16] Automatically update sources from plugins --- .../manage/plugins/UpdatePluginsProvider.kt | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt index 294c65d0..73efbfcb 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/manage/plugins/UpdatePluginsProvider.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.withContext import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import org.draken.tsukimix.core.parser.external.ExtensionProvider +import org.draken.tsukimix.core.parser.external.NativeExtManager import org.draken.usagi.core.db.MangaDatabase import org.draken.usagi.core.model.PluginKeyResolver import org.draken.usagi.core.network.BaseHttpClient @@ -37,6 +39,8 @@ class UpdatePluginsProvider private val savedFiltersRepository: SavedFiltersRepository, private val mangaDynamicRepository: MangaDynamicRepository, private val pluginKeyResolver: PluginKeyResolver, + private val manager: NativeExtManager, + private val provider: ExtensionProvider, ) { private val mutex = Mutex() private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } @@ -49,31 +53,40 @@ class UpdatePluginsProvider if (now - settings.lastAutoPlugins < COOLDOWN) return@withContext settings.lastAutoPlugins = now val installed = mangaDynamicRepository.get().toSet() - if (installed.isEmpty()) return@withContext - val meta = readAndCleanDto(installed) - if (meta.isEmpty()) return@withContext - val pluginsDir = mangaDynamicRepository.getDir() - val results = - installed - .map { name -> - async { - val info = meta[name] ?: return@async null - val release = requestRelease(info.repository, name) ?: return@async null - if (release.tag == info.tag) return@async null - if (replacePlugin(release.downloadUrl, File(pluginsDir, name))) { - name to RemoteReleaseDto(info.repository, release.tag) - } else { - null - } - } - }.awaitAll() - .filterNotNull() - - if (results.isNotEmpty()) { - results.forEach { (name, dto) -> meta[name] = dto } - writeDto(meta) - reloadPlugins(pluginsDir) + if (installed.isNotEmpty()) { + val meta = readAndCleanDto(installed) + if (meta.isNotEmpty()) { + val pluginsDir = mangaDynamicRepository.getDir() + val results = + installed + .map { name -> + async { + val info = meta[name] ?: return@async null + val rel = requestRelease(info.repository, name) ?: return@async null + if (rel.tag == info.tag) return@async null + if (replacePlugin(rel.downloadUrl, File(pluginsDir, name))) { + name to RemoteReleaseDto(info.repository, rel.tag) + } else { + null + } + } + }.awaitAll() + .filterNotNull() + if (results.isNotEmpty()) { + results.forEach { (name, dto) -> meta[name] = dto } + writeDto(meta) + reloadPlugins(pluginsDir) + } + } } + val inst = manager.installed.value.associateBy { it.packageName } + provider + .loadSaved() + .filter { art -> + val cur = inst[art.packageName] + cur != null && (art.versionCode ?: 0) > cur.versionCode + }.map { art -> async { runCatching { manager.install(art) } } } + .awaitAll() } } finally { mutex.unlock() From 747f15b6adfd228e0c27ebc108c9758c542aec45 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 04:41:16 +0700 Subject: [PATCH 15/16] Re-use filter for catalog in manager --- .../settings/sources/catalog/SourcesCatalogActivity.kt | 2 +- .../settings/sources/catalog/SourcesCatalogViewModel.kt | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt index 759cfa94..8bf7a769 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogActivity.kt @@ -272,7 +272,7 @@ class SourcesCatalogActivity : icon = R.drawable.ic_language, isDropdown = true, ) - if (hasNewSources && !viewModel.isScopedMode) { + if (hasNewSources || viewModel.isScopedMode) { chips += ChipModel( title = getString(R.string._new), diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt index 4a8d5fbe..098e1095 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -503,7 +503,7 @@ class SourcesCatalogViewModel val custom = catalogProvider.repositoryName(art.repositoryUrl) ?: DirectExternalPluginMetadata.get(art.packageName) - extSources.add( + val ext = SourceCatalogItem.Extension( c, art, @@ -513,8 +513,9 @@ class SourcesCatalogViewModel multi, art.packageName in installing, custom, - ), - ) + ) + if (filter.isNewOnly && !ext.isInstalled && !ext.isPreInstalledApk) continue + extSources.add(ext) } return extSources.ifEmpty { val title = From e102feeb1e9ebc19505d1bff69c6941037a914d8 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Wed, 2 Sep 2026 05:03:01 +0700 Subject: [PATCH 16/16] Set half expanded state with filter table --- .../usagi/filter/ui/external/sheet/FilterSheetFragment.kt | 1 + app/src/main/res/layout/sheet_options.xml | 2 +- gradle/libs.versions.toml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/filter/ui/external/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/draken/usagi/filter/ui/external/sheet/FilterSheetFragment.kt index 65662f27..b6e70628 100644 --- a/app/src/main/kotlin/org/draken/usagi/filter/ui/external/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/draken/usagi/filter/ui/external/sheet/FilterSheetFragment.kt @@ -74,6 +74,7 @@ class FilterSheetFragment : override fun onStart() { super.onStart() setHalfExpanded() + (dialog as? com.google.android.material.bottomsheet.BottomSheetDialog)?.behavior?.skipCollapsed = false } override fun onStateChanged( diff --git a/app/src/main/res/layout/sheet_options.xml b/app/src/main/res/layout/sheet_options.xml index 7c3b4570..9255a0b5 100644 --- a/app/src/main/res/layout/sheet_options.xml +++ b/app/src/main/res/layout/sheet_options.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent" + android:layout_height="wrap_content" android:orientation="vertical">