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 +249,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)
@@ -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,13 +311,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..cdc56cfee1 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,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.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"
}
}
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..9b2ceda0d7 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,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>()
+ /** 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 +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 {
+ private fun pollStatus(id: Long): Observable {
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 {
+ 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)
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 efe255be0a..a52d4a9d47 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
@@ -29,6 +30,7 @@ import uy.kohesive.injekt.api.get
typealias ExtensionTuple =
Triple, List, List>
+typealias ExtensionIntallInfo = Pair
/**
* Presenter of [ExtensionBottomSheet].
@@ -47,7 +49,7 @@ class ExtensionBottomPresenter(
var mangaItems = hashMapOf>()
private set
- private var currentDownloads = hashMapOf()
+ private var currentDownloads = hashMapOf()
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.subscribeToInstallUpdate(extension: Extension) {
+ private fun Observable.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)
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..da24759def 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,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
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..32d102fe38 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,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(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?
) {
- 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..5a14c3702f 100644
--- a/app/src/main/res/layout/extension_card_item.xml
+++ b/app/src/main/res/layout/extension_card_item.xml
@@ -11,6 +11,23 @@
android:layout_height="64dp"
android:background="@drawable/list_item_selector">
+
+
-
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginEnd="16dp">
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5bcdfaf39e..69620eaa32 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -304,6 +304,7 @@
May contain NSFW (18+) content
App info
%1$s must be enabled first
+ Could not install extension
- %d update pending
- %d updates pending