Merge branch 'ExtensionsUsingPackageInstaller'

This commit is contained in:
Jays2Kings 2021-07-03 00:52:04 -04:00
commit 49e0d561b2
12 changed files with 270 additions and 59 deletions

View File

@ -175,6 +175,7 @@
<activity
android:name=".extension.util.ExtensionInstallActivity"
android:icon="@mipmap/ic_launcher"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
<activity

View File

@ -7,13 +7,13 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.system.launchNow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -238,7 +238,7 @@ class ExtensionManager(
*
* @param extension The extension to be installed.
*/
fun installExtension(extension: Extension.Available): Observable<InstallStep> {
fun installExtension(extension: Extension.Available): Observable<ExtensionIntallInfo> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
}
@ -249,7 +249,7 @@ class ExtensionManager(
*
* @param extension The extension to be updated.
*/
fun updateExtension(extension: Extension.Installed): Observable<InstallStep> {
fun updateExtension(extension: Extension.Installed): Observable<ExtensionIntallInfo> {
val availableExt = availableExtensions.find { it.pkgName == extension.pkgName }
?: return Observable.empty()
return installExtension(availableExt)
@ -265,6 +265,24 @@ class ExtensionManager(
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,9 +311,12 @@ class ExtensionManager(
val ctx = context
launchNow {
nowTrustedExtensions.map { extension ->
nowTrustedExtensions
.map { extension ->
async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) }
}.map { it.await() }.forEach { result ->
}
.map { it.await() }
.forEach { result ->
if (result is LoadResult.Success) {
registerNewExtension(result.extension)
}

View File

@ -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

View File

@ -1,12 +1,16 @@
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.R
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 +20,87 @@ 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>()
extensionManager.setInstallationResult(downloadId, success)
private fun packageInstallStep(intent: Intent) {
val extras = intent.extras ?: return
if (PACKAGE_INSTALLED_ACTION == intent.action) {
val downloadId = extras.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val extensionManager: ExtensionManager by injectLazy()
when (val status = 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.setInstallationResult(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.setInstallationResult(downloadId, false)
if (status != PackageInstaller.STATUS_FAILURE_ABORTED) {
toast(R.string.could_not_install_extension)
}
finish()
}
else -> {
extensionManager.setInstallationResult(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"
}
}

View File

@ -5,12 +5,14 @@ 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
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.storage.getUriCompat
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
@ -46,6 +48,15 @@ internal class ExtensionInstaller(private val context: Context) {
*/
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
/** Map of download id to installer session id */
val downloadInstallerMap = hashMapOf<Long, Int>()
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 +86,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 +111,7 @@ internal class ExtensionInstaller(private val context: Context) {
*
* @param id The id of the download to poll.
*/
private fun pollStatus(id: Long): Observable<InstallStep> {
private fun pollStatus(id: Long): Observable<ExtensionIntallInfo> {
val query = DownloadManager.Query().setFilterById(id)
return Observable.interval(0, 1, TimeUnit.SECONDS)
@ -111,11 +128,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(ExtensionIntallInfo(step, null))
}
.doOnError {
Timber.e(it)
}
}
private fun pollInstallStatus(id: Long): Observable<ExtensionIntallInfo> {
return Observable.interval(0, 500, 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 +179,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 +203,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 +267,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)

View File

@ -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)
}
}

View File

@ -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
@ -29,6 +30,7 @@ import uy.kohesive.injekt.api.get
typealias ExtensionTuple =
Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
typealias ExtensionIntallInfo = Pair<InstallStep, PackageInstaller.SessionInfo?>
/**
* Presenter of [ExtensionBottomSheet].
@ -47,7 +49,7 @@ class ExtensionBottomPresenter(
var mangaItems = hashMapOf<Long, List<MangaItem>>()
private set
private var currentDownloads = hashMapOf<String, InstallStep>()
private var currentDownloads = hashMapOf<String, ExtensionIntallInfo>()
private val sourceManager: SourceManager = Injekt.get()
@ -211,12 +213,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 +235,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 +248,10 @@ class ExtensionBottomPresenter(
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
}
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
private fun Observable<ExtensionIntallInfo>.subscribeToInstallUpdate(extension: Extension) {
this.doOnNext { currentDownloads[extension.pkgName] = it }
.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)

View File

@ -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 -> {

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.extension
import android.content.res.ColorStateList
import android.graphics.Color
import android.view.View
import androidx.core.view.isVisible
import coil.clear
import coil.load
import eu.kanade.tachiyomi.R
@ -26,6 +27,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 +49,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 +72,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 +83,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

View File

@ -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,20 @@ 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<ExtensionHolder, ExtensionGroupItem>(header) {
constructor(
extension: Extension,
header: ExtensionGroupItem? = null,
installInfo: ExtensionIntallInfo?
) : this(extension, header, installInfo?.first, installInfo?.second)
val sessionProgress: Int?
get() = (session?.progress?.times(100)?.toInt())
/**
* Returns the layout resource of this item.
*/
@ -46,7 +57,7 @@ data class ExtensionItem(
position: Int,
payloads: MutableList<Any?>?
) {
if (payloads == null || payloads.isEmpty()) {
if (payloads.isNullOrEmpty()) {
holder.bind(this)
} else {
holder.bindButton(this)

View File

@ -11,6 +11,23 @@
android:layout_height="64dp"
android:background="@drawable/list_item_selector">
<ProgressBar
android:id="@+id/install_progress"
android:layout_width="0dp"
style="?android:attr/progressBarStyleHorizontal"
android:progressBackgroundTint="?android:attr/colorBackground"
android:progressTint="?colorAccent"
android:layout_height="2dp"
android:visibility="gone"
tools:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toBottomOf="parent"
tools:progress="45"
android:max="100"
/>
<ImageView
android:id="@+id/source_image"
android:layout_width="0dp"
@ -33,7 +50,7 @@
android:textAppearance="@style/TextAppearance.Regular.SubHeading"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@id/lang"
app:layout_constraintEnd_toStartOf="@id/ext_button"
app:layout_constraintEnd_toStartOf="@id/button_layout"
app:layout_constraintStart_toEndOf="@id/source_image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
@ -78,6 +95,16 @@
app:layout_constraintTop_toBottomOf="@+id/ext_title"
tools:text="Warning" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/button_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@id/ext_title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/ext_button"
style="@style/Theme.Widget.Button.OutlinedButtonAccent"
@ -85,13 +112,31 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/cancel_button"
app:layout_constraintTop_toTopOf="parent"
tools:text="Details" />
<ImageButton
android:id="@+id/cancel_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintWidth_max="wrap"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/stop"
android:padding="12dp"
android:tooltipText="@string/stop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/ext_button"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_close_24dp"
app:tint="?colorAccent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@ -304,6 +304,7 @@
<string name="may_contain_nsfw">May contain NSFW (18+) content</string>
<string name="app_info">App info</string>
<string name="_must_be_enabled_first">%1$s must be enabled first</string>
<string name="could_not_install_extension">Could not install extension</string>
<plurals name="_updates_pending">
<item quantity="one">%d update pending</item>
<item quantity="other">%d updates pending</item>