diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index aa2dc6c9cb..9a7e5ccc37 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -261,6 +261,11 @@ dependencies {
// Licenses
implementation("com.mikepenz:aboutlibraries-core:${BuildPluginsVersion.ABOUTLIB_PLUGIN}")
+ // Shizuku
+ val shizukuVersion = "12.0.0"
+ implementation("dev.rikka.shizuku:api:$shizukuVersion")
+ implementation("dev.rikka.shizuku:provider:$shizukuVersion")
+
// Tests
testImplementation("junit:junit:4.13.2")
testImplementation("org.assertj:assertj-core:3.16.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7e506d9a7f..d8447926d4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -18,6 +18,7 @@
+
@@ -188,6 +189,9 @@
android:name=".data.backup.BackupRestoreService"
android:exported="false" />
+
+
+
+
(null)
+ private val queue = Collections.synchronizedList(mutableListOf())
+
+ private val cancelReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1).takeIf { it >= 0 } ?: return
+ cancelQueue(downloadId)
+ }
+ }
+
+ /**
+ * Installer readiness. If false, queue check will not run.
+ *
+ * @see checkQueue
+ */
+ abstract var ready: Boolean
+
+ /**
+ * Add an item to install queue.
+ *
+ * @param downloadId Download ID as known by [ExtensionManager]
+ * @param uri Uri of APK to install
+ */
+ fun addToQueue(downloadId: Long, uri: Uri) {
+ queue.add(Entry(downloadId, uri))
+ checkQueue()
+ }
+
+ /**
+ * Proceeds to install the APK of this entry inside this method. Call [continueQueue]
+ * when the install process for this entry is finished to continue the queue.
+ *
+ * @param entry The [Entry] of item to process
+ * @see continueQueue
+ */
+ @CallSuper
+ open fun processEntry(entry: Entry) {
+ extensionManager.setInstalling(entry.downloadId)
+ }
+
+ /**
+ * Called before queue continues. Override this to handle when the removed entry is
+ * currently being processed.
+ *
+ * @return true if this entry can be removed from queue.
+ */
+ open fun cancelEntry(entry: Entry): Boolean {
+ return true
+ }
+
+ /**
+ * Tells the queue to continue processing the next entry and updates the install step
+ * of the completed entry ([waitingInstall]) to [ExtensionManager].
+ *
+ * @param resultStep new install step for the processed entry.
+ * @see waitingInstall
+ */
+ fun continueQueue(resultStep: InstallStep) {
+ val completedEntry = waitingInstall.getAndSet(null)
+ if (completedEntry != null) {
+ extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
+ checkQueue()
+ }
+ }
+
+ /**
+ * Checks the queue. The provided service will be stopped if the queue is empty.
+ * Will not be run when not ready.
+ *
+ * @see ready
+ */
+ fun checkQueue() {
+ if (!ready) {
+ return
+ }
+ if (queue.isEmpty()) {
+ service.stopSelf()
+ return
+ }
+ val nextEntry = queue.first()
+ if (waitingInstall.compareAndSet(null, nextEntry)) {
+ queue.removeFirst()
+ processEntry(nextEntry)
+ }
+ }
+
+ /**
+ * Call this method when the provided service is destroyed.
+ */
+ @CallSuper
+ open fun onDestroy() {
+ LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
+ queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
+ queue.clear()
+ waitingInstall.set(null)
+ }
+
+ protected fun getActiveEntry(): Entry? = waitingInstall.get()
+
+ /**
+ * Cancels queue for the provided download ID if exists.
+ *
+ * @param downloadId Download ID as known by [ExtensionManager]
+ */
+ private fun cancelQueue(downloadId: Long) {
+ val waitingInstall = this.waitingInstall.get()
+ val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
+ if (cancelEntry(toCancel)) {
+ queue.remove(toCancel)
+ if (waitingInstall == toCancel) {
+ // Currently processing removed entry, continue queue
+ this.waitingInstall.set(null)
+ checkQueue()
+ }
+ extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
+ }
+ }
+
+ /**
+ * Install item to queue.
+ *
+ * @param downloadId Download ID as known by [ExtensionManager]
+ * @param uri Uri of APK to install
+ */
+ data class Entry(val downloadId: Long, val uri: Uri)
+
+ init {
+ val filter = IntentFilter(ACTION_CANCEL_QUEUE)
+ LocalBroadcastManager.getInstance(service).registerReceiver(cancelReceiver, filter)
+ }
+
+ companion object {
+ private const val ACTION_CANCEL_QUEUE = "Installer.action.CANCEL_QUEUE"
+ private const val EXTRA_DOWNLOAD_ID = "Installer.extra.DOWNLOAD_ID"
+
+ /**
+ * Attempts to cancel the installation entry for the provided download ID.
+ *
+ * @param downloadId Download ID as known by [ExtensionManager]
+ */
+ fun cancelInstallQueue(context: Context, downloadId: Long) {
+ val intent = Intent(ACTION_CANCEL_QUEUE)
+ intent.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
+ }
+ }
+}
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
new file mode 100644
index 0000000000..9dd03f261b
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt
@@ -0,0 +1,105 @@
+package eu.kanade.tachiyomi.extension.installer
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInstaller
+import android.os.Build
+import eu.kanade.tachiyomi.extension.model.InstallStep
+import eu.kanade.tachiyomi.util.lang.use
+import eu.kanade.tachiyomi.util.system.getUriSize
+import timber.log.Timber
+
+class PackageInstallerInstaller(private val service: Service) : Installer(service) {
+
+ private val packageInstaller = service.packageManager.packageInstaller
+
+ private val packageActionReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ val userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT)
+ if (userAction == null) {
+ Timber.e("Fatal error for $intent")
+ continueQueue(InstallStep.Error)
+ return
+ }
+ userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ service.startActivity(userAction)
+ }
+ PackageInstaller.STATUS_FAILURE_ABORTED -> {
+ continueQueue(InstallStep.Idle)
+ }
+ PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
+ else -> continueQueue(InstallStep.Error)
+ }
+ }
+ }
+
+ private var activeSession: Pair? = null
+
+ // Always ready
+ override var ready = true
+
+ override fun processEntry(entry: Entry) {
+ super.processEntry(entry)
+ activeSession = null
+ try {
+ val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
+ }
+ activeSession = entry to packageInstaller.createSession(installParams)
+ val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException()
+ installParams.setSize(fileSize)
+
+ val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException()
+ val session = packageInstaller.openSession(activeSession!!.second)
+ val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize)
+ session.use {
+ arrayOf(inputStream, outputStream).use {
+ inputStream.copyTo(outputStream)
+ session.fsync(outputStream)
+ }
+
+ val intentSender = PendingIntent.getBroadcast(
+ service,
+ activeSession!!.second,
+ Intent(INSTALL_ACTION),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
+ ).intentSender
+ session.commit(intentSender)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
+ activeSession?.let { (_, sessionId) ->
+ packageInstaller.abandonSession(sessionId)
+ }
+ continueQueue(InstallStep.Error)
+ }
+ }
+
+ override fun cancelEntry(entry: Entry): Boolean {
+ activeSession?.let { (activeEntry, sessionId) ->
+ if (activeEntry == entry) {
+ packageInstaller.abandonSession(sessionId)
+ return false
+ }
+ }
+ return true
+ }
+
+ override fun onDestroy() {
+ service.unregisterReceiver(packageActionReceiver)
+ super.onDestroy()
+ }
+
+ init {
+ service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
+ }
+}
+
+private const val INSTALL_ACTION = "PackageInstallerInstaller.INSTALL_ACTION"
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
new file mode 100644
index 0000000000..94ce04ce5d
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt
@@ -0,0 +1,127 @@
+package eu.kanade.tachiyomi.extension.installer
+
+import android.app.Service
+import android.content.pm.PackageManager
+import android.os.Build
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.extension.model.InstallStep
+import eu.kanade.tachiyomi.util.system.getUriSize
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import rikka.shizuku.Shizuku
+import timber.log.Timber
+import java.io.BufferedReader
+import java.io.InputStream
+
+class ShizukuInstaller(private val service: Service) : Installer(service) {
+
+ private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private val shizukuDeadListener = Shizuku.OnBinderDeadListener {
+ Timber.e("Shizuku was killed prematurely")
+ service.stopSelf()
+ }
+
+ private val shizukuPermissionListener = object : Shizuku.OnRequestPermissionResultListener {
+ override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
+ if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
+ if (grantResult == PackageManager.PERMISSION_GRANTED) {
+ ready = true
+ checkQueue()
+ } else {
+ service.stopSelf()
+ }
+ Shizuku.removeRequestPermissionResultListener(this)
+ }
+ }
+ }
+
+ override var ready = false
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ override fun processEntry(entry: Entry) {
+ super.processEntry(entry)
+ ioScope.launch {
+ var sessionId: String? = null
+ try {
+ val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
+ service.contentResolver.openInputStream(entry.uri)!!.use {
+ val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ "pm install-create --user current -i ${service.packageName} -S $size"
+ } else {
+ "pm install-create -i ${service.packageName} -S $size"
+ }
+ val createResult = exec(createCommand)
+ sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
+ ?: throw RuntimeException("Failed to create install session")
+
+ val writeResult = exec("pm install-write -S $size $sessionId base -", it)
+ if (writeResult.resultCode != 0) {
+ throw RuntimeException("Failed to write APK to session $sessionId")
+ }
+
+ val commitResult = exec("pm install-commit $sessionId")
+ if (commitResult.resultCode != 0) {
+ throw RuntimeException("Failed to commit install session $sessionId")
+ }
+
+ continueQueue(InstallStep.Installed)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
+ if (sessionId != null) {
+ exec("pm install-abandon $sessionId")
+ }
+ continueQueue(InstallStep.Error)
+ }
+ }
+ }
+
+ // Don't cancel if entry is already started installing
+ override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
+
+ override fun onDestroy() {
+ Shizuku.removeBinderDeadListener(shizukuDeadListener)
+ Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
+ ioScope.cancel()
+ super.onDestroy()
+ }
+
+ private fun exec(command: String, stdin: InputStream? = null): ShellResult {
+ @Suppress("DEPRECATION")
+ val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
+ if (stdin != null) {
+ process.outputStream.use { stdin.copyTo(it) }
+ }
+ val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
+ val resultCode = process.waitFor()
+ return ShellResult(resultCode, output)
+ }
+
+ private data class ShellResult(val resultCode: Int, val out: String)
+
+ init {
+ Shizuku.addBinderDeadListener(shizukuDeadListener)
+ ready = if (Shizuku.pingBinder()) {
+ if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
+ true
+ } else {
+ Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
+ Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
+ false
+ }
+ } else {
+ Timber.e("Shizuku is not ready to use.")
+ service.toast(R.string.ext_installer_shizuku_stopped)
+ service.stopSelf()
+ false
+ }
+ }
+}
+
+private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045
+private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")
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..d1049689e2 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,9 +1,9 @@
package eu.kanade.tachiyomi.extension.model
enum class InstallStep {
- Pending, Downloading, Installing, Installed, Error;
+ Idle, Pending, Downloading, Installing, Installed, Error;
fun isCompleted(): Boolean {
- return this == Installed || this == Error
+ return this == Installed || this == Error || this == Idle
}
}
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 dd83bba99f..a1d01a02f2 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
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@@ -40,10 +41,13 @@ class ExtensionInstallActivity : Activity() {
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)
+ val newStep = when (resultCode) {
+ RESULT_OK -> InstallStep.Installed
+ RESULT_CANCELED -> InstallStep.Idle
+ else -> InstallStep.Error
+ }
+ extensionManager.updateInstallStep(downloadId, newStep)
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallService.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallService.kt
new file mode 100644
index 0000000000..f63fe9f4c6
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallService.kt
@@ -0,0 +1,82 @@
+package eu.kanade.tachiyomi.extension.util
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.IBinder
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.notification.Notifications
+import eu.kanade.tachiyomi.data.preference.PreferenceValues
+import eu.kanade.tachiyomi.extension.installer.Installer
+import eu.kanade.tachiyomi.extension.installer.PackageInstallerInstaller
+import eu.kanade.tachiyomi.extension.installer.ShizukuInstaller
+import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
+import eu.kanade.tachiyomi.util.system.notificationBuilder
+import timber.log.Timber
+
+class ExtensionInstallService : Service() {
+
+ private var installer: Installer? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ val notification = notificationBuilder(Notifications.CHANNEL_DOWNLOADER_PROGRESS) {
+ setSmallIcon(R.drawable.ic_tachi)
+ setAutoCancel(false)
+ setOngoing(true)
+ setShowWhen(false)
+ setContentTitle(getString(R.string.ext_install_service_notif))
+ setProgress(100, 100, true)
+ }.build()
+ startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val uri = intent?.data
+ val id = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.takeIf { it != -1L }
+ val installerUsed = intent?.getSerializableExtra(EXTRA_INSTALLER) as? PreferenceValues.ExtensionInstaller
+ if (uri == null || id == null || installerUsed == null) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ if (installer == null) {
+ installer = when (installerUsed) {
+ PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
+ PreferenceValues.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
+ else -> {
+ Timber.e("Not implemented for installer $installerUsed")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ }
+ }
+ installer!!.addToQueue(id, uri)
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ installer?.onDestroy()
+ installer = null
+ }
+
+ override fun onBind(i: Intent?): IBinder? = null
+
+ companion object {
+ private const val EXTRA_INSTALLER = "EXTRA_INSTALLER"
+
+ fun getIntent(
+ context: Context,
+ downloadId: Long,
+ uri: Uri,
+ installer: PreferenceValues.ExtensionInstaller
+ ): Intent {
+ return Intent(context, ExtensionInstallService::class.java)
+ .setDataAndType(uri, ExtensionInstaller.APK_MIME)
+ .putExtra(EXTRA_DOWNLOAD_ID, downloadId)
+ .putExtra(EXTRA_INSTALLER, installer)
+ }
+ }
+}
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 4884663918..bcd6ca1c58 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
@@ -7,15 +7,21 @@ 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 com.jakewharton.rxrelay.PublishRelay
+import eu.kanade.tachiyomi.data.preference.PreferenceValues
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+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.util.storage.getUriCompat
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import timber.log.Timber
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
import java.io.File
import java.util.concurrent.TimeUnit
@@ -47,6 +53,8 @@ internal class ExtensionInstaller(private val context: Context) {
*/
private val downloadsRelay = PublishRelay.create>()
+ private val installerPref = Injekt.get().extensionInstaller()
+
/**
* Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process.
@@ -79,8 +87,6 @@ internal class ExtensionInstaller(private val context: Context) {
.map { it.second }
// Poll download status
.mergeWith(pollStatus(id))
- // Force an error if the download takes more than 3 minutes
- .mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error })
// Stop when the application is installed or errors
.takeUntil { it.isCompleted() }
// Always notify on main thread
@@ -126,12 +132,29 @@ internal class ExtensionInstaller(private val context: Context) {
* @param uri The uri of the extension to install.
*/
fun installApk(downloadId: Long, uri: Uri) {
- val intent = Intent(context, ExtensionInstallActivity::class.java)
- .setDataAndType(uri, APK_MIME)
- .putExtra(EXTRA_DOWNLOAD_ID, downloadId)
- .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ when (val installer = installerPref.get()) {
+ PreferenceValues.ExtensionInstaller.LEGACY -> {
+ val intent = Intent(context, ExtensionInstallActivity::class.java)
+ .setDataAndType(uri, APK_MIME)
+ .putExtra(EXTRA_DOWNLOAD_ID, downloadId)
+ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
- context.startActivity(intent)
+ context.startActivity(intent)
+ }
+ else -> {
+ val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
+ ContextCompat.startForegroundService(context, intent)
+ }
+ }
+ }
+
+ /**
+ * 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)
}
/**
@@ -147,13 +170,12 @@ internal class ExtensionInstaller(private val context: Context) {
}
/**
- * Sets the result of the installation of an extension.
+ * Sets the step of the installation of an extension.
*
* @param downloadId The id of the download.
- * @param result Whether the extension was installed or not.
+ * @param step New install step.
*/
- fun setInstallationResult(downloadId: Long, result: Boolean) {
- val step = if (result) InstallStep.Installed else InstallStep.Error
+ fun updateInstallStep(downloadId: Long, step: InstallStep) {
downloadsRelay.call(downloadId to step)
}
@@ -216,9 +238,7 @@ internal class ExtensionInstaller(private val context: Context) {
val uri = downloadManager.getUriForDownloadedFile(id)
// Set next installation step
- if (uri != null) {
- downloadsRelay.call(id to InstallStep.Installing)
- } else {
+ if (uri == null) {
Timber.e("Couldn't locate downloaded APK")
downloadsRelay.call(id to InstallStep.Error)
return
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
index 9d08e90d63..89f621da27 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
@@ -22,5 +22,6 @@ class ExtensionAdapter(controller: ExtensionController) :
interface OnButtonClickListener {
fun onButtonClick(position: Int)
+ fun onCancelButtonClick(position: Int)
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
index aeb37fdb1d..c35f053d0d 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
@@ -119,6 +119,11 @@ open class ExtensionController :
}
}
+ override fun onCancelButtonClick(position: Int) {
+ val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
+ presenter.cancelInstallUpdateExtension(extension)
+ }
+
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.browse_extensions, menu)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
index cc92957d0e..9216c73e18 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.browse.extension
import android.view.View
+import androidx.core.view.isVisible
import coil.clear
import coil.load
import eu.davidea.viewholders.FlexibleViewHolder
@@ -9,7 +10,6 @@ import eu.kanade.tachiyomi.databinding.ExtensionCardItemBinding
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.LocaleHelper
-import uy.kohesive.injekt.api.get
class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
FlexibleViewHolder(view, adapter) {
@@ -20,6 +20,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
binding.extButton.setOnClickListener {
adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
}
+ binding.cancelButton.setOnClickListener {
+ adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
+ }
}
fun bind(item: ExtensionItem) {
@@ -42,44 +45,40 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
} else {
extension.getApplicationIcon(itemView.context)?.let { binding.image.setImageDrawable(it) }
}
- bindButton(item)
+ bindButtons(item)
}
@Suppress("ResourceType")
- fun bindButton(item: ExtensionItem) = with(binding.extButton) {
- isEnabled = true
- isClickable = true
-
+ fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
val extension = item.extension
val installStep = item.installStep
- if (installStep != null) {
- setText(
- when (installStep) {
- InstallStep.Pending -> R.string.ext_pending
- InstallStep.Downloading -> R.string.ext_downloading
- InstallStep.Installing -> R.string.ext_installing
- InstallStep.Installed -> R.string.ext_installed
- InstallStep.Error -> R.string.action_retry
- }
- )
- if (installStep != InstallStep.Error) {
- isEnabled = false
- isClickable = false
- }
- } else if (extension is Extension.Installed) {
- when {
- extension.hasUpdate -> {
- setText(R.string.ext_update)
- }
- else -> {
- setText(R.string.action_settings)
+ setText(
+ when (installStep) {
+ InstallStep.Pending -> R.string.ext_pending
+ InstallStep.Downloading -> R.string.ext_downloading
+ InstallStep.Installing -> R.string.ext_installing
+ InstallStep.Installed -> R.string.ext_installed
+ InstallStep.Error -> R.string.action_retry
+ InstallStep.Idle -> {
+ when (extension) {
+ is Extension.Installed -> {
+ if (extension.hasUpdate) {
+ R.string.ext_update
+ } else {
+ R.string.action_settings
+ }
+ }
+ is Extension.Untrusted -> R.string.ext_trust
+ is Extension.Available -> R.string.ext_install
+ }
}
}
- } else if (extension is Extension.Untrusted) {
- setText(R.string.ext_trust)
- } else {
- setText(R.string.ext_install)
- }
+ )
+
+ val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
+ binding.cancelButton.isVisible = !isIdle
+ isEnabled = isIdle
+ isClickable = isIdle
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
index ddea87cc72..7598808843 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
@@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
data class ExtensionItem(
val extension: Extension,
val header: ExtensionGroupItem? = null,
- val installStep: InstallStep? = null
+ val installStep: InstallStep = InstallStep.Idle
) :
AbstractSectionableItem(header) {
@@ -49,7 +49,7 @@ data class ExtensionItem(
if (payloads == null || payloads.isEmpty()) {
holder.bind(this)
} else {
- holder.bindButton(this)
+ holder.bindButtons(this)
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
index 31f7c25b0d..b9ac17b738 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
@@ -77,14 +77,14 @@ open class ExtensionPresenter(
if (updatesSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
items += updatesSorted.map { extension ->
- ExtensionItem(extension, header, currentDownloads[extension.pkgName])
+ ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
}
}
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
items += installedSorted.map { extension ->
- ExtensionItem(extension, header, currentDownloads[extension.pkgName])
+ ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
}
items += untrustedSorted.map { extension ->
@@ -100,7 +100,7 @@ open class ExtensionPresenter(
.forEach {
val header = ExtensionGroupItem(it.key, it.value.size)
items += it.value.map { extension ->
- ExtensionItem(extension, header, currentDownloads[extension.pkgName])
+ ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
}
}
}
@@ -133,6 +133,10 @@ open class ExtensionPresenter(
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
}
+ fun cancelInstallUpdateExtension(extension: Extension) {
+ extensionManager.cancelInstallUpdateExtension(extension)
+ }
+
private fun Observable.subscribeToInstallUpdate(extension: Extension) {
this.doOnNext { currentDownloads[extension.pkgName] = it }
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
index ed1cfa6ec5..cce9184b3a 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
@@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
+import eu.kanade.tachiyomi.util.system.MiuiUtil
+import eu.kanade.tachiyomi.util.system.isPackageInstalled
import eu.kanade.tachiyomi.util.system.isTablet
import eu.kanade.tachiyomi.util.system.powerManager
import eu.kanade.tachiyomi.util.system.toast
@@ -187,6 +189,45 @@ class SettingsAdvancedController : SettingsController() {
}
}
+ preferenceCategory {
+ titleRes = R.string.label_extensions
+
+ listPreference {
+ key = Keys.extensionInstaller
+ titleRes = R.string.ext_installer_pref
+ summary = "%s"
+ entriesRes = arrayOf(
+ R.string.ext_installer_legacy,
+ R.string.ext_installer_packageinstaller,
+ R.string.ext_installer_shizuku
+ )
+ entryValues = PreferenceValues.ExtensionInstaller.values().map { it.name }.toTypedArray()
+ defaultValue = if (MiuiUtil.isMiui()) {
+ PreferenceValues.ExtensionInstaller.LEGACY
+ } else {
+ PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER
+ }.name
+
+ onChange {
+ if (it == PreferenceValues.ExtensionInstaller.SHIZUKU.name &&
+ !context.isPackageInstalled("moe.shizuku.privileged.api")
+ ) {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.ext_installer_shizuku)
+ .setMessage(R.string.ext_installer_shizuku_unavailable_dialog)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ openInBrowser("https://shizuku.rikka.app/download")
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ false
+ } else {
+ true
+ }
+ }
+ }
+ }
+
preferenceCategory {
titleRes = R.string.pref_category_display
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/CloseableExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/CloseableExtensions.kt
new file mode 100644
index 0000000000..647eaab396
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/CloseableExtensions.kt
@@ -0,0 +1,31 @@
+package eu.kanade.tachiyomi.util.lang
+
+import java.io.Closeable
+
+/**
+ * Executes the given block function on this resources and then closes it down correctly whether an exception is
+ * thrown or not.
+ *
+ * @param block a function to process with given Closeable resources.
+ * @return the result of block function invoked on this resource.
+ */
+inline fun Array.use(block: () -> Unit) {
+ var blockException: Throwable? = null
+ try {
+ return block()
+ } catch (e: Throwable) {
+ blockException = e
+ throw e
+ } finally {
+ when (blockException) {
+ null -> forEach { it?.close() }
+ else -> forEach {
+ try {
+ it?.close()
+ } catch (closeException: Throwable) {
+ blockException.addSuppressed(closeException)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
index 40dc9116ee..8e2e6d86e5 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
@@ -41,6 +41,7 @@ import androidx.core.graphics.green
import androidx.core.graphics.red
import androidx.core.net.toUri
import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@@ -377,3 +378,24 @@ fun Context.isOnline(): Boolean {
}
return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(actNw::hasTransport)
}
+
+/**
+ * Gets document size of provided [Uri]
+ *
+ * @return document size of [uri] or null if size can't be obtained
+ */
+fun Context.getUriSize(uri: Uri): Long? {
+ return UniFile.fromUri(this, uri).length().takeIf { it >= 0 }
+}
+
+/**
+ * Returns true if [packageName] is installed.
+ */
+fun Context.isPackageInstalled(packageName: String): Boolean {
+ return try {
+ packageManager.getApplicationInfo(packageName, 0)
+ true
+ } catch (e: PackageManager.NameNotFoundException) {
+ false
+ }
+}
diff --git a/app/src/main/res/layout/extension_card_item.xml b/app/src/main/res/layout/extension_card_item.xml
index 8ac5919d4e..2c715dcabd 100644
--- a/app/src/main/res/layout/extension_card_item.xml
+++ b/app/src/main/res/layout/extension_card_item.xml
@@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
+ android:layout_marginEnd="16dp"
android:background="@drawable/list_item_selector_background">
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 453237af46..761aa73d46 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -264,6 +264,13 @@
Language: %1$s
18+
May contain NSFW (18+) content
+ Installing extension…
+ Installer
+ Legacy
+ PackageInstaller
+ Shizuku
+ Shizuku is not running
+ Install and start Shizuku to use Shizuku as extension installer.
Fullscreen