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:
parent
6124efa164
commit
bad1dd264d
7 changed files with 101 additions and 223 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 <--
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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://"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <--
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue