mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-23 17:09:49 -07:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user