From 14e669e40ccb3bf1707f2e1cc65363b28a3454dc Mon Sep 17 00:00:00 2001 From: nzoba <55888232+nzoba@users.noreply.github.com> Date: Mon, 15 Jan 2024 21:40:12 +0100 Subject: [PATCH] Add extension repos (#1719) * Add custom repo setting * Few fixes custom repo * Change controller to view * Improve extensions * Allow permanently trusting unofficial extensions by version code + signature * Add advanced setting to revoke all trusted unknown extensions * Fix crash when IO context --- app/src/main/AndroidManifest.xml | 10 + .../java/eu/kanade/tachiyomi/AppModule.kt | 3 + .../java/eu/kanade/tachiyomi/Migrations.kt | 5 + .../data/preference/PreferencesHelper.kt | 7 +- .../tachiyomi/extension/ExtensionManager.kt | 56 ++--- .../tachiyomi/extension/ExtensionUpdateJob.kt | 4 +- ...{ExtensionGithubApi.kt => ExtensionApi.kt} | 77 +++---- .../tachiyomi/extension/model/Extension.kt | 11 +- .../extension/util/ExtensionLoader.kt | 27 +-- .../extension/util/TrustExtension.kt | 31 +++ .../tachiyomi/ui/category/CategoryHolder.kt | 47 ++-- .../ui/extension/ExtensionBottomPresenter.kt | 4 +- .../ui/extension/ExtensionBottomSheet.kt | 6 +- .../tachiyomi/ui/extension/ExtensionHolder.kt | 18 +- .../ui/extension/ExtensionTrustDialog.kt | 8 +- .../details/ExtensionDetailsController.kt | 53 ----- .../details/ExtensionDetailsHeaderAdapter.kt | 5 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 14 +- .../ui/setting/SettingsAdvancedController.kt | 11 + .../ui/setting/SettingsBrowseController.kt | 10 + .../ui/source/browse/repos/InfoRepoMessage.kt | 65 ++++++ .../ui/source/browse/repos/RepoAdapter.kt | 38 ++++ .../ui/source/browse/repos/RepoController.kt | 203 ++++++++++++++++++ .../ui/source/browse/repos/RepoHolder.kt | 137 ++++++++++++ .../ui/source/browse/repos/RepoItem.kt | 68 ++++++ .../ui/source/browse/repos/RepoPresenter.kt | 100 +++++++++ .../eu/kanade/tachiyomi/util/CrashLogUtil.kt | 5 +- app/src/main/res/layout/info_repo_message.xml | 48 +++++ app/src/main/res/menu/extension_details.xml | 12 -- app/src/main/res/values/strings.xml | 22 +- 30 files changed, 877 insertions(+), 228 deletions(-) rename app/src/main/java/eu/kanade/tachiyomi/extension/api/{ExtensionGithubApi.kt => ExtensionApi.kt} (66%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/extension/util/TrustExtension.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/InfoRepoMessage.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoPresenter.kt create mode 100644 app/src/main/res/layout/info_repo_message.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f259297e0971..5f08f9452fb9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,16 @@ + + + + + + + + + + = try { api.findExtensions() } catch (e: Exception) { - Timber.e(e) + Timber.e(e, context.getString(R.string.extension_api_error)) + withUIContext { context.toast(R.string.extension_api_error) } emptyList() } - enableAdditionalSubLanguages(extensions) _availableExtensionsFlow.value = extensions @@ -198,14 +203,17 @@ class ExtensionManager( val pkgName = installedExt.pkgName val availableExt = availableExtensions.find { it.pkgName == pkgName } - if (!installedExt.isUnofficial && availableExt == null != installedExt.isObsolete) { + if (availableExt == null != installedExt.isObsolete) { mutInstalledExtensions[index] = installedExt.copy(isObsolete = true) changed = true } if (availableExt != null) { val hasUpdate = installedExt.updateExists(availableExt) if (installedExt.hasUpdate != hasUpdate) { - mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate) + mutInstalledExtensions[index] = installedExt.copy( + hasUpdate = hasUpdate, + repoUrl = availableExt.repoUrl, + ) hasUpdateCount++ changed = true } @@ -303,34 +311,30 @@ class ExtensionManager( } /** - * Adds the given signature to the list of trusted signatures. It also loads in background the - * extensions that match this signature. + * Adds the given extension to the list of trusted extensions. It also loads in background the + * now trusted extensions. * - * @param signature The signature to whitelist. + * @param pkgName the package name of the extension to trust + * @param versionCode the version code of the extension to trust + * @param signatureHash the signature hash of the extension to trust */ - fun trustSignature(signature: String) { - val untrustedSignatures = untrustedExtensionsFlow.value.map { it.signatureHash }.toSet() - if (signature !in untrustedSignatures) return + fun trust(pkgName: String, versionCode: Long, signatureHash: String) { + val untrustedPkgNames = untrustedExtensionsFlow.value.map { it.pkgName }.toSet() + if (pkgName !in untrustedPkgNames) return - ExtensionLoader.trustedSignatures += signature - val preference = preferences.trustedSignatures() - preference.set(preference.get() + signature) + trustExtension.trust(pkgName, versionCode, signatureHash) - val nowTrustedExtensions = untrustedExtensionsFlow.value.filter { it.signatureHash == signature } + val nowTrustedExtensions = untrustedExtensionsFlow.value + .filter { it.pkgName == pkgName && it.versionCode == versionCode } _untrustedExtensionsFlow.value -= nowTrustedExtensions - val ctx = context launchNow { nowTrustedExtensions .map { extension -> - async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) } - } - .map { it.await() } - .forEach { result -> - if (result is LoadResult.Success) { - registerNewExtension(result.extension) - } + async { ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName) }.await() } + .filterIsInstance() + .forEach { registerNewExtension(it.extension) } } } @@ -422,7 +426,7 @@ class ExtensionManager( private fun Extension.Installed.updateExists(availableExtension: Extension.Available? = null): Boolean { val availableExt = availableExtension ?: availableExtensionsFlow.value.find { it.pkgName == pkgName } - if (isUnofficial || availableExt == null) return false + ?: return false return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion) } @@ -435,6 +439,7 @@ class ExtensionManager( val name: String, val versionCode: Long, val libVersion: Double, + val repoUrl: String, ) : Parcelable { constructor(extension: Extension.Available) : this( apkName = extension.apkName, @@ -442,6 +447,7 @@ class ExtensionManager( name = extension.name, versionCode = extension.versionCode, libVersion = extension.libVersion, + repoUrl = extension.repoUrl, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt index 1a80bb6024bf..7774dad1667f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt @@ -24,7 +24,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob -import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionLoader @@ -44,7 +44,7 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam override suspend fun doWork(): Result = coroutineScope { val pendingUpdates = try { - ExtensionGithubApi().checkForUpdates(context) + ExtensionApi().checkForUpdates(context) } catch (e: Exception) { return@coroutineScope Result.failure() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt similarity index 66% rename from app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt rename to app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index d072c8b99961..8996264a4db8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -1,13 +1,14 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.system.withIOContext import kotlinx.serialization.Serializable @@ -17,44 +18,19 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy -internal class ExtensionGithubApi { +internal class ExtensionApi { private val json: Json by injectLazy() private val networkService: NetworkHelper by injectLazy() - - private var requiresFallbackSource = false + private val preferences: PreferencesHelper by injectLazy() suspend fun findExtensions(): List { return withIOContext { - val githubResponse = if (requiresFallbackSource) { - null - } else { - try { - networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .await() - } catch (e: Throwable) { - Timber.e(e, "Failed to get extensions from GitHub") - requiresFallbackSource = true - null - } - } - - val response = githubResponse ?: run { - networkService.client - .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) - .await() - } - - val extensions = with(json) { - response - .parseAs>() - .toExtensions() - } + val extensions = preferences.extensionRepos().get().flatMap { getExtensions(it) } // Sanity check - a small number of extensions probably means something broke // with the repo generator - if (extensions.size < 100) { + if (extensions.isEmpty()) { throw Exception() } @@ -62,6 +38,23 @@ internal class ExtensionGithubApi { } } + private suspend fun getExtensions(repoBaseUrl: String): List { + return try { + val response = networkService.client + .newCall(GET("$repoBaseUrl/index.min.json")) + .awaitSuccess() + + with(json) { + response + .parseAs>() + .toExtensions(repoBaseUrl) + } + } catch (e: Throwable) { + Timber.e(e, "Failed to get extensions from $repoBaseUrl") + emptyList() + } + } + suspend fun checkForUpdates(context: Context, prefetchedExtensions: List? = null): List { return withIOContext { val extensions = prefetchedExtensions ?: findExtensions() @@ -79,7 +72,7 @@ internal class ExtensionGithubApi { val availableExt = extensions.find { it.pkgName == pkgName } ?: continue val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode val hasUpdatedLib = availableExt.libVersion > installedExt.libVersion - val hasUpdate = installedExt.isUnofficial.not() && (hasUpdatedVer || hasUpdatedLib) + val hasUpdate = hasUpdatedVer || hasUpdatedLib if (hasUpdate) { extensionsWithUpdate.add(availableExt) } @@ -89,7 +82,7 @@ internal class ExtensionGithubApi { } } - private fun List.toExtensions(): List { + private fun List.toExtensions(repoUrl: String): List { return this .filter { val libVersion = it.extractLibVersion() @@ -104,25 +97,16 @@ internal class ExtensionGithubApi { libVersion = it.extractLibVersion(), lang = it.lang, isNsfw = it.nsfw == 1, - hasReadme = it.hasReadme == 1, - hasChangelog = it.hasChangelog == 1, sources = it.sources ?: emptyList(), apkName = it.apk, - iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", + iconUrl = "$repoUrl/icon/${it.pkg}.png", + repoUrl = repoUrl, ) } } fun getApkUrl(extension: ExtensionManager.ExtensionInfo): String { - return "${getUrlPrefix()}apk/${extension.apkName}" - } - - private fun getUrlPrefix(): String { - return if (requiresFallbackSource) { - FALLBACK_REPO_URL_PREFIX - } else { - REPO_URL_PREFIX - } + return "${extension.repoUrl}/apk/${extension.apkName}" } private fun ExtensionJsonObject.extractLibVersion(): Double { @@ -130,9 +114,6 @@ internal class ExtensionGithubApi { } } -private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/" -private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/" - @Serializable private data class ExtensionJsonObject( val name: String, @@ -142,7 +123,5 @@ private data class ExtensionJsonObject( val code: Long, val version: String, val nsfw: Int, - val hasReadme: Int = 0, - val hasChangelog: Int = 0, val sources: List?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index aaedfa516787..ba20b6a544e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -13,8 +13,6 @@ sealed class Extension { abstract val libVersion: Double abstract val lang: String? abstract val isNsfw: Boolean - abstract val hasReadme: Boolean - abstract val hasChangelog: Boolean data class Installed( override val name: String, @@ -24,15 +22,13 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, - override val hasReadme: Boolean, - override val hasChangelog: Boolean, val pkgFactory: String?, val sources: List, val icon: Drawable?, val hasUpdate: Boolean = false, val isObsolete: Boolean = false, - val isUnofficial: Boolean = false, val isShared: Boolean, + val repoUrl: String? = null, ) : Extension() data class Available( @@ -43,11 +39,10 @@ sealed class Extension { override val libVersion: Double, override val lang: String, override val isNsfw: Boolean, - override val hasReadme: Boolean, - override val hasChangelog: Boolean, val apkName: String, val iconUrl: String, val sources: List, + val repoUrl: String, ) : Extension() @Serializable @@ -67,7 +62,5 @@ sealed class Extension { val signatureHash: String, override val lang: String? = null, override val isNsfw: Boolean = false, - override val hasReadme: Boolean = false, - override val hasChangelog: Boolean = false, ) : Extension() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index 1aca9155ff45..d5c2b108e37d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -33,6 +33,7 @@ import java.nio.file.attribute.BasicFileAttributes internal object ExtensionLoader { private val preferences: PreferencesHelper by injectLazy() + private val trustExtension: TrustExtension by injectLazy() private val loadNsfwSource by lazy { preferences.showNsfwSources().get() } @@ -41,8 +42,6 @@ internal object ExtensionLoader { private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" private const val METADATA_NSFW = "tachiyomi.extension.nsfw" - private const val METADATA_HAS_README = "tachiyomi.extension.hasReadme" - private const val METADATA_HAS_CHANGELOG = "tachiyomi.extension.hasChangelog" const val LIB_VERSION_MIN = 1.3 const val LIB_VERSION_MAX = 1.5 @@ -52,14 +51,6 @@ internal object ExtensionLoader { PackageManager.GET_SIGNATURES or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0) - // inorichi's key - private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" - - /** - * List of the trusted signatures. - */ - var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() - private const val PRIVATE_EXTENSION_EXTENSION = "ext" private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") @@ -307,7 +298,7 @@ internal object ExtensionLoader { if (signatures.isNullOrEmpty()) { Timber.w("Package $pkgName isn't signed") return LoadResult.Error - } else if (!hasTrustedSignature(signatures)) { + } else if (!isTrusted(pkgInfo, signatures)) { val extension = Extension.Untrusted( extName, pkgName, @@ -326,9 +317,6 @@ internal object ExtensionLoader { return LoadResult.Error } - val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1 - val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1 - val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!! @@ -371,11 +359,8 @@ internal object ExtensionLoader { libVersion = libVersion, lang = lang, isNsfw = isNsfw, - hasReadme = hasReadme, - hasChangelog = hasChangelog, sources = sources, pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), - isUnofficial = !isOfficiallySigned(signatures), icon = appInfo.loadIcon(pkgManager), isShared = extensionInfo.isShared, ) @@ -437,12 +422,8 @@ internal object ExtensionLoader { ?.toList() } - private fun hasTrustedSignature(signatures: List): Boolean { - return trustedSignatures.any { signatures.contains(it) } - } - - private fun isOfficiallySigned(signatures: List): Boolean { - return signatures.all { it == officialSignature } + private fun isTrusted(pkgInfo: PackageInfo, signatures: List): Boolean { + return trustExtension.isTrusted(pkgInfo, signatures.last()) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/TrustExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/TrustExtension.kt new file mode 100644 index 000000000000..e15db1f0c011 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/TrustExtension.kt @@ -0,0 +1,31 @@ +package eu.kanade.tachiyomi.extension.util + +import android.content.pm.PackageInfo +import androidx.core.content.pm.PackageInfoCompat +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class TrustExtension( + private val preferences: PreferencesHelper = Injekt.get(), +) { + + fun isTrusted(pkgInfo: PackageInfo, signatureHash: String): Boolean { + val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:$signatureHash" + return key in preferences.trustedExtensions().get() + } + + fun trust(pkgName: String, versionCode: Long, signatureHash: String) { + preferences.trustedExtensions().let { exts -> + // Remove previously trusted versions + val removed = exts.get().filterNot { it.startsWith("$pkgName:") }.toMutableSet() + + removed += "$pkgName:$versionCode:$signatureHash" + exts.set(removed) + } + } + + fun revokeAll() { + preferences.trustedExtensions().delete() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt index 23c21ecadc04..51f5b4cf8c7a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt @@ -53,22 +53,14 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie createCategory = category.order == CREATE_CATEGORY_ORDER if (createCategory) { binding.title.setTextColor(ContextCompat.getColor(itemView.context, R.color.material_on_background_disabled)) - regularDrawable = ContextCompat.getDrawable( - itemView.context, - R.drawable - .ic_add_24dp, - ) + regularDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_add_24dp) binding.image.isVisible = false binding.editButton.setImageDrawable(null) binding.editText.setText("") binding.editText.hint = binding.title.text } else { binding.title.setTextColor(itemView.context.getResourceColor(R.attr.colorOnBackground)) - regularDrawable = ContextCompat.getDrawable( - itemView.context, - R.drawable - .ic_drag_handle_24dp, - ) + regularDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_drag_handle_24dp) binding.image.isVisible = true binding.editText.setText(binding.title.text) } @@ -87,13 +79,11 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie showKeyboard() if (!createCategory) { binding.reorder.setImageDrawable( - ContextCompat.getDrawable( - itemView.context, - R.drawable.ic_delete_24dp, - ), + ContextCompat.getDrawable(itemView.context, R.drawable.ic_delete_24dp), ) binding.reorder.setOnClickListener { adapter.categoryItemListener.onItemDelete(flexibleAdapterPosition) + hideKeyboard() } } } else { @@ -106,20 +96,18 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie } binding.editText.clearFocus() binding.editButton.drawable?.mutate()?.setTint( - ContextCompat.getColor( - itemView.context, - R - .color.gray_button, - ), + ContextCompat.getColor(itemView.context, R.color.gray_button), ) binding.reorder.setImageDrawable(regularDrawable) } } private fun submitChanges() { - if (binding.editText.visibility == View.VISIBLE) { - if (adapter.categoryItemListener - .onCategoryRename(flexibleAdapterPosition, binding.editText.text.toString()) + if (binding.editText.isVisible) { + if (adapter.categoryItemListener.onCategoryRename( + flexibleAdapterPosition, + binding.editText.text.toString(), + ) ) { isEditing(false) if (!createCategory) { @@ -129,16 +117,17 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie } else { itemView.performClick() } + hideKeyboard() } private fun showKeyboard() { - val inputMethodManager: InputMethodManager = - itemView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.showSoftInput( - binding.editText, - WindowManager.LayoutParams - .SOFT_INPUT_ADJUST_PAN, - ) + val inputMethodManager = itemView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.showSoftInput(binding.editText, WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + } + + private fun hideKeyboard() { + val inputMethodManager = itemView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(binding.editText.windowToken, 0) } override fun onActionStateChanged(position: Int, actionState: Int) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt index 17ae56178244..6db5441b947d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt @@ -274,7 +274,7 @@ class ExtensionBottomPresenter : BaseMigrationPresenter() } } - fun trustSignature(signatureHash: String) { - extensionManager.trustSignature(signatureHash) + fun trustExtension(pkgName: String, versionCode: Long, signatureHash: String) { + extensionManager.trust(pkgName, versionCode, signatureHash) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt index 5e578d8ee589..8fd11be57efa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt @@ -323,7 +323,7 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At } private fun openTrustDialog(extension: Extension.Untrusted) { - ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName) + ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName, extension.versionCode) .showDialog(controller.router) } @@ -407,8 +407,8 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At extAdapter?.updateItem(updateHeader) } - override fun trustSignature(signatureHash: String) { - presenter.trustSignature(signatureHash) + override fun trustExtension(pkgName: String, versionCode: Long, signatureHash: String) { + presenter.trustExtension(pkgName, versionCode, signatureHash) } override fun uninstallExtension(pkgName: String) { presenter.uninstallExtension(pkgName) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt index 88d9ca381ddd..5bd7f22fbbef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt @@ -93,12 +93,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : binding.version.text = infoText.joinToString(" • ") binding.lang.text = LocaleHelper.getDisplayName(extension.lang) binding.warning.text = when { - extension is Extension.Untrusted -> itemView.context.getString(R.string.untrusted) - extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.unofficial) - extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.obsolete) extension.isNsfw -> itemView.context.getString(R.string.nsfw_short) else -> "" - }.uppercase(Locale.ROOT) + }.plusRepo(extension).uppercase(Locale.ROOT) binding.installProgress.progress = item.sessionProgress ?: 0 binding.installProgress.isVisible = item.sessionProgress != null binding.cancelButton.isVisible = item.sessionProgress != null @@ -115,6 +112,19 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : bindButton(item) } + private fun String.plusRepo(extension: Extension): String { + val repoText = when { + extension is Extension.Untrusted -> itemView.context.getString(R.string.untrusted) + extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.obsolete) + else -> "" + } + return if (isEmpty()) { + this + } else { + "$this • " + } + repoText + } + @Suppress("ResourceType") fun bindButton(item: ExtensionItem) = with(binding.extButton) { if (item.installStep == InstallStep.Done) return@with diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt index 265c0df66b69..a10b3bddc200 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt @@ -10,10 +10,11 @@ class ExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) where T : ExtensionTrustDialog.Listener { lateinit var listener: Listener - constructor(target: T, signatureHash: String, pkgName: String) : this( + constructor(target: T, signatureHash: String, pkgName: String, versionCode: Long) : this( Bundle().apply { putString(SIGNATURE_KEY, signatureHash) putString(PKGNAME_KEY, pkgName) + putLong(VERSION_CODE, versionCode) }, ) { listener = target @@ -24,7 +25,7 @@ class ExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) .setTitle(R.string.untrusted_extension) .setMessage(R.string.untrusted_extension_message) .setPositiveButton(R.string.trust) { _, _ -> - listener.trustSignature(args.getString(SIGNATURE_KEY)!!) + listener.trustExtension(args.getString(PKGNAME_KEY)!!, args.getLong(VERSION_CODE), args.getString(SIGNATURE_KEY)!!) } .setNegativeButton(R.string.uninstall) { _, _ -> listener.uninstallExtension(args.getString(PKGNAME_KEY)!!) @@ -34,10 +35,11 @@ class ExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) private companion object { const val SIGNATURE_KEY = "signature_key" const val PKGNAME_KEY = "pkgname_key" + const val VERSION_CODE = "version_code" } interface Listener { - fun trustSignature(signatureHash: String) + fun trustExtension(pkgName: String, versionCode: Long, signatureHash: String) fun uninstallExtension(pkgName: String) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsController.kt index fd834c50f68f..3756877f0cde 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsController.kt @@ -40,8 +40,6 @@ import eu.kanade.tachiyomi.ui.setting.defaultValue import eu.kanade.tachiyomi.ui.setting.onChange import eu.kanade.tachiyomi.ui.setting.switchPreference import eu.kanade.tachiyomi.util.system.LocaleHelper -import eu.kanade.tachiyomi.util.system.contextCompatDrawable -import eu.kanade.tachiyomi.util.view.openInBrowser import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.widget.LinearLayoutManagerAccurateOffset @@ -153,66 +151,15 @@ class ExtensionDetailsController(bundle: Bundle? = null) : override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.extension_details, menu) - - presenter.extension?.let { extension -> - menu.findItem(R.id.action_history).isVisible = !extension.isUnofficial - menu.findItem(R.id.action_readme).isVisible = !extension.isUnofficial - if (extension.hasReadme) { - menu.findItem(R.id.action_readme).icon = view?.context?.contextCompatDrawable(R.drawable.ic_help_24dp) - } - } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_history -> openChangelog() - R.id.action_readme -> openReadme() R.id.action_clear_cookies -> clearCookies() } return super.onOptionsItemSelected(item) } - private fun openChangelog() { - val extension = presenter.extension!! - val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") - val pkgFactory = extension.pkgFactory - if (extension.hasChangelog) { - val url = createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md") - openInBrowser(url) - return - } - - // Falling back on GitHub commit history because there is no explicit changelog in extension - val url = createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory) - openInBrowser(url) - } - - private fun createUrl(url: String, pkgName: String, pkgFactory: String?, path: String = ""): String { - return if (!pkgFactory.isNullOrEmpty()) { - when (path.isEmpty()) { - true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory" - else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path - } - } else { - url + "/src/" + pkgName.replace(".", "/") + path - } - } - - private fun openReadme() { - val extension = presenter.extension!! - - if (!extension.hasReadme) { - openInBrowser("https://tachiyomi.org/docs/faq/browse/extensions") - return - } - - val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") - val pkgFactory = extension.pkgFactory - val url = createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md") - openInBrowser(url) - return - } - private fun clearCookies() { val urls = presenter.extension?.sources ?.filterIsInstance() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsHeaderAdapter.kt index 569fc1dcebef..387680b8b837 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsHeaderAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/details/ExtensionDetailsHeaderAdapter.kt @@ -75,10 +75,7 @@ class ExtensionDetailsHeaderAdapter(private val presenter: ExtensionDetailsPrese binding.extensionUninstallButton.text = context.getString(R.string.remove) } - if (extension.isUnofficial) { - binding.extensionWarningBanner.isVisible = true - binding.extensionWarningBanner.setText(R.string.unofficial_extension_message) - } else if (extension.isObsolete) { + if (extension.isObsolete) { binding.extensionWarningBanner.isVisible = true binding.extensionWarningBanner.setText(R.string.obsolete_extension_message) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 26e4de7950cd..1e70b6b98154 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -85,7 +85,7 @@ import eu.kanade.tachiyomi.data.updater.AppUpdateResult import eu.kanade.tachiyomi.data.updater.RELEASE_URL import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.extension.ExtensionManager -import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface @@ -104,6 +104,7 @@ import eu.kanade.tachiyomi.ui.setting.SettingsController import eu.kanade.tachiyomi.ui.setting.SettingsMainController import eu.kanade.tachiyomi.ui.source.BrowseController import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.source.browse.repos.RepoController import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata import eu.kanade.tachiyomi.util.manga.MangaShortcutManager import eu.kanade.tachiyomi.util.system.contextCompatDrawable @@ -955,7 +956,7 @@ open class MainActivity : BaseActivity() { lifecycleScope.launch(Dispatchers.IO) { try { extensionManager.findAvailableExtensions() - val pendingUpdates = ExtensionGithubApi().checkForUpdates( + val pendingUpdates = ExtensionApi().checkForUpdates( this@MainActivity, extensionManager.availableExtensionsFlow.value.takeIf { it.isNotEmpty() }, ) @@ -1053,6 +1054,15 @@ open class MainActivity : BaseActivity() { controller?.showSheet() } } + Intent.ACTION_VIEW -> { + // Deep link to add extension repo + if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") { + intent.data?.getQueryParameter("url")?.let { repoUrl -> + router.popToRoot() + router.pushController(RepoController(repoUrl).withFadeTransaction()) + } + } + } else -> return false } return true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 8ccb0d1a1993..14990ae2528d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -29,6 +29,7 @@ import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn import eu.kanade.tachiyomi.extension.ShizukuInstaller import eu.kanade.tachiyomi.extension.util.ExtensionInstaller +import eu.kanade.tachiyomi.extension.util.TrustExtension import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.PREF_DOH_360 import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD @@ -78,6 +79,8 @@ class SettingsAdvancedController : SettingsController() { private val downloadManager: DownloadManager by injectLazy() + val trustExtension: TrustExtension by injectLazy() + private val isUpdaterEnabled = BuildConfig.INCLUDE_UPDATER @SuppressLint("BatteryLife") @@ -369,6 +372,14 @@ class SettingsAdvancedController : SettingsController() { it != ExtensionInstaller.PACKAGE_INSTALLER && Build.VERSION.SDK_INT < Build.VERSION_CODES.S } } + preference { + titleRes = R.string.ext_revoke_trust + + onClick { + trustExtension.revokeAll() + activity?.toast(R.string.requires_app_restart) + } + } } preferenceCategory { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt index d1566231800e..bd09483b658f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt @@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.migration.MigrationController +import eu.kanade.tachiyomi.ui.source.browse.repos.RepoController import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.withFadeTransaction import uy.kohesive.injekt.injectLazy @@ -50,6 +51,15 @@ class SettingsBrowseController : SettingsController() { true } } + preference { + key = "pref_edit_extension_repos" + + val repoCount = preferences.extensionRepos().get().count() + titleRes = R.string.extension_repos + if (repoCount > 0) summary = context.resources.getQuantityString(R.plurals.num_repos, repoCount, repoCount) + + onClick { router.pushController(RepoController().withFadeTransaction()) } + } if (ExtensionManager.canAutoInstallUpdates()) { val intPref = intListPreference(activity) { key = PreferenceKeys.autoUpdateExtensions diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/InfoRepoMessage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/InfoRepoMessage.kt new file mode 100644 index 000000000000..a9733f1d2aaf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/InfoRepoMessage.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.ui.source.browse.repos + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder + +class InfoRepoMessage : AbstractFlexibleItem() { + + /** + * Returns the layout resource for this item. + */ + override fun getLayoutRes(): Int { + return R.layout.info_repo_message + } + + /** + * Returns a new view holder for this item. + * + * @param view The view of this item. + * @param adapter The adapter of this item. + */ + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter>, + ): Holder { + return Holder(view, adapter) + } + + /** + * Binds the given view holder with this item. + * + * @param adapter The adapter of this item. + * @param holder The holder to bind. + * @param position The position of this item in the adapter. + * @param payloads List of partial changes. + */ + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: Holder, + position: Int, + payloads: MutableList, + ) { + } + + /** + * Returns true if this item is draggable. + */ + override fun isDraggable(): Boolean = false + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is InfoRepoMessage + } + + override fun hashCode(): Int { + return "Info repo message".hashCode() + } + + class Holder(val view: View, adapter: FlexibleAdapter>) : + BaseFlexibleViewHolder(view, adapter, true) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoAdapter.kt new file mode 100644 index 000000000000..4f9859e186b3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoAdapter.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.ui.source.browse.repos + +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem + +/** + * Custom adapter for repos. + * + * @param controller The containing controller. + */ +class RepoAdapter(controller: RepoController) : + FlexibleAdapter>(null, controller, true) { + + /** + * Listener called when an item of the list is released. + */ + val repoItemListener: RepoItemListener = controller + + /** + * Clears the active selections from the model. + */ + fun resetEditing(position: Int) { + for (i in 0..itemCount) { + (getItem(i) as? RepoItem)?.isEditing = false + } + (getItem(position) as? RepoItem)?.isEditing = true + notifyDataSetChanged() + } + + interface RepoItemListener { + /** + * Called when an item of the list is released. + */ + fun onLogoClick(position: Int) + fun onRepoRename(position: Int, newName: String): Boolean + fun onItemDelete(position: Int) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoController.kt new file mode 100644 index 000000000000..4a0e56cc7f6d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoController.kt @@ -0,0 +1,203 @@ +package eu.kanade.tachiyomi.ui.source.browse.repos + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import androidx.core.net.toUri +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding +import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface +import eu.kanade.tachiyomi.ui.base.controller.BaseController +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.system.isOnline +import eu.kanade.tachiyomi.util.system.materialAlertDialog +import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.liftAppbarWith +import eu.kanade.tachiyomi.util.view.snack + +/** + * Controller to manage the repos for the user's extensions. + */ +class RepoController(bundle: Bundle? = null) : + BaseController(bundle), + FlexibleAdapter.OnItemClickListener, + SmallToolbarInterface, + RepoAdapter.RepoItemListener { + + constructor(repoUrl: String) : this( + Bundle().apply { + putString(REPO_URL, repoUrl) + }, + ) { + presenter.createRepo(repoUrl) + } + + /** + * Adapter containing repo items. + */ + private var adapter: RepoAdapter? = null + + /** + * Undo helper used for restoring a deleted repo. + */ + private var snack: Snackbar? = null + + /** + * Creates the presenter for this controller. Not to be manually called. + */ + private val presenter = RepoPresenter(this) + + /** + * Returns the toolbar title to show when this controller is attached. + */ + override fun getTitle(): String? { + return resources?.getString(R.string.extension_repos) + } + + override fun createBinding(inflater: LayoutInflater) = CategoriesControllerBinding.inflate(inflater) + + /** + * Called after view inflation. Used to initialize the view. + * + * @param view The view of this controller. + */ + override fun onViewCreated(view: View) { + super.onViewCreated(view) + liftAppbarWith(binding.recycler, true) + + adapter = RepoAdapter(this@RepoController) + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.setHasFixedSize(true) + binding.recycler.adapter = adapter + adapter?.isPermanentDelete = false + + presenter.getRepos() + } + + /** + * Called when the view is being destroyed. Used to release references and remove callbacks. + * + * @param view The view of this controller. + */ + override fun onDestroyView(view: View) { + // Manually call callback to delete repos if required + snack?.dismiss() + view.clearFocus() + confirmDelete() + snack = null + adapter = null + super.onDestroyView(view) + } + + override fun handleBack(): Boolean { + view?.clearFocus() + confirmDelete() + return super.handleBack() + } + + /** + * Called from the presenter when the repos are updated. + * + */ + fun updateRepos() { + adapter?.updateDataSet(presenter.getReposWithCreate()) + adapter?.addItem(0, InfoRepoMessage()) + } + + /** + * Called when an item in the list is clicked. + * + * @param position The position of the clicked item. + * @return true if this click should enable selection mode. + */ + override fun onItemClick(view: View?, position: Int): Boolean { + adapter?.resetEditing(position) + return true + } + + override fun onLogoClick(position: Int) { + val repo = (adapter?.getItem(position) as? RepoItem)?.repo ?: return + if (isNotOnline()) return + + if (repo.isBlank()) { + activity?.toast(R.string.url_not_set_click_again) + } else { + activity?.openInBrowser(repo.toUri()) + } + } + + private fun isNotOnline(showSnackbar: Boolean = true): Boolean { + if (activity == null || !activity!!.isOnline()) { + if (showSnackbar) view?.snack(R.string.no_network_connection) + return true + } + return false + } + + override fun onRepoRename(position: Int, newName: String): Boolean { + val repo = (adapter?.getItem(position) as? RepoItem)?.repo ?: return false + if (newName.isBlank()) { + activity?.toast(R.string.repo_cannot_be_blank) + return false + } + if (repo == RepoPresenter.CREATE_REPO_ITEM) { + return (presenter.createRepo(newName)) + } + return (presenter.renameRepo(repo, newName)) + } + + override fun onItemDelete(position: Int) { + activity!!.materialAlertDialog() + .setTitle(R.string.confirm_repo_deletion) + .setMessage(R.string.delete_repo_confirmation) + .setPositiveButton(R.string.delete) { _, _ -> + deleteRepo(position) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun deleteRepo(position: Int) { + adapter?.removeItem(position) + snack = + view?.snack(R.string.snack_repo_deleted, Snackbar.LENGTH_INDEFINITE) { + var undoing = false + setAction(R.string.undo) { + adapter?.restoreDeletedItems() + undoing = true + } + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (!undoing) confirmDelete() + } + }, + ) + } + (activity as? MainActivity)?.setUndoSnackBar(snack) + } + + fun confirmDelete() { + val adapter = adapter ?: return + presenter.deleteRepo(adapter.deletedItems.map { (it as RepoItem).repo }.firstOrNull()) + adapter.confirmDeletion() + snack = null + } + + /** + * Called from the presenter when a invalid repo is made + */ + fun onRepoInvalidNameError() { + activity?.toast(R.string.invalid_repo_name) + } + + companion object { + const val REPO_URL = "repo_url" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoHolder.kt new file mode 100644 index 000000000000..b3d01003792b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoHolder.kt @@ -0,0 +1,137 @@ +package eu.kanade.tachiyomi.ui.source.browse.repos + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.InputType +import android.view.View +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import androidx.core.content.ContextCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.CategoriesItemBinding +import eu.kanade.tachiyomi.util.system.getResourceColor + +/** + * Holder used to display repo items. + * + * @param view The view used by repo items. + * @param adapter The adapter containing this holder. + */ +class RepoHolder(view: View, val adapter: RepoAdapter) : FlexibleViewHolder(view, adapter) { + + private val binding = CategoriesItemBinding.bind(view) + + init { + binding.editButton.setOnClickListener { + submitChanges() + } + } + + private var createRepo = false + private var regularDrawable: Drawable? = null + + /** + * Binds this holder with the given repo. + * + * @param repo The repo to bind. + */ + fun bind(repo: String) { + // Set capitalized title. + binding.image.isVisible = false + binding.editText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submitChanges() + } + true + } + createRepo = repo == RepoPresenter.CREATE_REPO_ITEM + if (createRepo) { + binding.title.text = itemView.context.getString(R.string.action_add_repo) + binding.title.setTextColor( + ContextCompat.getColor(itemView.context, R.color.material_on_background_disabled), + ) + regularDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_add_24dp) + binding.editButton.setImageDrawable(null) + binding.editText.setText("") + binding.editText.hint = "" + } else { + binding.title.text = repo + binding.title.maxLines = 2 + binding.title.setTextColor(itemView.context.getResourceColor(R.attr.colorOnBackground)) + regularDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_github_24dp) + binding.reorder.setOnClickListener { + adapter.repoItemListener.onLogoClick(flexibleAdapterPosition) + } + binding.editText.setText(binding.title.text) + } + } + + @SuppressLint("ClickableViewAccessibility") + fun isEditing(editing: Boolean) { + itemView.isActivated = editing + binding.title.isInvisible = editing + binding.editText.isInvisible = !editing + if (editing) { + binding.editText.inputType = InputType.TYPE_TEXT_VARIATION_URI + binding.editText.requestFocus() + binding.editText.selectAll() + binding.editButton.setImageDrawable(ContextCompat.getDrawable(itemView.context, R.drawable.ic_check_24dp)) + binding.editButton.drawable.mutate().setTint(itemView.context.getResourceColor(R.attr.colorSecondary)) + showKeyboard() + if (!createRepo) { + binding.editText.setText("${binding.editText.text}/index.min.json") + binding.reorder.setImageDrawable( + ContextCompat.getDrawable(itemView.context, R.drawable.ic_delete_24dp), + ) + binding.reorder.setOnClickListener { + adapter.repoItemListener.onItemDelete(flexibleAdapterPosition) + hideKeyboard() + } + } + } else { + if (!createRepo) { + setDragHandleView(binding.reorder) + binding.editButton.setImageDrawable( + ContextCompat.getDrawable(itemView.context, R.drawable.ic_edit_24dp), + ) + } else { + binding.editButton.setImageDrawable(null) + binding.reorder.setOnTouchListener { _, _ -> true } + } + binding.editText.clearFocus() + binding.editButton.drawable?.mutate()?.setTint( + ContextCompat.getColor(itemView.context, R.color.gray_button), + ) + binding.reorder.setImageDrawable(regularDrawable) + } + } + + private fun submitChanges() { + if (binding.editText.isVisible) { + if (adapter.repoItemListener.onRepoRename(flexibleAdapterPosition, binding.editText.text.toString())) { + isEditing(false) + if (!createRepo) { + binding.title.text = binding.editText.text.toString() + } + } + } else { + itemView.performClick() + } + hideKeyboard() + } + + private fun showKeyboard() { + val inputMethodManager = itemView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.showSoftInput(binding.editText, WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + } + + private fun hideKeyboard() { + val inputMethodManager = itemView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(binding.editText.windowToken, 0) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoItem.kt new file mode 100644 index 000000000000..4f7305234d31 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoItem.kt @@ -0,0 +1,68 @@ +package eu.kanade.tachiyomi.ui.source.browse.repos + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R + +/** + * Repo item for a recycler view. + */ +class RepoItem(val repo: String) : AbstractFlexibleItem() { + + /** + * Whether this item is currently selected. + */ + var isEditing = false + + /** + * Returns the layout resource for this item. + */ + override fun getLayoutRes(): Int { + return R.layout.categories_item + } + + /** + * Returns a new view holder for this item. + * + * @param view The view of this item. + * @param adapter The adapter of this item. + */ + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter>, + ): RepoHolder { + return RepoHolder(view, adapter as RepoAdapter) + } + + /** + * Binds the given view holder with this item. + * + * @param adapter The adapter of this item. + * @param holder The holder to bind. + * @param position The position of this item in the adapter. + * @param payloads List of partial changes. + */ + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: RepoHolder, + position: Int, + payloads: List?, + ) { + holder.bind(repo) + holder.isEditing(isEditing) + } + + /** + * Returns true if this item is draggable. + */ + override fun isDraggable(): Boolean = false + + override fun equals(other: Any?): Boolean { + return this === other + } + + override fun hashCode(): Int = repo.hashCode() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoPresenter.kt new file mode 100644 index 000000000000..b794f1a2963e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/repos/RepoPresenter.kt @@ -0,0 +1,100 @@ +package eu.kanade.tachiyomi.ui.source.browse.repos + +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.minusAssign +import eu.kanade.tachiyomi.data.preference.plusAssign +import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Presenter of [RepoController]. Used to manage the repos for the extensions. + */ +class RepoPresenter( + private val controller: RepoController, + private val preferences: PreferencesHelper = Injekt.get(), +) : BaseCoroutinePresenter() { + + private var scope = CoroutineScope(Job() + Dispatchers.Default) + + /** + * List containing repos. + */ + private val repos: Set + get() = preferences.extensionRepos().get() + + /** + * Called when the presenter is created. + */ + fun getRepos() { + scope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + controller.updateRepos() + } + } + } + + fun getReposWithCreate(): List { + return (listOf(CREATE_REPO_ITEM) + repos).map(::RepoItem) + } + + /** + * Creates and adds a new repo to the database. + * + * @param name The name of the repo to create. + */ + fun createRepo(name: String): Boolean { + if (isInvalidRepo(name)) return false + + preferences.extensionRepos() += name.removeSuffix("/index.min.json") + controller.updateRepos() + return true + } + + /** + * Deletes the repo from the database. + * + * @param repo The repo to delete. + */ + fun deleteRepo(repo: String?) { + val safeRepo = repo ?: return + preferences.extensionRepos() -= safeRepo + controller.updateRepos() + } + + /** + * Renames a repo. + * + * @param repo The repo to rename. + * @param name The new name of the repo. + */ + fun renameRepo(repo: String, name: String): Boolean { + val truncName = name.removeSuffix("/index.min.json") + if (!repo.equals(truncName, true)) { + if (isInvalidRepo(name)) return false + preferences.extensionRepos() -= repo + preferences.extensionRepos() += truncName + controller.updateRepos() + } + return true + } + + private fun isInvalidRepo(name: String): Boolean { + // Do not allow invalid formats + if (!name.matches(repoRegex)) { + controller.onRepoInvalidNameError() + return true + } + return false + } + + companion object { + private val repoRegex = """^https://.*/index\.min\.json$""".toRegex() + const val CREATE_REPO_ITEM = "create_repo" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt index cecfd371b4ee..af4ddae567a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt @@ -59,13 +59,12 @@ class CrashLogUtil(private val context: Context) { val availableExtension = availableExtensions.find { it.pkgName == installedExtension.pkgName } val hasUpdate = (availableExtension?.versionCode ?: 0) > installedExtension.versionCode - if (hasUpdate || installedExtension.isObsolete || installedExtension.isUnofficial) { + if (hasUpdate || installedExtension.isObsolete) { val extensionInfo = "Extension Name: ${installedExtension.name}\n" + "Installed Version: ${installedExtension.versionName}\n" + "Available Version: ${availableExtension?.versionName ?: "N/A"}\n" + - "Obsolete: ${installedExtension.isObsolete}\n" + - "Unofficial: ${installedExtension.isUnofficial}\n" + "Obsolete: ${installedExtension.isObsolete}\n" extensionInfoList.add(extensionInfo) } } diff --git a/app/src/main/res/layout/info_repo_message.xml b/app/src/main/res/layout/info_repo_message.xml new file mode 100644 index 000000000000..9e2ed261d02b --- /dev/null +++ b/app/src/main/res/layout/info_repo_message.xml @@ -0,0 +1,48 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/extension_details.xml b/app/src/main/res/menu/extension_details.xml index c41bcadccfe7..c39c1a28b0e3 100644 --- a/app/src/main/res/menu/extension_details.xml +++ b/app/src/main/res/menu/extension_details.xml @@ -1,18 +1,6 @@ - - - - Private Allows extensions to be installed without user prompts and enables automatic updates for devices under Android 12 Untrusted extension - This extension was signed with an untrusted certificate and wasn\'t activated.\n\nA malicious extension could read any login credentials stored in Tachiyomi or execute arbitrary code.\n\nBy trusting this certificate you accept these risks. + Malicious extensions can read any stored login credentials or execute arbitrary code.\n\nBy trusting this extension, you accept these risks. This extension is no longer available. - This extension is not from the official Tachiyomi extensions list. + Failed to fetch available extensions Version: %1$s Language: %1$s 18+ Installed %1$s - Unofficial MIUI Optimization must be disabled to install extensions. May contain NSFW (18+) content App info @@ -362,6 +361,23 @@ Extension update available %d extension updates available + Revoke trusted unknown extensions + + + Add repo + Add additional repos to Tachiyomi. This should be a URL that ends with \"index.min.json\" + Extension repos + + %d repo + %d repos + + This repo already exists! + Repo name cannot be blank + Repo deleted + Invalid repo name + Delete repo + Do you wish to delete the repo \"%s\"? + Repo URL not set, please edit the URL Set as cover