Using Package Installer for installing extensions

This commit is contained in:
Jays2Kings 2021-06-23 00:41:24 -04:00
parent 050213a4a3
commit 565da98c0a
11 changed files with 242 additions and 50 deletions

View File

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

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension package eu.kanade.tachiyomi.extension
import android.content.Context import android.content.Context
import android.content.pm.PackageInstaller
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -238,7 +239,7 @@ class ExtensionManager(
* *
* @param extension The extension to be installed. * @param extension The extension to be installed.
*/ */
fun installExtension(extension: Extension.Available): Observable<InstallStep> { fun installExtension(extension: Extension.Available): Observable<Pair<InstallStep, PackageInstaller.SessionInfo?>> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension) return installer.downloadAndInstall(api.getApkUrl(extension), extension)
} }
@ -249,7 +250,7 @@ class ExtensionManager(
* *
* @param extension The extension to be updated. * @param extension The extension to be updated.
*/ */
fun updateExtension(extension: Extension.Installed): Observable<InstallStep> { fun updateExtension(extension: Extension.Installed): Observable<Pair<InstallStep, PackageInstaller.SessionInfo?>> {
val availableExt = availableExtensions.find { it.pkgName == extension.pkgName } val availableExt = availableExtensions.find { it.pkgName == extension.pkgName }
?: return Observable.empty() ?: return Observable.empty()
return installExtension(availableExt) return installExtension(availableExt)
@ -261,10 +262,28 @@ class ExtensionManager(
* @param downloadId The id of the download. * @param downloadId The id of the download.
* @param result Whether the extension was installed or not. * @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) 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. * Uninstalls the extension that matches the given package name.
* *
@ -293,13 +312,16 @@ class ExtensionManager(
val ctx = context val ctx = context
launchNow { launchNow {
nowTrustedExtensions.map { extension -> nowTrustedExtensions
async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) } .map { extension ->
}.map { it.await() }.forEach { result -> async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) }
if (result is LoadResult.Success) { }
registerNewExtension(result.extension) .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 package eu.kanade.tachiyomi.extension.model
enum class InstallStep { enum class InstallStep {
Pending, Downloading, Installing, Installed, Error; Pending, Downloading, Loading, Installing, Installed, Error;
fun isCompleted(): Boolean { fun isCompleted(): Boolean {
return this == Installed || this == Error return this == Installed || this == Error

View File

@ -1,12 +1,15 @@
package eu.kanade.tachiyomi.extension.util package eu.kanade.tachiyomi.extension.util
import android.app.Activity import android.app.Activity
import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionParams
import android.os.Bundle import android.os.Bundle
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.injectLazy
import uy.kohesive.injekt.api.get
/** /**
* Activity used to install extensions, because we can only receive the result of the installation * 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 { 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) { } catch (error: Exception) {
// Either install package can't be found (probably bots) or there's a security exception // Either install package can't be found (probably bots) or there's a security exception
// with the download manager. Nothing we can workaround. // with the download manager. Nothing we can workaround.
toast(error.message) toast(error.message)
} }
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == INSTALL_REQUEST_CODE) {
checkInstallationResult(resultCode)
}
finish() finish()
} }
private fun checkInstallationResult(resultCode: Int) { private fun packageInstallStep(intent: Intent) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) val extras = intent.extras
val success = resultCode == RESULT_OK if (PACKAGE_INSTALLED_ACTION == intent.action) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val extensionManager = Injekt.get<ExtensionManager>() val extensionManager: ExtensionManager by injectLazy()
extensionManager.setInstallationResult(downloadId, success) 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 { private companion object {
const val INSTALL_REQUEST_CODE = 500 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,6 +5,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.core.net.toUri import androidx.core.net.toUri
@ -46,6 +47,15 @@ internal class ExtensionInstaller(private val context: Context) {
*/ */
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>() 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 * Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process. * step in the installation process.
@ -75,13 +85,19 @@ internal class ExtensionInstaller(private val context: Context) {
activeDownloads[pkgName] = id activeDownloads[pkgName] = id
downloadsRelay.filter { it.first == 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 // Poll download status
.mergeWith(pollStatus(id)) .mergeWith(pollStatus(id))
// Poll installation status
.mergeWith(pollInstallStatus(id))
// Force an error if the download takes more than 3 minutes // 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 // Stop when the application is installed or errors
.takeUntil { it.isCompleted() } .takeUntil { it.first.isCompleted() }
// Always notify on main thread // Always notify on main thread
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
// Always remove the download when unsubscribed // 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. * @param id The id of the download to poll.
*/ */
private fun pollStatus(id: Long): Observable<InstallStep> { private fun pollStatus(id: Long): Observable<Pair<InstallStep, PackageInstaller.SessionInfo?>> {
val query = DownloadManager.Query().setFilterById(id) val query = DownloadManager.Query().setFilterById(id)
return Observable.interval(0, 1, TimeUnit.SECONDS) 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 } .takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
// Map to our model // Map to our model
.flatMap { status -> .flatMap { status ->
when (status) { val step = when (status) {
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending) DownloadManager.STATUS_PENDING -> InstallStep.Pending
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading) DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
else -> Observable.empty() else -> return@flatMap Observable.empty()
} }
Observable.just(step to null as PackageInstaller.SessionInfo?)
}
.doOnError {
Timber.e(it)
}
}
private fun pollInstallStatus(id: Long): Observable<Pair<InstallStep, PackageInstaller.SessionInfo?>> {
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 { .doOnError {
Timber.e(it) Timber.e(it)
@ -149,6 +178,22 @@ internal class ExtensionInstaller(private val context: Context) {
context.startActivity(intent) 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. * 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) { fun setInstallationResult(downloadId: Long, result: Boolean) {
val step = if (result) InstallStep.Installed else InstallStep.Error val step = if (result) InstallStep.Installed else InstallStep.Error
downloadInstallerMap.remove(downloadId)
downloadsRelay.call(downloadId to step) downloadsRelay.call(downloadId to step)
} }
@ -220,7 +266,7 @@ internal class ExtensionInstaller(private val context: Context) {
// Set next installation step // Set next installation step
if (uri != null) { if (uri != null) {
downloadsRelay.call(id to InstallStep.Installing) downloadsRelay.call(id to InstallStep.Loading)
} else { } else {
Timber.e("Couldn't locate downloaded APK") Timber.e("Couldn't locate downloaded APK")
downloadsRelay.call(id to InstallStep.Error) downloadsRelay.call(id to InstallStep.Error)

View File

@ -19,9 +19,10 @@ class ExtensionAdapter(val listener: OnButtonClickListener) :
/** /**
* Listener for browse item clicks. * Listener for browse item clicks.
*/ */
val buttonClickListener: ExtensionAdapter.OnButtonClickListener = listener val buttonClickListener: OnButtonClickListener = listener
interface OnButtonClickListener { interface OnButtonClickListener {
fun onButtonClick(position: Int) fun onButtonClick(position: Int)
fun onCancelClick(position: Int)
} }
} }

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.extension package eu.kanade.tachiyomi.ui.extension
import android.content.pm.PackageInstaller
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
@ -211,12 +212,19 @@ class ExtensionBottomPresenter(
fun getAutoCheckPref() = preferences.automaticExtUpdates() fun getAutoCheckPref() = preferences.automaticExtUpdates()
@Synchronized @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 extensions = extensions.toMutableList()
val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
return if (position != -1) { return if (position != -1) {
val item = extensions[position].copy(installStep = state) val item = extensions[position].copy(
installStep = state,
session = session
)
extensions[position] = item extensions[position] = item
this.extensions = extensions 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) { fun installExtension(extension: Extension.Available) {
extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) extensionManager.installExtension(extension).subscribeToInstallUpdate(extension)
} }
@ -234,10 +247,10 @@ class ExtensionBottomPresenter(
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
} }
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) { private fun Observable<Pair<InstallStep, PackageInstaller.SessionInfo?>>.subscribeToInstallUpdate(extension: Extension) {
this.doOnNext { currentDownloads[extension.pkgName] = it } this.doOnNext { currentDownloads[extension.pkgName] = it.first }
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
.map { state -> updateInstallStep(extension, state) } .map { state -> updateInstallStep(extension, state.first, state.second) }
.subscribe { item -> .subscribe { item ->
if (item != null) { if (item != null) {
bottomSheet.downloadUpdate(item) 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 { override fun onItemClick(view: View?, position: Int): Boolean {
when (binding.tabs.selectedTabPosition) { when (binding.tabs.selectedTabPosition) {
0 -> { 0 -> {

View File

@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.ui.extension
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.view.View import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import coil.clear import coil.clear
import coil.load import coil.load
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -26,6 +28,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
binding.extButton.setOnClickListener { binding.extButton.setOnClickListener {
adapter.buttonClickListener.onButtonClick(flexibleAdapterPosition) adapter.buttonClickListener.onButtonClick(flexibleAdapterPosition)
} }
binding.cancelButton.setOnClickListener {
adapter.buttonClickListener.onCancelClick(flexibleAdapterPosition)
}
} }
private val shouldLabelNsfw by lazy { 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 is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.unofficial)
extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.nsfw_short) extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.nsfw_short)
else -> "" 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() binding.sourceImage.clear()
@ -65,6 +73,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
isClickable = true isClickable = true
isActivated = false 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 extension = item.extension
val installStep = item.installStep val installStep = item.installStep
strokeColor = ColorStateList.valueOf(Color.TRANSPARENT) strokeColor = ColorStateList.valueOf(Color.TRANSPARENT)
@ -73,6 +84,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
when (installStep) { when (installStep) {
InstallStep.Pending -> R.string.pending InstallStep.Pending -> R.string.pending
InstallStep.Downloading -> R.string.downloading InstallStep.Downloading -> R.string.downloading
InstallStep.Loading -> R.string.loading
InstallStep.Installing -> R.string.installing InstallStep.Installing -> R.string.installing
InstallStep.Installed -> R.string.installed InstallStep.Installed -> R.string.installed
InstallStep.Error -> R.string.retry InstallStep.Error -> R.string.retry

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.extension package eu.kanade.tachiyomi.ui.extension
import android.content.pm.PackageInstaller
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
@ -19,10 +20,14 @@ import eu.kanade.tachiyomi.source.CatalogueSource
data class ExtensionItem( data class ExtensionItem(
val extension: Extension, val extension: Extension,
val header: ExtensionGroupItem? = null, val header: ExtensionGroupItem? = null,
val installStep: InstallStep? = null val installStep: InstallStep? = null,
val session: PackageInstaller.SessionInfo? = null
) : ) :
AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) { AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
val sessionProgress: Int?
get() = (session?.progress?.times(100)?.toInt())
/** /**
* Returns the layout resource of this item. * Returns the layout resource of this item.
*/ */
@ -46,7 +51,7 @@ data class ExtensionItem(
position: Int, position: Int,
payloads: MutableList<Any?>? payloads: MutableList<Any?>?
) { ) {
if (payloads == null || payloads.isEmpty()) { if (payloads.isNullOrEmpty()) {
holder.bind(this) holder.bind(this)
} else { } else {
holder.bindButton(this) holder.bindButton(this)

View File

@ -11,6 +11,24 @@
android:layout_height="64dp" android:layout_height="64dp"
android:background="@drawable/list_item_selector"> android:background="@drawable/list_item_selector">
<!-- style="@android:style/Widget.ProgressBar.Horizontal"-->
<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 <ImageView
android:id="@+id/source_image" android:id="@+id/source_image"
android:layout_width="0dp" android:layout_width="0dp"
@ -33,7 +51,7 @@
android:textAppearance="@style/TextAppearance.Regular.SubHeading" android:textAppearance="@style/TextAppearance.Regular.SubHeading"
android:textSize="14sp" android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@id/lang" app:layout_constraintBottom_toTopOf="@id/lang"
app:layout_constraintEnd_toStartOf="@id/ext_button" app:layout_constraintEnd_toStartOf="@id/cancel_button"
app:layout_constraintStart_toEndOf="@id/source_image" app:layout_constraintStart_toEndOf="@id/source_image"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" app:layout_constraintVertical_chainStyle="packed"
@ -78,6 +96,24 @@
app:layout_constraintTop_toBottomOf="@+id/ext_title" app:layout_constraintTop_toBottomOf="@+id/ext_title"
tools:text="Warning" /> tools:text="Warning" />
<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/next_title"
android:padding="12dp"
android:tooltipText="@string/stop"
app:layout_constraintEnd_toStartOf="@id/ext_button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/ext_title"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginEnd="4dp"
app:srcCompat="@drawable/ic_close_24dp"
app:tint="?colorAccent" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/ext_button" android:id="@+id/ext_button"
style="@style/Theme.Widget.Button.OutlinedButtonAccent" style="@style/Theme.Widget.Button.OutlinedButtonAccent"
@ -87,6 +123,7 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:layout_constraintStart_toEndOf="@id/cancel_button"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"