diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 241f331a98..30fc80dd98 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -175,6 +175,7 @@ { + fun installExtension(extension: Extension.Available): Observable> { return installer.downloadAndInstall(api.getApkUrl(extension), extension) } @@ -249,7 +250,7 @@ class ExtensionManager( * * @param extension The extension to be updated. */ - fun updateExtension(extension: Extension.Installed): Observable { + fun updateExtension(extension: Extension.Installed): Observable> { val availableExt = availableExtensions.find { it.pkgName == extension.pkgName } ?: return Observable.empty() return installExtension(availableExt) @@ -261,10 +262,28 @@ class ExtensionManager( * @param downloadId The id of the download. * @param result Whether the extension was installed or not. */ - fun setInstallationResult(downloadId: Long, result: Boolean) { + fun cancelInstallation(downloadId: Long, result: Boolean) { installer.setInstallationResult(downloadId, result) } + /** Sets the result of the installation of an extension. + * + * @param sessionId The id of the download. + * @param result Whether the extension was installed or not. + */ + fun cancelInstallation(sessionId: Int) { + installer.cancelInstallation(sessionId) + } + + /** + * Sets the result of the installation of an extension. + * + * @param downloadId The id of the download. + */ + fun setInstalling(downloadId: Long, sessionId: Int) { + installer.setInstalling(downloadId, sessionId) + } + /** * Uninstalls the extension that matches the given package name. * @@ -293,13 +312,16 @@ class ExtensionManager( val ctx = context launchNow { - nowTrustedExtensions.map { extension -> - async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) } - }.map { it.await() }.forEach { result -> - if (result is LoadResult.Success) { - registerNewExtension(result.extension) + nowTrustedExtensions + .map { extension -> + async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) } + } + .map { it.await() } + .forEach { result -> + if (result is LoadResult.Success) { + registerNewExtension(result.extension) + } } - } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt index 43bb5198d5..c50188bb62 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.extension.model enum class InstallStep { - Pending, Downloading, Installing, Installed, Error; + Pending, Downloading, Loading, Installing, Installed, Error; fun isCompleted(): Boolean { return this == Installed || this == 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 1623a5d7d6..8fd8506d6d 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 @@ -1,12 +1,15 @@ package eu.kanade.tachiyomi.extension.util import android.app.Activity +import android.app.PendingIntent import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.SessionParams import android.os.Bundle +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.util.system.toast -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy /** * Activity used to install extensions, because we can only receive the result of the installation @@ -16,37 +19,84 @@ class ExtensionInstallActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE) - .setDataAndType(intent.data, intent.type) - .putExtra(Intent.EXTRA_RETURN_RESULT, true) - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - try { - startActivityForResult(installIntent, INSTALL_REQUEST_CODE) + if (PACKAGE_INSTALLED_ACTION == intent.action) { + packageInstallStep(intent) + return + } + + val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) + val packageInstaller = packageManager.packageInstaller + val data = UniFile.fromUri(this, intent.data).openInputStream() + + val params = SessionParams( + SessionParams.MODE_FULL_INSTALL + ) + // TODO: Add once compiling via SDK 31 +// if (Build.VERSION.SDK_INT >= 31) { +// params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) +// } + val sessionId = packageInstaller.createSession(params) + val session = packageInstaller.openSession(sessionId) + session.openWrite("package", 0, -1).use { packageInSession -> + data.use { `is` -> + val buffer = ByteArray(16384) + var n: Int + while (`is`.read(buffer).also { n = it } >= 0) { + packageInSession.write(buffer, 0, n) + } + } + } + + val newIntent = Intent(this, ExtensionInstallActivity::class.java) + .setAction(PACKAGE_INSTALLED_ACTION) + .putExtra(ExtensionInstaller.EXTRA_DOWNLOAD_ID, downloadId) + .putExtra(EXTRA_SESSION_ID, sessionId) + + val pendingIntent = PendingIntent.getActivity(this, downloadId.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val statusReceiver = pendingIntent.intentSender + session.commit(statusReceiver) + val extensionManager: ExtensionManager by injectLazy() + extensionManager.setInstalling(downloadId, sessionId) } catch (error: Exception) { // Either install package can't be found (probably bots) or there's a security exception // with the download manager. Nothing we can workaround. toast(error.message) } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == INSTALL_REQUEST_CODE) { - checkInstallationResult(resultCode) - } finish() } - private fun checkInstallationResult(resultCode: Int) { - val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) - val success = resultCode == RESULT_OK - - val extensionManager = Injekt.get() - extensionManager.setInstallationResult(downloadId, success) + private fun packageInstallStep(intent: Intent) { + val extras = intent.extras + if (PACKAGE_INSTALLED_ACTION == intent.action) { + val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) + val extensionManager: ExtensionManager by injectLazy() + when (extras!!.getInt(PackageInstaller.EXTRA_STATUS)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val confirmIntent = extras[Intent.EXTRA_INTENT] as? Intent + startActivityForResult(confirmIntent, INSTALL_REQUEST_CODE) + finish() + } + PackageInstaller.STATUS_SUCCESS -> { + extensionManager.cancelInstallation(downloadId, true) + finish() + } + PackageInstaller.STATUS_FAILURE, PackageInstaller.STATUS_FAILURE_ABORTED, PackageInstaller.STATUS_FAILURE_BLOCKED, PackageInstaller.STATUS_FAILURE_CONFLICT, PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, PackageInstaller.STATUS_FAILURE_INVALID, PackageInstaller.STATUS_FAILURE_STORAGE -> { + extensionManager.cancelInstallation(downloadId, false) + finish() + } + else -> { + extensionManager.cancelInstallation(downloadId, false) + finish() + } + } + } } private companion object { const val INSTALL_REQUEST_CODE = 500 + const val EXTRA_SESSION_ID = "ExtensionInstaller.extra.SESSION_ID" + const val PACKAGE_INSTALLED_ACTION = + "eu.kanade.tachiyomi.SESSION_API_PACKAGE_INSTALLED" } } 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 a58543d220..846c353777 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 @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageInstaller import android.net.Uri import android.os.Environment import androidx.core.net.toUri @@ -46,6 +47,15 @@ internal class ExtensionInstaller(private val context: Context) { */ private val downloadsRelay = PublishRelay.create>() + /** Map of download id to installer session id */ + val downloadInstallerMap = hashMapOf() + + data class DownloadSessionInfo( + val downloadId: Long, + val session: PackageInstaller.Session, + val sessionId: Int + ) + /** * Adds the given extension to the downloads queue and returns an observable containing its * step in the installation process. @@ -75,13 +85,19 @@ internal class ExtensionInstaller(private val context: Context) { activeDownloads[pkgName] = id downloadsRelay.filter { it.first == id } - .map { it.second } + .map { + val sessionId = downloadInstallerMap[it.first] ?: return@map it.second to null + val session = context.packageManager.packageInstaller.getSessionInfo(sessionId) + it.second to session + } // Poll download status .mergeWith(pollStatus(id)) + // Poll installation status + .mergeWith(pollInstallStatus(id)) // Force an error if the download takes more than 3 minutes - .mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error }) + .mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error to null }) // Stop when the application is installed or errors - .takeUntil { it.isCompleted() } + .takeUntil { it.first.isCompleted() } // Always notify on main thread .observeOn(AndroidSchedulers.mainThread()) // Always remove the download when unsubscribed @@ -94,7 +110,7 @@ internal class ExtensionInstaller(private val context: Context) { * * @param id The id of the download to poll. */ - private fun pollStatus(id: Long): Observable { + private fun pollStatus(id: Long): Observable> { val query = DownloadManager.Query().setFilterById(id) return Observable.interval(0, 1, TimeUnit.SECONDS) @@ -111,11 +127,24 @@ internal class ExtensionInstaller(private val context: Context) { .takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED } // Map to our model .flatMap { status -> - when (status) { - DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending) - DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading) - else -> Observable.empty() + val step = when (status) { + DownloadManager.STATUS_PENDING -> InstallStep.Pending + DownloadManager.STATUS_RUNNING -> InstallStep.Downloading + else -> return@flatMap Observable.empty() } + Observable.just(step to null as PackageInstaller.SessionInfo?) + } + .doOnError { + Timber.e(it) + } + } + + private fun pollInstallStatus(id: Long): Observable> { + return Observable.interval(0, 250, TimeUnit.MILLISECONDS) + .flatMap { + val sessionId = downloadInstallerMap[id] ?: return@flatMap Observable.empty() + val session = context.packageManager.packageInstaller.getSessionInfo(sessionId) + Observable.just(InstallStep.Installing to session) } .doOnError { Timber.e(it) @@ -149,6 +178,22 @@ internal class ExtensionInstaller(private val context: Context) { context.startActivity(intent) } + /** + * Sets the result of the installation of an extension. + * + * @param downloadId The id of the download. + */ + fun setInstalling(downloadId: Long, sessionId: Int) { + downloadsRelay.call(downloadId to InstallStep.Installing) + downloadInstallerMap[downloadId] = sessionId + } + + fun cancelInstallation(sessionId: Int) { + val downloadId = downloadInstallerMap.entries.find { it.value == sessionId }?.key ?: return + setInstallationResult(downloadId, false) + context.packageManager.packageInstaller.abandonSession(sessionId) + } + /** * Sets the result of the installation of an extension. * @@ -157,6 +202,7 @@ internal class ExtensionInstaller(private val context: Context) { */ fun setInstallationResult(downloadId: Long, result: Boolean) { val step = if (result) InstallStep.Installed else InstallStep.Error + downloadInstallerMap.remove(downloadId) downloadsRelay.call(downloadId to step) } @@ -220,7 +266,7 @@ internal class ExtensionInstaller(private val context: Context) { // Set next installation step if (uri != null) { - downloadsRelay.call(id to InstallStep.Installing) + downloadsRelay.call(id to InstallStep.Loading) } else { Timber.e("Couldn't locate downloaded APK") downloadsRelay.call(id to InstallStep.Error) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt index 0354744ae8..a0d4dc24b8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt @@ -19,9 +19,10 @@ class ExtensionAdapter(val listener: OnButtonClickListener) : /** * Listener for browse item clicks. */ - val buttonClickListener: ExtensionAdapter.OnButtonClickListener = listener + val buttonClickListener: OnButtonClickListener = listener interface OnButtonClickListener { fun onButtonClick(position: Int) + fun onCancelClick(position: Int) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt index 3598a89e79..82183e3ddf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.ui.extension +import android.content.pm.PackageInstaller import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga @@ -211,12 +212,19 @@ class ExtensionBottomPresenter( fun getAutoCheckPref() = preferences.automaticExtUpdates() @Synchronized - private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? { + private fun updateInstallStep( + extension: Extension, + state: InstallStep, + session: PackageInstaller.SessionInfo? + ): ExtensionItem? { val extensions = extensions.toMutableList() val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } return if (position != -1) { - val item = extensions[position].copy(installStep = state) + val item = extensions[position].copy( + installStep = state, + session = session + ) extensions[position] = item this.extensions = extensions @@ -226,6 +234,11 @@ class ExtensionBottomPresenter( } } + fun cancelExtensionInstall(extItem: ExtensionItem) { + val sessionId = extItem.session?.sessionId ?: return + extensionManager.cancelInstallation(sessionId) + } + fun installExtension(extension: Extension.Available) { extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) } @@ -234,10 +247,10 @@ class ExtensionBottomPresenter( extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) } - private fun Observable.subscribeToInstallUpdate(extension: Extension) { - this.doOnNext { currentDownloads[extension.pkgName] = it } + private fun Observable>.subscribeToInstallUpdate(extension: Extension) { + this.doOnNext { currentDownloads[extension.pkgName] = it.first } .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } - .map { state -> updateInstallStep(extension, state) } + .map { state -> updateInstallStep(extension, state.first, state.second) } .subscribe { item -> if (item != null) { bottomSheet.downloadUpdate(item) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt index 5c011af763..efd1312963 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt @@ -196,6 +196,11 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At } } + override fun onCancelClick(position: Int) { + val extension = (extAdapter?.getItem(position) as? ExtensionItem) ?: return + presenter.cancelExtensionInstall(extension) + } + override fun onItemClick(view: View?, position: Int): Boolean { when (binding.tabs.selectedTabPosition) { 0 -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt index 7721b26e8b..c619e6c7a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt @@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.ui.extension import android.content.res.ColorStateList import android.graphics.Color import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import coil.clear import coil.load import eu.kanade.tachiyomi.R @@ -26,6 +28,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : binding.extButton.setOnClickListener { adapter.buttonClickListener.onButtonClick(flexibleAdapterPosition) } + binding.cancelButton.setOnClickListener { + adapter.buttonClickListener.onCancelClick(flexibleAdapterPosition) + } } private val shouldLabelNsfw by lazy { @@ -45,7 +50,10 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.unofficial) extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.nsfw_short) else -> "" - }.toUpperCase(Locale.ROOT) + }.uppercase(Locale.ROOT) + binding.installProgress.progress = item.sessionProgress ?: 0 + binding.installProgress.isVisible = item.sessionProgress != null + binding.cancelButton.isVisible = item.sessionProgress != null binding.sourceImage.clear() @@ -65,6 +73,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : isClickable = true isActivated = false + binding.installProgress.progress = item.sessionProgress ?: 0 + binding.cancelButton.isVisible = item.sessionProgress != null + binding.installProgress.isVisible = item.sessionProgress != null val extension = item.extension val installStep = item.installStep strokeColor = ColorStateList.valueOf(Color.TRANSPARENT) @@ -73,6 +84,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : when (installStep) { InstallStep.Pending -> R.string.pending InstallStep.Downloading -> R.string.downloading + InstallStep.Loading -> R.string.loading InstallStep.Installing -> R.string.installing InstallStep.Installed -> R.string.installed InstallStep.Error -> R.string.retry diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt index dafe4eb0b1..2266a60cd2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.ui.extension +import android.content.pm.PackageInstaller import android.view.View import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter @@ -19,10 +20,14 @@ import eu.kanade.tachiyomi.source.CatalogueSource data class ExtensionItem( val extension: Extension, val header: ExtensionGroupItem? = null, - val installStep: InstallStep? = null + val installStep: InstallStep? = null, + val session: PackageInstaller.SessionInfo? = null ) : AbstractSectionableItem(header) { + val sessionProgress: Int? + get() = (session?.progress?.times(100)?.toInt()) + /** * Returns the layout resource of this item. */ @@ -46,7 +51,7 @@ data class ExtensionItem( position: Int, payloads: MutableList? ) { - if (payloads == null || payloads.isEmpty()) { + if (payloads.isNullOrEmpty()) { holder.bind(this) } else { holder.bindButton(this) diff --git a/app/src/main/res/layout/extension_card_item.xml b/app/src/main/res/layout/extension_card_item.xml index f041b165ce..b9007feaa3 100644 --- a/app/src/main/res/layout/extension_card_item.xml +++ b/app/src/main/res/layout/extension_card_item.xml @@ -11,6 +11,24 @@ android:layout_height="64dp" android:background="@drawable/list_item_selector"> + + + + +