Install and update extensions only from a store whose signing key matches

Updates come only from stores whose key the installed extension is signed with, the highest version among them. A downloaded apk must be signed with its store's key before it's installed.
This commit is contained in:
AntsyLich
2026-09-27 23:41:21 +06:00
parent ed17ece4e3
commit 0938411053
5 changed files with 83 additions and 81 deletions
@@ -66,8 +66,11 @@ class ExtensionManager(
private val loadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Loaded>())
val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized()
private val availableExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope)
// Every store's listing, since more than one store can list the same extension
private val availableExtensionListFlow = MutableStateFlow(emptyList<Extension.Available>())
val availableExtensionsFlow = availableExtensionListFlow
.map { extensions -> extensions.associateBy { it.pkgName }.values.toList() }
.stateIn(scope, SharingStarted.Lazily, emptyList())
private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.NotLoaded>())
val notLoadedExtensionsFlow = notLoadedExtensionMapFlow.mapExtensionsWhenInitialized()
@@ -153,7 +156,7 @@ class ExtensionManager(
.associateBy { it.pkgName }
// Newly loaded extensions have no status derived from the store index yet
updatedInstalledExtensionsStatuses(availableExtensionMapFlow.value.values.toList())
updatedInstalledExtensionsStatuses(availableExtensionListFlow.value)
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to load extensions" }
} finally {
@@ -163,7 +166,7 @@ class ExtensionManager(
}
/**
* Finds the available extensions in the [api] and updates [availableExtensionMapFlow].
* Finds the available extensions in the [api] and updates [availableExtensionListFlow].
*/
suspend fun findAvailableExtensions() {
val extensions: List<Extension.Available> = try {
@@ -176,7 +179,7 @@ class ExtensionManager(
enableAdditionalSubLanguages(extensions)
availableExtensionMapFlow.value = extensions.associateBy { it.pkgName }
availableExtensionListFlow.value = extensions
updatedInstalledExtensionsStatuses(extensions)
setupAvailableExtensionsSourcesDataMap(extensions)
}
@@ -222,31 +225,8 @@ class ExtensionManager(
return
}
val loadedExtensionsMap = loadedExtensionMapFlow.value.toMutableMap()
var changed = false
for ((pkgName, extension) in loadedExtensionsMap) {
val availableExt = availableExtensions.find { it.pkgName == pkgName }
if (availableExt == null && !extension.isObsolete) {
loadedExtensionsMap[pkgName] = extension.copy(isObsolete = true)
changed = true
} else if (availableExt != null) {
val hasUpdate = extension.updateExists(availableExt)
if (extension.hasUpdate != hasUpdate) {
loadedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate,
store = availableExt.store,
)
} else {
loadedExtensionsMap[pkgName] = extension.copy(
store = availableExt.store,
)
}
changed = true
}
}
if (changed) {
loadedExtensionMapFlow.value = loadedExtensionsMap
loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) ->
extension.withStatus(availableExtensions)
}
updatePendingUpdatesCount()
}
@@ -259,7 +239,7 @@ class ExtensionManager(
* @param extension The extension to be installed.
*/
fun installExtension(extension: Extension.Available): Flow<InstallStep> {
return installer.downloadAndInstall(extension.apkUrl, extension)
return installer.downloadAndInstall(extension)
}
/**
@@ -270,9 +250,9 @@ class ExtensionManager(
* @param extension The extension to be updated.
*/
fun updateExtension(extension: Extension.Loaded): Flow<InstallStep> {
val availableExt = availableExtensionMapFlow.value[extension.pkgName] ?: return emptyFlow()
val update = extension.findUpdate(availableExtensionListFlow.value) ?: return emptyFlow()
val isUpdateForPrivatelyInstalled = !extension.isShared
return installer.downloadAndInstall(availableExt.apkUrl, availableExt, isUpdateForPrivatelyInstalled)
return installer.downloadAndInstall(update, isUpdateForPrivatelyInstalled)
}
fun cancelInstallUpdateExtension(extension: Extension) {
@@ -341,7 +321,7 @@ class ExtensionManager(
private inner class InstallationListener : ExtensionInstallReceiver.Listener {
override fun onExtensionLoaded(extension: Extension.Loaded) {
registerExtension(extension.withUpdateCheck())
registerExtension(extension.withStatus(availableExtensionListFlow.value))
notLoadedExtensionMapFlow.value -= extension.pkgName
updatePendingUpdatesCount()
}
@@ -360,22 +340,17 @@ class ExtensionManager(
}
/**
* Extension method to set the update field of an installed extension.
* Derives what the store listings say about an installed extension. Without any listing there's
* nothing to derive from, so it's left as is rather than being marked obsolete.
*/
private fun Extension.Loaded.withUpdateCheck(): Extension.Loaded {
return if (updateExists()) {
copy(hasUpdate = true)
} else {
this
}
}
private fun Extension.Loaded.updateExists(availableExtension: Extension.Available? = null): Boolean {
val availableExt = availableExtension
?: availableExtensionMapFlow.value[pkgName]
?: return false
return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion)
private fun Extension.Loaded.withStatus(availableExtensions: List<Extension.Available>): Extension.Loaded {
if (availableExtensions.isEmpty()) return this
val listing = findListing(availableExtensions)
return copy(
hasUpdate = findUpdate(availableExtensions) != null,
isObsolete = listing == null,
store = listing?.store ?: store,
)
}
private fun updatePendingUpdatesCount() {
@@ -388,10 +363,6 @@ class ExtensionManager(
private operator fun <T : Extension> Map<String, T>.plus(extension: T) = plus(extension.pkgName to extension)
private fun <T : Extension> StateFlow<Map<String, T>>.mapExtensions(scope: CoroutineScope): StateFlow<List<T>> {
return map { it.values.toList() }.stateIn(scope, SharingStarted.Lazily, value.values.toList())
}
/**
* Extensions are loaded in the background, so this flow only starts emitting once that finished.
*/
@@ -29,17 +29,7 @@ class ExtensionApi(
val extensions = findExtensions()
val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
for (installedExt in loadedExtensions) {
val pkgName = installedExt.pkgName
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
val hasUpdatedLib = availableExt.libVersion > installedExt.libVersion
val hasUpdate = hasUpdatedVer || hasUpdatedLib
if (hasUpdate) {
extensionsWithUpdate.add(installedExt)
}
}
val extensionsWithUpdate = loadedExtensions.filter { it.findUpdate(extensions) != null }
if (extensionsWithUpdate.isNotEmpty()) {
extensionUpdateNotifier.promptUpdates(extensionsWithUpdate.map { it.name })
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.util
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import dev.zacsweers.metro.Inject
@@ -49,13 +50,11 @@ class ExtensionInstaller(
* Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process.
*
* @param url The url of the apk.
* @param extension The extension to install.
* @param extension The listing to install, whose apk must be signed with its store's key.
* @param isUpdateForPrivatelyInstalled If this is an update for a privately installed extension
*/
fun downloadAndInstall(
url: String,
extension: Extension,
extension: Extension.Available,
isUpdateForPrivatelyInstalled: Boolean = false,
): Flow<InstallStep> {
val downloadId = extension.pkgName.hashCode().toLong()
@@ -68,7 +67,7 @@ class ExtensionInstaller(
val tmpFile = File(context.cacheDir, "extension_${extension.pkgName}.apk")
try {
step.value = InstallStep.Downloading
val request = Request.Builder().url(url).build()
val request = Request.Builder().url(extension.apkUrl).build()
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful) {
@@ -80,8 +79,16 @@ class ExtensionInstaller(
}
}
// The store's index names the apk, but nothing ties what's served there to the store's key
val packageInfo = ExtensionLoader.getArchivePackageInfo(context, tmpFile)
?.takeIf { extension.store.signingKey in ExtensionLoader.getSignatures(it).orEmpty() }
if (packageInfo == null) {
tmpFile.delete()
throw Exception("Extension isn't signed with the key of ${extension.store.name}")
}
step.value = InstallStep.Installing
installApk(downloadId, tmpFile, isUpdateForPrivatelyInstalled)
installApk(downloadId, tmpFile, packageInfo, isUpdateForPrivatelyInstalled)
} catch (e: Exception) {
if (e is InterruptedException) {
// Canceled
@@ -106,11 +113,17 @@ class ExtensionInstaller(
* Starts an intent to install the extension at the given uri.
*
* @param tempFile The file of the extension to install. Delete after use.
* @param packageInfo The package info already read from [tempFile].
* @param isUpdateForPrivatelyInstalled If this install is an update for a privately installed extension
*/
private fun installApk(downloadId: Long, tempFile: File, isUpdateForPrivatelyInstalled: Boolean = false) {
private fun installApk(
downloadId: Long,
tempFile: File,
packageInfo: PackageInfo,
isUpdateForPrivatelyInstalled: Boolean = false,
) {
if (isUpdateForPrivatelyInstalled) {
installApkPrivately(downloadId, tempFile)
installApkPrivately(downloadId, tempFile, packageInfo)
return
}
@@ -125,7 +138,7 @@ class ExtensionInstaller(
}
BasePreferences.ExtensionInstaller.PRIVATE -> {
installApkPrivately(downloadId, tempFile)
installApkPrivately(downloadId, tempFile, packageInfo)
}
else -> {
@@ -140,9 +153,9 @@ class ExtensionInstaller(
}
}
private fun installApkPrivately(downloadId: Long, tempFile: File) {
private fun installApkPrivately(downloadId: Long, tempFile: File, packageInfo: PackageInfo) {
try {
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile, packageInfo)) {
updateInstallStep(downloadId, InstallStep.Installed)
} else {
updateInstallStep(downloadId, InstallStep.Error)
@@ -59,9 +59,8 @@ internal object ExtensionLoader {
private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts")
fun installPrivateExtensionFile(context: Context, file: File): Boolean {
val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS)
?.takeIf { isPackageAnExtension(it) } ?: return false
fun installPrivateExtensionFile(context: Context, file: File, extension: PackageInfo): Boolean {
if (!isPackageAnExtension(extension)) return false
val currentExtension = getExtensionPackageInfoFromPkgName(context, extension.packageName)
if (currentExtension != null) {
@@ -105,6 +104,10 @@ internal object ExtensionLoader {
File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete()
}
fun getArchivePackageInfo(context: Context, file: File): PackageInfo? {
return context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS)
}
/**
* Return a list of all the available extensions initialized concurrently.
*
@@ -274,6 +277,7 @@ internal object ExtensionLoader {
versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo),
isShared = extensionInfo.isShared,
contentWarning = ContentWarning.SAFE,
signatures = getSignatures(pkgInfo).orEmpty(),
reason = Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()),
)
}
@@ -304,6 +308,7 @@ internal object ExtensionLoader {
?: pkgName
val versionName = pkgInfo.versionName
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
val signatures = getSignatures(pkgInfo).orEmpty()
val contentWarning = when {
metaData == null -> ContentWarning.SAFE
metaData.containsKey(METADATA_CONTENT_WARNING) -> {
@@ -327,6 +332,7 @@ internal object ExtensionLoader {
versionCode = versionCode,
isShared = extensionInfo.isShared,
contentWarning = contentWarning,
signatures = signatures,
libVersion = libVersion,
reason = reason,
)
@@ -354,8 +360,7 @@ internal object ExtensionLoader {
return notLoaded(Extension.NotLoaded.Reason.UnsupportedLibVersion, libVersion)
}
val signatures = getSignatures(pkgInfo)
if (signatures.isNullOrEmpty()) {
if (signatures.isEmpty()) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion)
} else if (!trustExtension.isTrusted(pkgInfo, signatures)) {
@@ -435,6 +440,7 @@ internal object ExtensionLoader {
pkgFactory = metaData.getString(METADATA_SOURCE_FACTORY),
icon = runCatching { appInfo.loadIcon(pkgManager) }.getOrNull(),
isShared = extensionInfo.isShared,
signatures = signatures,
)
}
@@ -475,7 +481,7 @@ internal object ExtensionLoader {
* @param pkgInfo The package info of the application.
* @return List SHA256 digest of the signatures
*/
private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
fun getSignatures(pkgInfo: PackageInfo): List<String>? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val signingInfo = pkgInfo.signingInfo
when {
@@ -21,6 +21,26 @@ sealed interface Extension {
*/
sealed interface Installed : Extension {
val isShared: Boolean
/** SHA-256 digests of the certificates its apk is signed with. */
val signatures: List<String>
/**
* The newest listing of this extension among the stores whose signing key it's signed with. An
* apk from any other store can't replace it, so only these are where its updates come from.
*/
fun findListing(available: Collection<Available>): Available? {
return available
.filter { it.pkgName == pkgName && it.store.signingKey in signatures }
.maxWithOrNull(compareBy<Available> { it.versionCode }.thenBy { it.libVersion })
}
fun findUpdate(available: Collection<Available>): Available? {
val installedLibVersion = libVersion
return findListing(available)?.takeIf {
it.versionCode > versionCode || (installedLibVersion != null && it.libVersion > installedLibVersion)
}
}
}
/**
@@ -68,6 +88,7 @@ sealed interface Extension {
override val lang: String,
override val contentWarning: ContentWarning,
override val isShared: Boolean,
override val signatures: List<String>,
val pkgFactory: String?,
val sources: List<Source>,
val icon: Drawable?,
@@ -87,6 +108,7 @@ sealed interface Extension {
override val versionCode: Long,
override val isShared: Boolean,
override val contentWarning: ContentWarning,
override val signatures: List<String>,
override val libVersion: Double? = null,
override val lang: String? = null,
val reason: Reason,