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
This commit is contained in:
nzoba 2024-01-15 21:40:12 +01:00 committed by GitHub
parent 5e558c1f85
commit 14e669e40c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 877 additions and 228 deletions

View file

@ -60,6 +60,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link to add repos -->
<intent-filter android:label="@string/action_add_repo">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tachiyomi" />
<data android:host="add-repo" />
</intent-filter>
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable"/>
</activity>
<activity

View file

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackPreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.util.TrustExtension
import eu.kanade.tachiyomi.network.JavaScriptEngine
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
@ -68,6 +69,8 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { MangaShortcutManager() }
addSingletonFactory { TrustExtension(get()) }
// Asynchronously init expensive components for a faster cold start
ContextCompat.getMainExecutor(app).execute {

View file

@ -255,6 +255,11 @@ object Migrations {
} catch (_: Exception) {
}
}
if (oldVersion < 111) {
prefs.edit {
remove("trusted_signatures")
}
}
return true
}

View file

@ -295,7 +295,10 @@ class PreferencesHelper(val context: Context) {
fun automaticExtUpdates() = flowPrefs.getBoolean(Keys.automaticExtUpdates, true)
fun installedExtensionsOrder() = flowPrefs.getInt(Keys.installedExtensionsOrder, InstalledExtensionsOrder.Name.value)
fun extensionRepos() = flowPrefs.getStringSet("extension_repos", emptySet())
fun installedExtensionsOrder() =
flowPrefs.getInt(Keys.installedExtensionsOrder, InstalledExtensionsOrder.Name.value)
fun migrationSourceOrder() = flowPrefs.getInt("migration_source_order", Values.MigrationSourceOrder.Alphabetically.value)
@ -342,7 +345,7 @@ class PreferencesHelper(val context: Context) {
fun migrateFlags() = flowPrefs.getInt("migrate_flags", Int.MAX_VALUE)
fun trustedSignatures() = flowPrefs.getStringSet("trusted_signatures", emptySet())
fun trustedExtensions() = flowPrefs.getStringSet("trusted_extensions", emptySet())
// using string instead of set so it is ordered
fun migrationSources() = flowPrefs.getString("migrate_sources", "")

View file

@ -4,17 +4,21 @@ import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Parcelable
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
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.model.InstallStep
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.extension.util.TrustExtension
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.system.launchNow
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
@ -39,12 +43,13 @@ import java.util.Locale
class ExtensionManager(
private val context: Context,
private val preferences: PreferencesHelper = Injekt.get(),
private val trustExtension: TrustExtension = Injekt.get(),
) {
/**
* API where all the available extensions can be found.
*/
private val api = ExtensionGithubApi()
private val api = ExtensionApi()
/**
* The installer which installs, updates and uninstalls the extensions.
@ -130,10 +135,10 @@ class ExtensionManager(
val extensions: List<Extension.Available> = 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<LoadResult.Success>()
.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,
)
}

View file

@ -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()
}

View file

@ -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<Extension.Available> {
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<List<ExtensionJsonObject>>()
.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<Extension.Available> {
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
.awaitSuccess()
with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(repoBaseUrl)
}
} catch (e: Throwable) {
Timber.e(e, "Failed to get extensions from $repoBaseUrl")
emptyList()
}
}
suspend fun checkForUpdates(context: Context, prefetchedExtensions: List<Extension.Available>? = null): List<Extension.Available> {
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<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
private fun List<ExtensionJsonObject>.toExtensions(repoUrl: String): List<Extension.Available> {
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<Extension.AvailableSource>?,
)

View file

@ -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<Source>,
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<AvailableSource>,
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()
}

View file

@ -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<String>): Boolean {
return trustedSignatures.any { signatures.contains(it) }
}
private fun isOfficiallySigned(signatures: List<String>): Boolean {
return signatures.all { it == officialSignature }
private fun isTrusted(pkgInfo: PackageInfo, signatures: List<String>): Boolean {
return trustExtension.isTrusted(pkgInfo, signatures.last())
}
/**

View file

@ -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()
}
}

View file

@ -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) {

View file

@ -274,7 +274,7 @@ class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>()
}
}
fun trustSignature(signatureHash: String) {
extensionManager.trustSignature(signatureHash)
fun trustExtension(pkgName: String, versionCode: Long, signatureHash: String) {
extensionManager.trust(pkgName, versionCode, signatureHash)
}
}

View file

@ -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)

View file

@ -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

View file

@ -10,10 +10,11 @@ class ExtensionTrustDialog<T>(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<T>(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<T>(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)
}
}

View file

@ -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<HttpSource>()

View file

@ -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)
}

View file

@ -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<MainActivityBinding>() {
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<MainActivityBinding>() {
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

View file

@ -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 {

View file

@ -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

View file

@ -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<InfoRepoMessage.Holder>() {
/**
* 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<IFlexible<RecyclerView.ViewHolder>>,
): 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<IFlexible<RecyclerView.ViewHolder>>,
holder: Holder,
position: Int,
payloads: MutableList<Any>,
) {
}
/**
* 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<IFlexible<RecyclerView.ViewHolder>>) :
BaseFlexibleViewHolder(view, adapter, true)
}

View file

@ -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<AbstractFlexibleItem<*>>(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)
}
}

View file

@ -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<CategoriesControllerBinding>(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<Snackbar>() {
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"
}
}

View file

@ -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)
}
}

View file

@ -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<RepoHolder>() {
/**
* 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<IFlexible<RecyclerView.ViewHolder>>,
): 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<IFlexible<RecyclerView.ViewHolder>>,
holder: RepoHolder,
position: Int,
payloads: List<Any?>?,
) {
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()
}

View file

@ -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<RepoController>() {
private var scope = CoroutineScope(Job() + Dispatchers.Default)
/**
* List containing repos.
*/
private val repos: Set<String>
get() = preferences.extensionRepos().get()
/**
* Called when the presenter is created.
*/
fun getRepos() {
scope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
controller.updateRepos()
}
}
}
fun getReposWithCreate(): List<RepoItem> {
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"
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Tachiyomi.CardView.Draggable">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/front_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/background"
android:minHeight="52dp">
<ImageView
android:id="@+id/help_icon"
android:layout_width="48dp"
android:layout_height="0dp"
android:layout_alignParentStart="true"
android:layout_gravity="start"
android:contentDescription="@string/help"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_help_24dp"
app:tint="?attr/colorOnBackground" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/help_icon"
android:background="?background"
android:ellipsize="end"
android:paddingStart="0dp"
android:paddingTop="16dp"
android:paddingEnd="8dp"
android:paddingBottom="16dp"
android:text="@string/action_add_repo_message"
android:textAlignment="textStart"
android:textAppearance="?textAppearanceBodyMedium"
android:textStyle="italic"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/help_icon"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -1,18 +1,6 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_history"
android:icon="@drawable/ic_history_24dp"
android:title="@string/whats_new"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_readme"
android:icon="@drawable/ic_help_outline_24dp"
android:title="@string/faq_and_guides"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_clear_cookies"
android:icon="@drawable/ic_delete_24dp"

View file

@ -333,14 +333,13 @@
<string name="ext_installer_private" translatable="false">Private</string>
<string name="ext_installer_summary">Allows extensions to be installed without user prompts and enables automatic updates for devices under Android 12</string>
<string name="untrusted_extension">Untrusted extension</string>
<string name="untrusted_extension_message">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.</string>
<string name="untrusted_extension_message">Malicious extensions can read any stored login credentials or execute arbitrary code.\n\nBy trusting this extension, you accept these risks.</string>
<string name="obsolete_extension_message">This extension is no longer available.</string>
<string name="unofficial_extension_message">This extension is not from the official Tachiyomi extensions list.</string>
<string name="extension_api_error">Failed to fetch available extensions</string>
<string name="version_">Version: %1$s</string>
<string name="language_">Language: %1$s</string>
<string name="nsfw_short">18+</string>
<string name="installed_">Installed %1$s</string>
<string name="unofficial">Unofficial</string>
<string name="extensions_miui_warning">MIUI Optimization must be disabled to install extensions.</string>
<string name="may_contain_nsfw">May contain NSFW (18+) content</string>
<string name="app_info">App info</string>
@ -362,6 +361,23 @@
<item quantity="one">Extension update available</item>
<item quantity="other">%d extension updates available</item>
</plurals>
<string name="ext_revoke_trust">Revoke trusted unknown extensions</string>
<!-- Extension Repos -->
<string name="action_add_repo">Add repo</string>
<string name="action_add_repo_message">Add additional repos to Tachiyomi. This should be a URL that ends with \"index.min.json\"</string>
<string name="extension_repos">Extension repos</string>
<plurals name="num_repos">
<item quantity="one">%d repo</item>
<item quantity="other">%d repos</item>
</plurals>
<string name="error_repo_exists">This repo already exists!</string>
<string name="repo_cannot_be_blank">Repo name cannot be blank</string>
<string name="snack_repo_deleted">Repo deleted</string>
<string name="invalid_repo_name">Invalid repo name</string>
<string name="confirm_repo_deletion">Delete repo</string>
<string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
<string name="url_not_set_click_again">Repo URL not set, please edit the URL</string>
<!-- Reader -->
<string name="set_as_cover">Set as cover</string>