Skip to content

Commit

Permalink
Add extension repos (#1719)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
nzoba authored Jan 15, 2024
1 parent 5e558c1 commit 14e669e
Show file tree
Hide file tree
Showing 30 changed files with 877 additions and 228 deletions.
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/eu/kanade/tachiyomi/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/eu/kanade/tachiyomi/Migrations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ object Migrations {
} catch (_: Exception) {
}
}
if (oldVersion < 111) {
prefs.edit {
remove("trusted_signatures")
}
}

return true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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", "")
Expand Down
56 changes: 31 additions & 25 deletions app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) }
}
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -435,13 +439,15 @@ class ExtensionManager(
val name: String,
val versionCode: Long,
val libVersion: Double,
val repoUrl: String,
) : Parcelable {
constructor(extension: Extension.Available) : this(
apkName = extension.apkName,
pkgName = extension.pkgName,
name = extension.name,
versionCode = extension.versionCode,
libVersion = extension.libVersion,
repoUrl = extension.repoUrl,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,51 +18,43 @@ 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()
}

extensions
}
}

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()
Expand All @@ -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)
}
Expand All @@ -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()
Expand All @@ -104,35 +97,23 @@ 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 {
return version.substringBeforeLast('.').toDouble()
}
}

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,
Expand All @@ -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>?,
)
Loading

0 comments on commit 14e669e

Please sign in to comment.