mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 10:44:42 +00:00
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:
parent
5e558c1f85
commit
14e669e40c
30 changed files with 877 additions and 228 deletions
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -255,6 +255,11 @@ object Migrations {
|
|||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
if (oldVersion < 111) {
|
||||
prefs.edit {
|
||||
remove("trusted_signatures")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -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", "")
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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>?,
|
||||
)
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
48
app/src/main/res/layout/info_repo_message.xml
Normal file
48
app/src/main/res/layout/info_repo_message.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue