diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt index d3aef3b18..931bf48af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt @@ -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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt index 834a40d6f..829690508 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt @@ -112,6 +112,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use { shellInterface?.install(it) } + 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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt index a300ba2ea..ef73e9de7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt @@ -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() 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 b71974a30..2b88d3b40 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 @@ -1,66 +1,48 @@ 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.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 /** * 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()!! - - /** - * 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() - - private val downloadsStateFlows = hashMapOf>() +internal class ExtensionInstaller( + private val context: Context, +) { + private val scope = CoroutineScope(Dispatchers.IO) + private val activeJobs = mutableMapOf() + private val activeSteps = mutableMapOf>() private val extensionInstaller = Injekt.get().extensionInstaller() + private val httpClient: OkHttpClient = Injekt.get().client + /** * Adds the given extension to the downloads queue and returns an observable containing its * step in the installation process. @@ -69,129 +51,86 @@ internal class ExtensionInstaller(private val context: Context) { * @param extension The extension to install. */ fun downloadAndInstall(url: String, extension: Extension): Flow { - val pkgName = extension.pkgName + val downloadId = extension.pkgName.hashCode().toLong() + cancelInstall(extension.pkgName) - val oldDownload = activeDownloads[pkgName] - if (oldDownload != null) { - deleteDownload(pkgName) - } + val step = MutableStateFlow(InstallStep.Pending) + activeSteps[downloadId] = step - // Register the receiver after removing (and unregistering) the previous download - downloadReceiver.register() + val job = scope.launch { + val tmpFile = File(context.cacheDir, "extension_${extension.pkgName}.apk") + try { + step.value = InstallStep.Downloading + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() - 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) + if (!response.isSuccessful) { + throw Exception("Failed to download extension") + } + response.body.byteStream().use { input -> + tmpFile.outputStream().use { output -> + input.copyTo(output) + } + } - 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 + step.value = InstallStep.Installing + installApk(downloadId, tmpFile) + } catch (e: Exception) { + if (e is InterruptedException) { + // Canceled + } else { + logcat(LogPriority.ERROR, e) + step.value = InstallStep.Error + } } } - 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) + activeJobs[extension.pkgName] = job + + return step.asStateFlow() + .onCompletion { + activeJobs.remove(extension.pkgName) + activeSteps.remove(downloadId) + job.cancel() } - } } - /** - * 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 = 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)) - } - - emit(downloadStatus) - - // Stop polling when the download fails or finishes - if ( - downloadStatus == DownloadManager.STATUS_SUCCESSFUL || - downloadStatus == DownloadManager.STATUS_FAILED - ) { - return@flow - } - - delay(1.seconds) - } - } - // 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() - 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) } } @@ -201,9 +140,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()) } /** @@ -230,91 +168,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. - */ - 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://" } }