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 { 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/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/AppModule.kt b/app/src/main/kotlin/org/draken/usagi/core/AppModule.kt index 7c392535..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,6 +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.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 @@ -38,6 +43,8 @@ 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.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.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) @@ -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 provideExtRuntime( + installedManager: Manager, + directManager: NativeExtManager, + catalogProvider: ExtensionProvider, + database: MangaDatabase, + settings: AppSettings, + ): ExtRuntime = + ExtRuntime( + installedManager = installedManager, + directManager = directManager, + disabledSourceProvider = + { + if (settings.isAllSourcesEnabled) { + emptySet() + } else { + database + .getSourcesDao() + .findAll() + .asSequence() + .filterNot { it.isEnabled } + .map { it.source } + .toSet() + } + }, + sourcesPublisher = + { sources -> + Metadata.update(directManager.installed.value) { catalogProvider.repositoryName(it) } + val non = MangaSourceRegistry.sources.filterNot { it is Manga } + MangaSourceRegistry.publish(non + sources) + }, + ) + + @Provides + @Singleton + 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 9678a0d8..3c11d75e 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,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.tachiyomi.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource as External +import org.draken.tsukimix.core.parser.external.RuntimeInitializer as RuntimeInit open class BaseApp : Application(), @@ -74,7 +72,7 @@ open class BaseApp : lateinit var pluginKeyResolver: PluginKeyResolver @Inject - lateinit var externalManager: ExternalManager + lateinit var runtimeInit: dagger.Lazy @Inject @LocalStorageChanges @@ -105,18 +103,16 @@ open class BaseApp : localStorageChanges.collect(localMangaIndexProvider.get()) } launch { - externalManager.sources.collect { - val e = MangaSourceRegistry.sources.filterNot { it is External } - MangaSourceRegistry.publish(e + externalManager.getActiveSources()) - } + runCatching { runtimeInit.get().initialize() } } - mangaDynamicRepository.load(mangaDynamicRepository.getDir()) - externalManager.ensureReady() - withContext(Dispatchers.Default) { - pluginKeyResolver.normalize(database.get(), savedFiltersRepository) + runCatching { + mangaDynamicRepository.load(mangaDynamicRepository.getDir()) + 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/image/ExternalSourceFetcher.kt b/app/src/main/kotlin/org/draken/usagi/core/image/ExternalSourceFetcher.kt index e4be672d..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 @@ -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.external.model.Manga as External class ExternalSourceFetcher( private val httpSource: HttpSource, @@ -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/model/MangaSource.kt b/app/src/main/kotlin/org/draken/usagi/core/model/MangaSource.kt index b2ba7cdf..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,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.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 @@ -17,31 +19,66 @@ 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.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 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 locale: String - get() = delegate.locale +object DirectExternalPluginMetadata { + @Volatile + private var names: Map = emptyMap() - override val contentType: ContentType - get() = delegate.contentType + fun update( + installed: Collection, + resolver: ((String) -> String?)? = null, + ) { + names = + installed + .mapNotNull { r -> + val 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() + } - override val title: String - get() = delegate.title + fun get(packageName: String): String? = names[packageName] - override val isBroken: Boolean - get() = delegate.isBroken + fun deriveName(url: String): String? = + runCatching { + val uri = URI(url) + val host = uri.host?.lowercase(Locale.ROOT).orEmpty() + 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() } data object LocalMangaSource : MangaSource { @@ -75,13 +112,14 @@ 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.external.ExtensionManager + .getByName(name) + ?.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) } @@ -139,41 +177,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() -> { + when (val source = unwrap()) { + is ExternalSource -> { + val type = context.getString(source.contentType.titleResId) + val language = + if (source.locale.equals("all", true) || source.hasLanguageSuffix) { + context.getString(R.string.various_languages) + } else { + source.locale.toLocaleOrNull().getDisplayName(context) + } + val label = + if (source.isPreInstalled) { + context.getString(R.string.external_source) + } else { + DirectExternalPluginMetadata.get(source.pkgName) + ?: context.getString(R.string.external_source) + } + "$type, $language • $label" + } + + is ExternalMangaSource -> { context.getString(R.string.external_source) } - this === LocalMangaSource || this === TestMangaSource || this === UnknownMangaSource -> { + LocalMangaSource, TestMangaSource, UnknownMangaSource -> { null } else -> { val type = context.getString(contentType.titleResId) - val loc = locale.toLocale().getDisplayName(context) + 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) { - "$baseSummary • ${pluginSource.jarName}" - } else { - pluginSource?.jarName ?: 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 = @@ -195,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/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/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 33b4a1ad..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.TachiyomiMangaSource 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 ee00c8c7..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 @@ -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.external.ExtRuntime import org.draken.usagi.core.cache.MemoryContentCache import org.draken.usagi.core.model.LocalMangaSource import org.draken.usagi.core.model.MangaSourceInfo @@ -30,11 +31,11 @@ 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.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 @@ -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 extRuntime: Provider, ) { private val cache = ArrayMap>() private var cacheVersion = -1 @@ -95,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() } @@ -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 = extRuntime.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) @@ -174,18 +176,15 @@ interface MangaRepository { } } - private fun resolve() { + private fun resolve(target: MangaSource? = null) = synchronized(this) { if (MangaSourceRegistry.sources.isEmpty()) { - runCatching { - mangaRepository.load(mangaRepository.getDir()) - kotlinx.coroutines.runBlocking { - externalManager.ensureReady() - } - } + runCatching { mangaRepository.load(mangaRepository.getDir()) } + } + if (target is UnresolvedMangaSource && target.name.startsWith("EXTERNAL")) { + runCatching { kotlinx.coroutines.runBlocking { extRuntime.get().ensureReady() } } } } - } } companion object { 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..a7a6323a --- /dev/null +++ b/app/src/main/kotlin/org/draken/usagi/core/parser/external/tachiyomi/ExternalMangaRepository.kt @@ -0,0 +1,302 @@ +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.CancellationException +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 + var 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.Default) { + 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: CancellationException) { + throw e + } 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.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) + } + 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.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) + } + 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.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) + } + } + } + + 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.Default) { + try { + http.getImage(cp) + } catch (e: CancellationException) { + throw e + } 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) + + 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() + 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 d7ef2195..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 @@ -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.external.ExtensionSourceSettings import org.draken.usagi.R import org.draken.usagi.core.exceptions.CloudFlareProtectedException import org.draken.usagi.core.model.MangaSource @@ -41,7 +43,7 @@ import tsuki.util.runCatchingCancellable import java.io.File import javax.inject.Inject import coil3.Uri as CoilUri -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, @@ -56,7 +58,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 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}") @@ -110,6 +112,42 @@ class FaviconFetcher( return fetchPackageIcon(source.packageName, source.authority) } + private suspend fun fetchExternalIcon(source: ExtensionMangaSource): 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: ExtensionMangaSource, + ): 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 deleted file mode 100644 index 8ef3ba70..00000000 --- a/app/src/main/kotlin/org/draken/usagi/core/parser/tachiyomi/ExternalMangaRepository.kt +++ /dev/null @@ -1,297 +0,0 @@ -package org.draken.usagi.core.parser.tachiyomi - -import android.content.Context -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.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.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 java.util.concurrent.ConcurrentHashMap -import org.draken.tsukimix.core.parser.tachiyomi.TachiyomiSourceSettings as externalSettings -import org.draken.usagi.core.network.imageproxy.ImageProxyInterceptor as Interceptor - -class ExternalMangaRepository( - private val context: Context, - override val source: TachiyomiMangaSource, - cache: MemoryContentCache, -) : CachingMangaRepository(cache), - FilterHost { - 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) { - 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 mangasPage = - try { - when { - hasFilters -> { - 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) - details.copy( - chapters = - update.chapters.asReversed().mapIndexed { index, chapter -> - chapter.toMangaChapter(source, details.title, index) - }, - source = source, - ) - } - - override suspend fun getPagesImpl(chapter: MangaChapter): List = - withContext(Dispatchers.IO) { - val pageList = - try { - external.getPageList(chapter.toSChapter()) - } catch (e: Throwable) { - throw mapException(e) - } - pageList.map { page -> - val resolved = - page.imageUrl - ?: (external as? HttpSource)?.getImageUrl(page) - ?: page.url - page.imageUrl = resolved - pageCache[pageCacheKey(source, resolved)] = page - page.toMangaPage(source, 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 httpSource = external as? HttpSource ?: return super.getPageRequest(page) - val tachiyomiPage = - pageCache[pageCacheKey(source, 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, source) - .build() - } - } - - override suspend fun getPageResponse( - page: MangaPage, - okHttp: OkHttpClient, - interceptor: Interceptor, - ): Response { - val httpSource = external as? HttpSource ?: return super.getPageResponse(page, okHttp, interceptor) - val tachiyomiPage = - pageCache[pageCacheKey(source, 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(context, source) - - fun getSettingsPreferences() = externalSettings.preferences(context, source) - - fun refreshDomainOverride() { - externalSettings.refreshDomainOverride(context, source) - } - - fun isSlowdownEnabled(): Boolean = externalSettings.isSlowdownEnabled(context, 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(context, source) ?: httpSource.getHomeUrl(), - source = source, - headers = httpSource.headers, - ) - } - return error as? IOException ?: IOException(error.message ?: error.toString(), 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 = ConcurrentHashMap() - - fun pageCacheKey( - source: TachiyomiMangaSource, - url: String, - ): String = "${source.name}:$url" - } -} 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/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 6a80b729..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 @@ -16,8 +16,11 @@ 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.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 @@ -26,9 +29,12 @@ import org.draken.usagi.core.db.entity.MangaSourceEntity 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 +46,11 @@ 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.DirectExternalPluginMetadata as Metadata +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 extRuntime: dagger.Lazy? = null, ) { private var assimilatedVersion = -1 private val dao: MangaSourcesDao @@ -60,15 +67,16 @@ class MangaSourcesRepository init { processLifecycleScope.launch(Dispatchers.IO) { + runCatching { extRuntime?.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.external.model.Manga + ext?.pkgName == plugin || Metadata.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) + extRuntime?.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..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,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.external.ExtRuntime 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 extRuntime: ExtRuntime, 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) + extRuntime.ensureReady(forceRefresh = true) + val synchronizedRollback = + ReversibleHandle { + rollback.reverse() + extRuntime.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/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 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/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/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt b/app/src/main/kotlin/org/draken/usagi/list/domain/ListFilterOption.kt index 82aaef6e..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.TachiyomiMangaSource 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/main/ui/MainActivity.kt b/app/src/main/kotlin/org/draken/usagi/main/ui/MainActivity.kt index 660321b5..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 @@ -57,7 +57,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 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..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 @@ -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 @@ -48,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 @@ -120,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 @@ -140,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) } @@ -238,7 +240,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 92272aac..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 @@ -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.external.ExtRuntime 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.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 externalManager: ExternalManager + lateinit var extRuntime: ExtRuntime + + private var filterBadge: ViewBadge? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -213,7 +215,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(extRuntime::resolve) ?: source private fun initFilter(filterOwner: FilterCoordinator.Owner) { if (viewBinding.containerSide != null) { @@ -229,8 +231,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 +244,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/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/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 8ef8f19f..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.TachiyomiSourceSettings +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 @@ -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..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.addLangToPref +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 @@ -29,7 +28,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 +36,7 @@ class SourceSettingsFragment : private val viewModel: SourceSettingsViewModel by viewModels() @Inject - lateinit var externalManager: ExternalManager + lateinit var extRuntime: ExtRuntime override fun onResume() { super.onResume() @@ -55,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 { - externalManager.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 a892075f..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,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.external.ExtRuntime import org.draken.usagi.R import org.draken.usagi.core.model.MangaSource import org.draken.usagi.core.model.MangaSourceRegistry @@ -16,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 @@ -26,8 +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.TachiyomiExtensionManager as ExternalManager -import org.draken.tsukimix.core.parser.tachiyomi.model.TachiyomiMangaSource 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 externalManager: ExternalManager, + 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,12 +121,13 @@ class SourceSettingsViewModel fun setEnabled(value: Boolean) { launchJob(Dispatchers.Default) { mangaSourcesRepository.setSourcesEnabled(setOf(source), value) + extRuntime.ensureReady(forceRefresh = true) } } fun publish() { val e = MangaSourceRegistry.sources.filterNot { it is ExternalSource } - MangaSourceRegistry.publish(e + externalManager.getActiveSources()) + MangaSourceRegistry.publish(e + extRuntime.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..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,6 +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.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 @@ -23,35 +22,57 @@ class SourcesSettingsViewModel @Inject constructor( sourcesRepository: MangaSourcesRepository, - @ApplicationContext private val context: Context, + private val catalogProvider: ExtensionProvider, + private val directManager: NativeExtManager, + @param: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) + val externalPluginCount = MutableStateFlow(calcExternalCount()) val availableSourcesCount = sourcesRepository .observeAvailableSourcesCount() .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) - val isLinksEnabled = MutableStateFlow(isLinksEnabled()) + init { + refreshExternalCounts() + } + + private fun calcExternalCount(): Int { + val cached = catalogProvider.getSavedRepositories().map { catalogProvider.canonicalKey(it) } + val inst = directManager.installed.value.map { catalogProvider.canonicalKey(it.repositoryUrl) } + return (cached + inst).filter { it.isNotBlank() }.toSet().size + } + + fun refreshExternalCounts() { + launchJob(Dispatchers.Default) { + 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/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..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 @@ -1,22 +1,141 @@ 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.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 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 { - override fun areItemsTheSame(other: ListModel): Boolean = other is Source && other.source == source + 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 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 { + DirectExternalPluginMetadata.get(u.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 (pLabel != null) { + "$type, $lang • $pLabel" + } else { + context.getString(R.string.source_summary_pattern, type, lang) + } + } + + override fun areItemsTheSame(other: ListModel) = other is Source && other.source == source + } + + 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 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 = + if (isMultiLanguage || source.language.equals("all", true)) { + context.getString(R.string.various_languages) + } else { + languageDisplayName(context) + } + 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) + } + val label = if (isPreInstalledApk) context.getString(R.string.external_source) else pluginName + return "$typeLabel, $lang • $label" + } + + private fun languageDisplayName(context: Context): String { + if (source.language.equals("all", true)) return context.getString(R.string.various_languages) + 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() } + ?: DirectExternalPluginMetadata.get(artifact.packageName) + ?: if (artifact.repositoryUrl.startsWith("local:") || artifact.repositoryUrl.startsWith("installed:")) { + installed + ?.name + ?.removePrefix("Extension: ") + ?.removePrefix("Extension - ") + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: artifact.name + .removePrefix("Extension: ") + .removePrefix("Extension - ") + .trim() + } else { + runCatching { + val uri = URI(artifact.repositoryUrl) + val host = uri.host?.lowercase(Locale.ROOT).orEmpty() + val segs = + uri.path + .trim('/') + .split('/') + .filter { it.isNotBlank() } + when { + host.endsWith(".github.io") -> host.removeSuffix(".github.io") + segs.isNotEmpty() -> segs.first().replaceFirstChar { it.titlecase(Locale.ROOT) } + else -> host.ifBlank { artifact.repositoryUrl } + } + }.getOrDefault(artifact.repositoryUrl) + } + + override fun areItemsTheSame(other: ListModel) = other is Extension && 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 + 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 58fd1424..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 @@ -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,122 @@ 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 = + binding.imageViewAdd.setOnClickListener { v -> listener.onItemLongClick(item, v) } + binding.root.setOnClickListener { v -> listener.onItemClick(item, v) } + val pad = context.getThemeDimensionPixelOffset( appcompatR.attr.listPreferredItemPaddingEnd, binding.root.paddingStart, ) - binding.root.updatePaddingRelative( - end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0), - ) - + 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.source.getSummary(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.imageViewIcon.setImageAsync(item.source) + binding.imageViewAdd.isVisible = true + binding.imageViewAdd.setImageResource(R.drawable.ic_add) + binding.imageViewAdd.contentDescription = context.getString(R.string.add) } } +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 -> + 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) 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() + } + bind { + val size = context.resources.getDimensionPixelSize(R.dimen.card_indicator_size) + binding.imageViewIcon.layoutParams = + binding.imageViewIcon.layoutParams.apply { + width = size + height = size + } + binding.imageViewIcon.scaleType = ImageView.ScaleType.CENTER_CROP + 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(fb) + } 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 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).apply { + setStyle(CircularProgressDrawable.DEFAULT) + setColorSchemeColors(context.getThemeColor(appcompatR.attr.colorControlNormal)) + start() + }, + ) + binding.imageViewAdd.contentDescription = context.getString(R.string.loading_) + } else { + binding.imageViewAdd.isEnabled = true + binding.imageViewAdd.setImageResource( + when { + isInst && item.hasUpdate -> R.drawable.ic_updated + isInst -> R.drawable.ic_delete + else -> R.drawable.ic_download + }, + ) + binding.imageViewAdd.contentDescription = + context.getString( + when { + isInst && item.hasUpdate -> R.string.update + isInst -> R.string.delete + else -> R.string.add + }, + ) + } + } +} + fun sourceCatalogItemHintAD() = adapterDelegateViewBinding( - { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, + { inf, p -> ItemEmptyHintBinding.inflate(inf, p, 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..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 @@ -1,17 +1,28 @@ 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.view.WindowInsetsCompat import androidx.core.view.updatePadding 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 org.draken.usagi.R @@ -23,11 +34,8 @@ 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 import org.draken.usagi.databinding.ActivitySourcesCatalogBinding import org.draken.usagi.list.ui.adapter.TypedListSpacingDecoration import org.draken.usagi.main.ui.owners.AppBarOwner @@ -38,37 +46,88 @@ 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 navBarBottomInset = 0 + private val downloadReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) + if (id != 0L && sideloadDownloadIds.remove(id)) openDownloadedApk(id) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) - val sourcesAdapter = SourcesCatalogAdapter(this) + if (viewModel.isScopedMode) { + title = intent.getStringExtra(EXTRA_REPOSITORY_NAME) ?: viewModel.scopedRepositoryUrl + } + val adapter = + SourcesCatalogAdapter( + nativeListener = this, + onExtensionClick = { item, _ -> viewModel.openSource(item) }, + onExtensionInstall = { item, _ -> install(item) }, + onExtensionUninstall = { item, _ -> uninstall(item) }, + onExtensionSideload = ::showSideloadMenu, + ) with(viewBinding.recyclerView) { setHasFixedSize(true) addItemDecoration(TypedListSpacingDecoration(context, false)) - adapter = sourcesAdapter + this.adapter = adapter } viewBinding.chipsFilter.onChipClickListener = this - FadingAppbarMediator(viewBinding.appbar, viewBinding.toolbar).bind() - viewModel.content.observe(this, sourcesAdapter) - viewModel.onActionDone.observeEvent( - this, - ReversibleActionObserver(viewBinding.recyclerView), - ) + 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) { - updateFilers(it.first, it.second, it.third) + updateFilters(it.first, it.second, it.third) } - addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) + 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, + ) + } + + override fun onResume() { + super.onResume() + 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 super.onDestroy() } @@ -78,6 +137,7 @@ class SourcesCatalogActivity : insets: WindowInsetsCompat, ): WindowInsetsCompat { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + navBarBottomInset = bars.bottom viewBinding.recyclerView.updatePadding( left = bars.left, right = bars.right, @@ -109,9 +169,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, @@ -121,42 +179,100 @@ class SourcesCatalogActivity : return false } - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - val sq = - (item.actionView as? SearchView) - ?.query - ?.trim() - ?.toString() - .orEmpty() - viewModel.performSearch(sq) - return true + private fun install(item: SourceCatalogItem.Extension) { + if (item.isLoaded && !item.hasUpdate) return + viewModel.install(item) } - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - viewModel.performSearch(null) - return true + private fun uninstall(item: SourceCatalogItem.Extension) { + 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 { + viewModel.uninstall(item) + } } - private fun updateFilers( + private fun showSideloadMenu( + item: SourceCatalogItem.Extension, + anchor: View, + ) { + PopupMenu(this, anchor) + .apply { + menu.add(Menu.NONE, Menu.NONE, 0, R.string.download) + setOnMenuItemClickListener { + downloadApk(item) + true + } + }.show() + } + + 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 + } + 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) ?: 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(intent) }.onFailure { + if (it is ActivityNotFoundException) showSnackbar(getString(R.string.error), Snackbar.LENGTH_LONG) + } + } + + private fun updateFilters( appliedFilter: SourcesCatalogFilter, hasNewSources: Boolean, contentTypes: List, ) { val chips = ArrayList(contentTypes.size + 3) + if (!viewModel.isScopedMode) { + chips += + ChipModel( + 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", + ) + } 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 = viewModel.localeDisplayName(appliedFilter.locale), icon = R.drawable.ic_language, isDropdown = true, ) - if (hasNewSources) { + if (hasNewSources || viewModel.isScopedMode) { chips += ChipModel( title = getString(R.string._new), @@ -166,43 +282,54 @@ 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 showLocalesMenu(anchor: View) { - val locales = - viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) { - it to it?.toLocale() - } - locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second }) - val menu = PopupMenu(this, anchor) - for ((i, lc) in locales.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(this)) - } - menu.setOnMenuItemClickListener { - viewModel.setLocale(locales.getOrNull(it.order)?.first) - true + private var currentSnackbar: Snackbar? = null + + private fun showSnackbar( + message: CharSequence, + duration: Int, + ) { + currentSnackbar?.dismiss() + val sb = Snackbar.make(viewBinding.recyclerView, message, duration) + (sb.view.layoutParams as? ViewGroup.MarginLayoutParams)?.let { + it.bottomMargin += navBarBottomInset + sb.view.layoutParams = it } - menu.show() + currentSnackbar = sb + sb.show() + } + + private fun showLocalesMenu(anchor: View) { + 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.removeSuffix(".jar")) - } - menu.setOnMenuItemClickListener { - val p = if (it.order == 0) null else viewModel.plugins[it.order - 1] - 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 = 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 5c6fd894..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 @@ -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, + 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(listener)) + addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(nativeListener)) + addDelegate( + ListItemType.INFO, + sourceCatalogItemExtensionAD( + onExtensionClick, + onExtensionInstall, + onExtensionUninstall, + onExtensionSideload, + ), + ) 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.Extension -> item.displayName.take(1) + else -> null + } } 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 d0a1971d..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 @@ -1,103 +1,351 @@ 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 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.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.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 import org.draken.usagi.core.ui.BaseViewModel 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 tsuki.util.toTitleCase +import java.io.File import java.util.EnumSet import java.util.Locale import javax.inject.Inject +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, - settings: AppSettings, + private val settings: AppSettings, + private val directManager: NativeExtManager, + 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 get() = scopedRepositoryUrl != null val onActionDone = MutableEventFlow() - val locales: Set = - repository.allMangaSources.mapTo(HashSet()) { it.locale }.also { - it.add(null) - } + val onActionError = MutableEventFlow() + val onOpenSource = MutableEventFlow() + 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 = Locale.getDefault().language.takeIf { it in locales }, - 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 = - 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 { 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 { r -> + val repoName = DirectExternalPluginMetadata.get(r.packageName) + if (repoName != null) { + put(repoName, repoName) + } else { + val name = + r.name + .removePrefix("Extension: ") + .removePrefix("Extension - ") + .trim() + put(r.packageName, name) + } + } + }.toList().sortedBy { it.second.lowercase(Locale.ROOT) } val contentTypes = MutableStateFlow>(emptyList()) + 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> = combine( searchQuery, appliedFilter, db.invalidationTracker.createFlow(TABLE_SOURCES), - ) { q, f, _ -> - buildSourcesList(f, q) - }.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 cached = catalogProvider.loadSavedCached() + extCatalog.value = cached contentTypes.value = getContentTypes(settings.isNsfwContentDisabled) + isInitialLoading.value = false + launch { runCatching { runtime.ensureReady() } } + launch { + val ref = catalogProvider.loadSaved() + if (ref != cached) extCatalog.value = ref + } } } fun performSearch(query: String?) { - searchQuery.value = query?.trim() + searchQuery.value = query } fun setLocale(value: String?) { appliedFilter.value = appliedFilter.value.copy(locale = value) } + 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 (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) + if (code.equals("all", true)) return context.getString(R.string.various_languages) + val loc = code.replace('_', '-').toLocale() + 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 { + 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 { + val scoped = scopedRepositoryUrl ?: return emptyList() + 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://") + } + return installed.map { it.toArtifact() } + } + + 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) { + val s = getImportedExtensionSource(item) ?: return false + repository.setSourcesEnabled(setOf(s), true) + runtime.ensureReady(forceRefresh = true) + true + } else { + val ok = directManager.install(item.artifact) + if (ok) { + runtime.ensureReady(forceRefresh = true) + getImportedExtensionSource(item)?.let { repository.setSourcesEnabled(setOf(it), true) } + } + ok + } + } catch (_: Throwable) { + false + } finally { + installingPackages.update { it - item.artifact.packageName } + } + + 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() + 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) + ) + } + + is ExternalMangaSource -> { + u.packageName == item.artifact.packageName + } + + else -> { + false + } + } + } + } + + suspend fun enableExtensionSource(source: MangaSource) { + repository.setSourcesEnabled(setOf(source), true) + runtime.ensureReady(forceRefresh = true) + } + + private fun ExtMangaSource.matches(item: SourceCatalogItem.Extension) = matchesCatalog(item.artifact, item.source) + + private fun ExtMangaSource.matchesCatalog( + art: ExtArtifact, + src: ExtSource, + ) = pkgName == art.packageName && + displayName.equals(src.name, true) && + canonicalLanguageCode(locale) == canonicalLanguageCode(src.language) + + private data class CatalogExtState( + 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 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)) } } @@ -106,15 +354,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) { @@ -128,48 +374,182 @@ class SourcesCatalogViewModel private suspend fun buildSourcesList( filter: SourcesCatalogFilter, query: String?, + artifacts: List, + installed: List, + loadedIds: Set, + pre: List, + installing: 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, - ) - return if (sources.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, - ) + 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, + ).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 + ps?.jarName.orEmpty() to s.title + } + locales.value = + buildSet { + add(null) + grouped.values.forEach { v -> + if (v.size > 1 || v.any { it.locale.isBlank() || it.locale.equals("all", true) }) { + add("all") + } else { + v.forEach { add(it.locale.ifBlank { "all" }) } + } + } + } + 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 (!multi) continue + } else { + val hasLocale = + variants.any { + it.locale.ifBlank { "all" }.equals(filter.locale, true) + } + if (multi || !hasLocale) continue + } + } + 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() && + !pref.title.contains(query, true) && + !pref.name.contains(query, true) + ) { + continue + } + res.add(SourceCatalogItem.Source(pref, isMultiLanguage = multi)) + } + return res.ifEmpty { + val title = + if (query == null) { + R.string.no_manga_sources + } else { + 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) + locales.value = + buildSet { + add(null) + scoped.forEach { a -> + val isMulti = + a.sources.size > 1 || + a.sources.any { it.language.isBlank() || it.language.equals("all", true) } + if (isMulti) { + add("all") + } else { + a.sources.forEach { add(it.language.ifBlank { "all" }) } + } + } + } + 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 (!multi) continue } else { - SourceCatalogItem.Hint( - icon = R.drawable.ic_empty_feed, - title = R.string.nothing_found, - text = R.string.no_manga_sources_found, - ) - }, - ) - } else { - sources.map { - SourceCatalogItem.Source(source = it) + val hasLang = + art.sources.any { + it.language.ifBlank { "all" }.equals(filter.locale, true) + } + if (multi || !hasLang) continue + } } + 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) + val ext = + SourceCatalogItem.Extension( + c, + art, + instMap[art.packageName], + c.id in loadedIds, + pre.any { it.matchesCatalog(art, c) }, + multi, + art.packageName in installing, + custom, + ) + if (filter.isNewOnly && !ext.isInstalled && !ext.isPreInstalledApk) continue + extSources.add(ext) + } + return extSources.ifEmpty { + val title = + if (query == null) { + R.string.no_manga_sources + } else { + 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 getContentTypes(isNsfwDisabled: Boolean): List { - val result = repository.allMangaSources.mapSortedByCount { it.contentType } - 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 710a30cd..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 @@ -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 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..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 @@ -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 @@ -16,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 @@ -141,7 +145,23 @@ class SourcesManageFragment : item: SourceConfigItem.SourceItem, isEnabled: Boolean, ) { - viewModel.setEnabled(item.source, isEnabled) + val packageName = item.source.externalPackageName() + 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..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,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.external.ExtRuntime 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 extRuntime: ExtRuntime, ) : BaseViewModel() { val content = listProducer.list val onActionDone = MutableEventFlow() @@ -75,8 +78,14 @@ class SourcesManageViewModel ) { launchJob(Dispatchers.Default) { val rollback = repository.setSourcesEnabled(setOf(source), isEnabled) + extRuntime.ensureReady(forceRefresh = true) if (!isEnabled) { - onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) + val synchronizedRollback = + ReversibleHandle { + rollback.reverse() + extRuntime.ensureReady(forceRefresh = true) + } + onActionDone.call(ReversibleAction(R.string.source_disabled, synchronizedRollback)) } } } @@ -125,6 +134,7 @@ class SourcesManageViewModel fun disableAll() { launchJob(Dispatchers.Default) { repository.disableAllSources() + 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 198bb290..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 @@ -1,81 +1,173 @@ 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, + onExtRenameClick: (PluginManageItem.Extension) -> Unit, + onExtLongClick: (PluginManageItem.Extension) -> Unit, + onExtClick: (PluginManageItem.Extension) -> Unit, onLongClick: (PluginManageItem.Plugin) -> Unit, onClick: (PluginManageItem.Plugin) -> Unit, isSelected: (PluginManageItem.Plugin) -> Boolean, + isExtSelected: (PluginManageItem.Extension) -> Boolean, ) : BaseListAdapter() { init { - addDelegate( - ListItemType.CHAPTER_LIST, - pluginItemDelegate(onRenameClick, onUpdateClick, onLongClick, onClick, isSelected), - ) - 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.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.imageViewMenu.setOnClickListener { onRename(item) } 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) - itemView.setOnLongClickListener { - onLongClick(item) + onLong(item) true } - itemView.setOnClickListener { - onClick(item) - } + itemView.setOnClickListener { onClick(item) } bind { - itemView.isSelected = isSelected(item) + 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 { + applyIconShape(binding.imageViewIcon, context) + binding.imageViewIcon.setImageAsync(avatar) + } 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 = + 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 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.imageViewRemove.isVisible = false + binding.imageViewAdd.isVisible = false + itemView.setOnLongClickListener { + onLong(item) + true + } + itemView.setOnClickListener { onClick(item) } + + bind { + 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 { + binding.imageViewIcon.setImageAsync(icon) + } + + binding.imageViewMenu.setOnClickListener { onRename(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 pluginPlaceholderDelegate() = + private fun loadingAD() = + adapterDelegateViewBinding( + { inf, p -> ItemSourceConfigBinding.inflate(inf, p, false) }, + ) { + binding.imageViewMenu.isVisible = false + binding.imageViewRemove.isVisible = false + binding.imageViewAdd.isVisible = false + itemView.isClickable = 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 d = context.resources.displayMetrics.density + binding.imageViewIcon.setImageDrawable(null) + binding.imageViewIcon.background = ShimmerDrawable(context, 8f * d) + binding.textViewTitle.text = " " + binding.textViewTitle.layoutParams.width = (180 * d).toInt() + binding.textViewTitle.background = ShimmerDrawable(context, 4f * d) + binding.textViewDescription.text = " " + binding.textViewDescription.layoutParams.width = (120 * d).toInt() + binding.textViewDescription.background = ShimmerDrawable(context, 4f * d) + } + } + + 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 { @@ -83,4 +175,111 @@ class PluginManageAdapter( binding.textSecondary.setTextAndVisible(item.summaryResId ?: 0) } } + + private class ShimmerDrawable( + context: Context, + private val radius: Float = 8f, + ) : Drawable(), + Animatable, + TimeAnimator.TimeListener { + 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 anim = TimeAnimator().apply { setTimeListener(this@ShimmerDrawable) } + private var phase = 0f + + override fun draw(canvas: Canvas) { + if (!isRunning) start() + 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 + bw, + 0f, + intArrayOf(c1, c2, c1), + floatArrayOf(0f, 0.5f, 1f), + Shader.TileMode.CLAMP, + ) + canvas.drawRoundRect(0f, 0f, w, b.height().toFloat(), radius, radius, paint) + } + + override fun onTimeUpdate( + a: TimeAnimator?, + total: Long, + dt: Long, + ) { + phase = (total % 1200L) / 1200f + invalidateSelf() + } + + override fun start() { + if (!anim.isRunning) anim.start() + } + + override fun stop() { + anim.cancel() + } + + 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(cf: ColorFilter?) { + paint.colorFilter = cf + } + + @Deprecated("Deprecated in Java") + override fun getOpacity() = PixelFormat.TRANSLUCENT + } + + 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 + } + + fun githubAvatarUrl(repo: String): String? = + repoLabel(repo) + .substringBefore('/') + .trim() + .takeIf { it.isNotBlank() } + ?.let { "https://github.com/$it.png" } + + 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 72cabe9b..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 @@ -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 @@ -32,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 @@ -48,16 +48,13 @@ class PluginsManageFragment : private var pluginsAdapter: PluginManageAdapter? = null private val launcher = - registerForActivityResult( - ActivityResultContracts.OpenDocument(), - ) { uri -> + registerForActivityResult(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, ) } } @@ -68,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( @@ -79,10 +76,14 @@ class PluginsManageFragment : pluginsAdapter = PluginManageAdapter( onRenameClick = ::onRenameClick, - onUpdateClick = ::onUpdateClick, - onLongClick = ::onLongClick, - onClick = ::onClick, - isSelected = { item -> viewModel.isSelected(item.name) }, + 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) @@ -90,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) - - binding.fabImport.setOnClickListener { - showImportDialog() - } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBack) + 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() } @@ -129,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, ), ) @@ -140,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) @@ -163,37 +154,36 @@ 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) - binding.buttonDir.setIconResource(R.drawable.ic_open_external) + 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) + 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() - viewModel.import( - askInput = { askText(R.string.import_from_github, "", null) }, - askSelect = ::askSelect, + viewModel.importUrl( + askInput = { askText(R.string.import_from_github, "", R.string.import_github_summary) }, askOverwrite = ::askOverwrite, - onResult = ::showImportResult, ) } dialog.show() @@ -201,84 +191,51 @@ class PluginsManageFragment : 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) { + private fun onExtClick(item: PluginManageItem.Extension) { if (viewModel.selectedPlugins.value.isNotEmpty()) { - viewModel.toggleSelection(item.name) + viewModel.toggleExtensionSelection(item) + } else { + router.openPluginCatalog(item.repositoryUrl, item.displayName) } } - private fun onLongClick(item: PluginManageItem.Plugin) { - viewModel.toggleSelection(item.name) - } - - 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.recyclerView, - if (success) R.string.removal_completed else R.string.load_failed, - Snackbar.LENGTH_SHORT, - ).show() - } - } + setPositiveButton(R.string.delete) { _, _ -> viewModel.delete() } }.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.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.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) - } - dialog.show() + d.setOnCancelListener { if (cont.isActive) cont.resume(false) } + cont.invokeOnCancellation { d.dismiss() } + d.show() } } @@ -290,53 +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()) - } - } - 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) } + setPositiveButton(android.R.string.ok) { _, _ -> if (cont.isActive) cont.resume(input.text?.toString()) } + } + d.setOnCancelListener { if (cont.isActive) cont.resume(null) } + d.setOnDismissListener { input.setCursorVisible(false) } + cont.invokeOnCancellation { + input.post { + input.setCursorVisible(false) + if (d.isShowing) d.dismiss() } - dialog.setOnCancelListener { if (cont.isActive) cont.resume(null) } - dialog.show() + } + d.show() } } - - private fun showImportResult(isSuccess: Boolean) { - val binding = viewBinding ?: return - Snackbar - .make( - binding.recyclerView, - 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 f423ee8c..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 @@ -9,20 +10,41 @@ 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.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.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 @@ -34,40 +56,51 @@ class PluginsManageViewModel private val settings: AppSettings, private val mangaDynamicRepository: MangaDynamicRepository, private val pluginKeyResolver: PluginKeyResolver, + private val directManager: NativeExtManager, + 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 extensionSnapshot = emptyList() - @Volatile - private var query = "" + @Volatile private var query = "" + + @Volatile private var pendingImportUrl: String? = null init { refresh() } fun refresh() { - launchLoadingJob(Dispatchers.Default) { - val localPlugins = loadPluginsLocal() - pluginsSnapshot = localPlugins + launchJob(Dispatchers.Default) { + pluginsSnapshot = loadPluginsLocal() + refreshExtensionItems(catalogProvider.loadSavedCached()) publishFiltered() - 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 { + runCatching { runtime.ensureReady() } + refreshExtensionItems(catalogProvider.loadSaved()) + } + if (pluginsSnapshot.isNotEmpty()) { + launch { + pluginsSnapshot = + coroutineScope { + pluginsSnapshot + .map { p -> + async { + val repo = p.repository ?: return@async p + val tag = updatePluginsProvider.requestTag(repo) ?: return@async p + p.copy(latestTag = tag) + } + }.awaitAll() + } + publishFiltered() + } } } } @@ -79,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, @@ -108,145 +123,304 @@ 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() } - fun importPlugin( uri: Uri, getOriginalName: (Uri) -> String?, askName: suspend (String) -> String?, askOverwrite: suspend (String) -> Boolean, - onResult: (Boolean) -> Unit, ) { launchJob(Dispatchers.Default) { - val originalName = getOriginalName(uri) ?: "plugin_${System.currentTimeMillis()}.jar" - val pluginName = askName(originalName.removeSuffix(".jar"))?.trim().orEmpty() - if (pluginName.isBlank()) return@launchJob - - val fileName = PluginFileLoader.resolve(pluginName) - if (isInstalled(fileName) && !askOverwrite(fileName)) return@launchJob - - val success = importFromUri(uri, fileName) - withContext(Dispatchers.Main) { onResult(success) } + 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) + if (directManager.installLocal(tempFile, rawName)) { + runtime.ensureReady(forceRefresh = true) + refresh() + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob + } + } finally { + tempFile.delete() + } + 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) + }, + ) } } - fun import( + 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 - } - - 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 + pendingImportUrl = input + publishFiltered() + try { + if (updatePluginsProvider.importFromUrl(input)) { + refresh() + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob } - - val name = PluginFileLoader.resolve(select.fileName) - if (isInstalled(name) && !askOverwrite(name)) return@launchJob - - val success = importFromGithub(select, name) - withContext(Dispatchers.Main) { onResult(success) } + 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() + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob + } + } + val artifacts = catalogProvider.load(input) + if (artifacts.isNotEmpty()) { + artifacts.forEach { catalogProvider.restorePackage(it.packageName) } + catalogProvider.saveRepository(input) + refreshExtensionItems(catalogProvider.loadSaved() + artifacts) + operationResult.call(OperationResult.Success(R.string.load_success)) + return@launchJob + } + operationResult.call(OperationResult.Failure(R.string.load_failed)) + } finally { + pendingImportUrl = null + publishFiltered() + } } } - 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) { + fun renameExtension( + item: PluginManageItem.Extension, + name: String, + ) { + launchJob(Dispatchers.Default) { + catalogProvider.setRepositoryName(item.repositoryUrl, name) + DirectExternalPluginMetadata.update(directManager.installed.value) { + catalogProvider.repositoryName(it) + } refresh() - true - } else { - importFromGithub(release, item.name) + operationResult.call(OperationResult.Success(R.string.load_success)) + } + } + + 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 toggleExtensionSelection(item: PluginManageItem.Extension) = toggleSelection(PREFIX + item.repositoryUrl) + + 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 - for (jar in select) { - try { - mangaDynamicRepository.deletePlugin(jar) - updatePluginsProvider.clearDto(jar) - } catch (_: Throwable) { - allSuccess = false + if (select.isEmpty()) return@launchJob + var ok = true + var hasLocal = false + for (key in select) { + 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(url) + } else { + hasLocal = true + try { + mangaDynamicRepository.deletePlugin(key) + updatePluginsProvider.clearDto(key) + } catch (_: Throwable) { + ok = false + } } } selectedPlugins.value = emptySet() - reloadPlugins(mangaDynamicRepository.getDir()) - 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) = File(mangaDynamicRepository.getDir(), PluginFileLoader.resolve(fileName)).exists() + + 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 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" + } + 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 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) - fun isInstalled(fileName: String): Boolean = File(mangaDynamicRepository.getDir(), PluginFileLoader.resolve(fileName)).exists() + private fun canonicalRepo(v: String) = catalogProvider.normalizeUrl(v) ?: v.trim().removeSuffix("/") private fun publishFiltered() { - val all = pluginsSnapshot + val all = + buildList { + pendingImportUrl?.let { add(PluginManageItem.Loading(it)) } + addAll(pluginsSnapshot) + addAll(extensionSnapshot) + } 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 @@ -254,35 +428,47 @@ class PluginsManageViewModel content.value = all 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.Extension -> { + item.displayName.contains(q, true) || + item.repositoryLabel.contains(q, true) || + item.repositoryUrl.contains(q, true) + } + + is PluginManageItem.Placeholder -> { + false + } + } + }.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 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 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 fc119f08..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,11 +39,11 @@ 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) - } + private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } suspend fun runAutoUpdate(settings: AppSettings) { if (!settings.isAutoPluginsEnabled || !mutex.tryLock()) return @@ -51,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() @@ -88,12 +99,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 } @@ -108,77 +117,94 @@ class UpdatePluginsProvider ): 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 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", true) || trimmed.contains(".jar?", true)) { + val rawName = trimmed.substringBefore('?').substringAfterLast('/') + val safeName = PluginFileLoader.resolve(rawName) + val dest = File(mangaDynamicRepository.getDir(), safeName) + return runCatchingCancellable { + val req = + Request + .Builder() + .url(trimmed) + .header("User-Agent", "Usagi-PluginDownloader/1.0") + .build() + okHttpClient.newCall(req).await().use { resp -> + if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") + PluginFileLoader.copyFromStream(dest, resp.body.byteStream()) + } + reloadPlugins(mangaDynamicRepository.getDir()) + true + }.getOrDefault(false) + } + return false } 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]}" } + 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> { @@ -188,9 +214,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 } @@ -200,23 +224,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() - PluginFileLoader.copyFromStream(dest, response.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 } @@ -225,27 +247,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) { @@ -256,9 +269,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()) @@ -266,11 +277,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() } @@ -279,16 +288,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 { @@ -302,5 +304,10 @@ 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, + ) } } 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..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,6 +1,9 @@ package org.draken.usagi.settings.sources.manage.plugins.model import androidx.annotation.StringRes +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 sealed interface PluginManageItem : ListModel { @@ -19,10 +22,34 @@ sealed interface PluginManageItem : ListModel { override fun areItemsTheSame(other: ListModel): Boolean = other is Plugin && name == other.name } + data class Extension( + val repositoryUrl: String, + 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 hasFailures: Boolean + get() = failures.isNotEmpty() + + override fun areItemsTheSame(other: ListModel): Boolean = other is Extension && 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/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/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"> 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 9301de78..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,9 +51,9 @@ testRules = "1.7.0" testRunner = "1.7.0" transition = "1.6.0" tsuki = "1.0.5" -tsukimix = "1.0.5" +tsukimix = "1.0.6" usagiRpc = "1.0.2" -usagi-serialization = "ad8b778c88" +usagi-serialization = "01254ce6f1" viewpager2 = "1.1.0" #noinspection GradleDependency webkit = "1.14.0"