diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index ccda3c39f..cd5a4d3a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -66,8 +66,11 @@ class ExtensionManager( private val loadedExtensionMapFlow = MutableStateFlow(emptyMap()) val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized() - private val availableExtensionMapFlow = MutableStateFlow(emptyMap()) - val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope) + // Every store's listing, since more than one store can list the same extension + private val availableExtensionListFlow = MutableStateFlow(emptyList()) + val availableExtensionsFlow = availableExtensionListFlow + .map { extensions -> extensions.associateBy { it.pkgName }.values.toList() } + .stateIn(scope, SharingStarted.Lazily, emptyList()) private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap()) 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 = 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 { - 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 { - 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.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 Map.plus(extension: T) = plus(extension.pkgName to extension) - private fun StateFlow>.mapExtensions(scope: CoroutineScope): StateFlow> { - 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. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index c0d9d908e..7ab402a68 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -29,17 +29,7 @@ class ExtensionApi( val extensions = findExtensions() - val extensionsWithUpdate = mutableListOf() - 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 }) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt index 8b7a59ab0..50d5813db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -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 { 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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index 78beb8e30..ae09978da 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -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? { + fun getSignatures(pkgInfo: PackageInfo): List? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val signingInfo = pkgInfo.signingInfo when { diff --git a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index 92262c9c7..ad56c45e9 100644 --- a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -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 + + /** + * 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? { + return available + .filter { it.pkgName == pkgName && it.store.signingKey in signatures } + .maxWithOrNull(compareBy { it.versionCode }.thenBy { it.libVersion }) + } + + fun findUpdate(available: Collection): 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, val pkgFactory: String?, val sources: List, 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, override val libVersion: Double? = null, override val lang: String? = null, val reason: Reason,