Fix extension install/update stuck at pending (#1630)

* Fix extension install/update stuck at pending (mihonapp/mihon#3000)

Co-authored-by: p
(cherry picked from commit 84265febf3ce24d71994ced2b81215f858430d4e)

* Fix lint

* Throw exception when Shizuku shell interface is unavailable during installation instead of silently doing nothing

* Properly close OkHttp response and streams

* Delete temporary extension file if failed to install

* Use ConcurrentHashMap for active jobs and steps to ensure thread safety when managing extension installation states across multiple coroutines.

* Use SupervisorJob to ensure that a failure in one installation job doesn't cancel the entire scope

* Don't use `:` in package name for safer filename

* Throw exception if asset file descriptor fails to open during extension installation

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
AntsyLich 2026-02-26 07:58:38 +06:00 committed by Cuong-Tran
parent 6124efa164
commit bad1dd264d
7 changed files with 101 additions and 223 deletions

View file

@ -44,6 +44,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Fix Add Repo input not taking up the full dialog width ([@cuong-tran](https://github.com/cuong-tran)) ([#2816](https://github.com/mihonapp/mihon/pull/2816))
- Fix migration's selected sources order not preserved ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2993))
- Fix migration dialog not showing for consecutive prompts from the same screen ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2994))
- Fix extension install/update stuck at pending ([@AntsyLich](https://github.com/AntsyLich)) ([#3000](https://github.com/mihonapp/mihon/pull/3000))
### Other
- Enable logcat logging on stable and debug builds without enabling verbose logging ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2836](https://github.com/mihonapp/mihon/pull/2836))

View file

@ -200,7 +200,7 @@ class ExtensionManager(
availableExtensionMapFlow.value = extensions.associateBy {
it.pkgName +
// KMK -->
":${it.signatureHash}"
"_${it.signatureHash}"
// KMK <--
}
updatedInstalledExtensionsStatuses(extensions)
@ -312,7 +312,7 @@ class ExtensionManager(
val availableExt = availableExtensionMapFlow.value[
extension.pkgName +
// KMK -->
":${extension.signatureHash}",
"_${extension.signatureHash}",
// KMK <--
] ?: return emptyFlow()
return installExtension(availableExt)
@ -322,7 +322,7 @@ class ExtensionManager(
installer.cancelInstall(
extension.pkgName +
// KMK -->
":${extension.signatureHash}",
"_${extension.signatureHash}",
// KMK <--
)
}

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
@ -82,6 +83,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
service.contentResolver.delete(entry.uri, null, null)
val intentSender = PendingIntent.getBroadcast(
service,
@ -89,6 +91,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
Intent(INSTALL_ACTION).setPackage(service.packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
).intentSender
@SuppressLint("RequestInstallPackagesPolicy")
session.commit(intentSender)
}
} catch (e: Exception) {

View file

@ -111,20 +111,17 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun processEntry(entry: Entry) {
super.processEntry(entry)
// KMK -->
val installer = shellInterface
if (installer == null) {
logcat(LogPriority.ERROR) { "Shizuku shell interface not available for ${entry.downloadId}" }
continueQueue(InstallStep.Error)
return
}
try {
service.contentResolver.openAssetFileDescriptor(entry.uri, "r")?.use {
installer.install(it)
}
shellInterface?.install(it)
// KMK -->
?: throw Exception("Shell interface is not available")
// KMK <--
}
// KMK -->
?: throw Exception("Failed to open asset file descriptor")
// KMK <--
service.contentResolver.delete(entry.uri, null, null)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
continueQueue(InstallStep.Error)

View file

@ -64,6 +64,11 @@ class ExtensionInstallActivity : Activity() {
}
}
override fun onDestroy() {
super.onDestroy()
intent.data?.let { contentResolver.delete(it, null, null) }
}
private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val extensionManager = Injekt.get<ExtensionManager>()

View file

@ -1,66 +1,52 @@
package eu.kanade.tachiyomi.extension.util
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Environment
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.installer.Installer
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.isPackageInstalled
import kotlinx.coroutines.delay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withUIContext
import okhttp3.OkHttpClient
import okhttp3.Request
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import kotlin.time.Duration.Companion.seconds
import java.util.concurrent.ConcurrentHashMap
/**
* The installer which installs, updates and uninstalls the extensions.
*
* @param context The application context.
*/
internal class ExtensionInstaller(private val context: Context) {
/**
* The system's download manager
*/
private val downloadManager = context.getSystemService<DownloadManager>()!!
/**
* The broadcast receiver which listens to download completion events.
*/
private val downloadReceiver = DownloadCompletionReceiver()
/**
* The currently requested downloads, with the package name (unique id) as key, and the id
* returned by the download manager.
*/
private val activeDownloads = hashMapOf<String, Long>()
private val downloadsStateFlows = hashMapOf<Long, MutableStateFlow<InstallStep>>()
internal class ExtensionInstaller(
private val context: Context,
) {
// KMK -->
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeJobs = ConcurrentHashMap<String, Job>()
private val activeSteps = ConcurrentHashMap<Long, MutableStateFlow<InstallStep>>()
// KMK <--
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
private val httpClient: OkHttpClient = Injekt.get<NetworkHelper>().client
/**
* Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process.
@ -71,130 +57,97 @@ internal class ExtensionInstaller(private val context: Context) {
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
val pkgName = extension.pkgName +
// KMK -->
":${extension.signatureHash}"
"_${extension.signatureHash}"
// KMK <--
val downloadId = pkgName.hashCode().toLong()
cancelInstall(pkgName)
val step = MutableStateFlow(InstallStep.Pending)
activeSteps[downloadId] = step
val job = scope.launch {
val tmpFile = File(context.cacheDir, "extension_$pkgName.apk")
try {
step.value = InstallStep.Downloading
val request = Request.Builder().url(url).build()
httpClient.newCall(request).execute()
// KMK -->
.use { response ->
// KMK <--
val oldDownload = activeDownloads[pkgName]
if (oldDownload != null) {
deleteDownload(pkgName)
if (!response.isSuccessful) {
throw Exception("Failed to download extension")
}
// Register the receiver after removing (and unregistering) the previous download
downloadReceiver.register()
val downloadUri = url.toUri()
val request = DownloadManager.Request(downloadUri)
.setTitle(extension.name)
.setMimeType(APK_MIME)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
val id = downloadManager.enqueue(request)
activeDownloads[pkgName] = id
val downloadStateFlow = MutableStateFlow(InstallStep.Pending)
downloadsStateFlows[id] = downloadStateFlow
// Poll download status
val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus ->
// Map to our model
when (downloadStatus) {
DownloadManager.STATUS_PENDING -> InstallStep.Pending
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
else -> null
}
}
return merge(downloadStateFlow, pollStatusFlow).transformWhile {
emit(it)
// Stop when the application is installed or errors
!it.isCompleted()
}.onCompletion {
// Always notify on main thread
withUIContext {
// Always remove the download when unsubscribed
deleteDownload(pkgName)
// KMK -->
tmpFile.outputStream().use { output ->
response.body.byteStream().use { input ->
// KMK <--
input.copyTo(output)
}
}
}
/**
* Returns a flow that polls the given download id for its status every second, as the
* manager doesn't have any notification system. It'll stop once the download finishes.
*
* @param id The id of the download to poll.
*/
private fun downloadStatusFlow(id: Long): Flow<Int> = flow {
val query = DownloadManager.Query().setFilterById(id)
while (true) {
// Get the current download status
val downloadStatus = downloadManager.query(query).use { cursor ->
if (!cursor.moveToFirst()) return@flow
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
step.value = InstallStep.Installing
installApk(downloadId, tmpFile)
} catch (e: Exception) {
if (e is InterruptedException) {
// Canceled
} else {
logcat(LogPriority.ERROR, e)
step.value = InstallStep.Error
}
// KMK -->
tmpFile.delete()
// KMK <--
}
}
emit(downloadStatus)
activeJobs[pkgName] = job
// Stop polling when the download fails or finishes
if (
downloadStatus == DownloadManager.STATUS_SUCCESSFUL ||
downloadStatus == DownloadManager.STATUS_FAILED
) {
return@flow
}
delay(1.seconds)
return step.asStateFlow()
.onCompletion {
activeJobs.remove(pkgName)
activeSteps.remove(downloadId)
job.cancel()
}
}
// Ignore duplicate results
.distinctUntilChanged()
/**
* Starts an intent to install the extension at the given uri.
*
* @param uri The uri of the extension to install.
* @param tempFile The file of the extension to install. Delete after use.
*/
fun installApk(downloadId: Long, uri: Uri) {
private fun installApk(downloadId: Long, tempFile: File) {
when (val installer = extensionInstaller.get()) {
BasePreferences.ExtensionInstaller.LEGACY -> {
val intent = Intent(context, ExtensionInstallActivity::class.java)
.setDataAndType(uri, APK_MIME)
.setDataAndType(tempFile.getUriCompat(context), APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
}
BasePreferences.ExtensionInstaller.PRIVATE -> {
val extensionManager = Injekt.get<ExtensionManager>()
val tempFile = File(context.cacheDir, "temp_$downloadId")
if (tempFile.exists() && !tempFile.delete()) {
// Unlikely but just in case
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
return
}
try {
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
updateInstallStep(downloadId, InstallStep.Installed)
} else {
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
updateInstallStep(downloadId, InstallStep.Error)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
updateInstallStep(downloadId, InstallStep.Error)
}
tempFile.delete()
}
else -> {
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
val intent = ExtensionInstallService.getIntent(
context,
downloadId,
tempFile.getUriCompat(context),
installer,
)
ContextCompat.startForegroundService(context, intent)
}
}
@ -204,9 +157,8 @@ internal class ExtensionInstaller(private val context: Context) {
* Cancels extension install and remove from download manager and installer.
*/
fun cancelInstall(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName) ?: return
downloadManager.remove(downloadId)
Installer.cancelInstallQueue(context, downloadId)
activeJobs.remove(pkgName)?.cancel()
Installer.cancelInstallQueue(context, pkgName.hashCode().toLong())
}
/**
@ -233,91 +185,11 @@ internal class ExtensionInstaller(private val context: Context) {
* @param step New install step.
*/
fun updateInstallStep(downloadId: Long, step: InstallStep) {
downloadsStateFlows[downloadId]?.let { it.value = step }
}
/**
* Deletes the download for the given package name.
*
* @param pkgName The package name of the download to delete together with its signature.
*/
private fun deleteDownload(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName)
if (downloadId != null) {
downloadManager.remove(downloadId)
downloadsStateFlows.remove(downloadId)
}
if (activeDownloads.isEmpty()) {
downloadReceiver.unregister()
}
}
/**
* Receiver that listens to download status events.
*/
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
/**
* Whether this receiver is currently registered.
*/
private var isRegistered = false
/**
* Registers this receiver if it's not already.
*/
fun register() {
if (isRegistered) return
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
}
/**
* Unregisters this receiver if it's not already.
*/
fun unregister() {
if (!isRegistered) return
isRegistered = false
context.unregisterReceiver(this)
}
/**
* Called when a download event is received. It looks for the download in the current active
* downloads and notifies its installation step.
*/
override fun onReceive(context: Context, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
// Avoid events for downloads we didn't request
if (id !in activeDownloads.values) return
val uri = downloadManager.getUriForDownloadedFile(id)
// Set next installation step
if (uri == null) {
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
updateInstallStep(id, InstallStep.Error)
return
}
val query = DownloadManager.Query().setFilterById(id)
downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
val localUri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
).removePrefix(FILE_SCHEME)
installApk(id, File(localUri).getUriCompat(context))
}
}
}
activeSteps[downloadId]?.let { it.value = step }
}
companion object {
const val APK_MIME = "application/vnd.android.package-archive"
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
const val FILE_SCHEME = "file://"
}
}

View file

@ -53,7 +53,7 @@ class ExtensionsScreenModel(
map[
it.pkgName +
// KMK -->
":${it.signatureHash}",
"_${it.signatureHash}",
// KMK <--
] ?: InstallStep.Idle,
)
@ -207,7 +207,7 @@ class ExtensionsScreenModel(
it + Pair(
extension.pkgName +
// KMK -->
":${extension.signatureHash}",
"_${extension.signatureHash}",
// KMK <--
installStep,
)
@ -219,7 +219,7 @@ class ExtensionsScreenModel(
it - (
extension.pkgName +
// KMK -->
":${extension.signatureHash}"
"_${extension.signatureHash}"
// KMK <--
)
}