Bläddra i källkod

Implement new extension install methods (#5904)

* Implement new extension install methods

* Fixes

* Resolve feedback

* Keep pending status when waiting to install

* Cancellable installation

* Remove auto error now that we have cancellable job
Ivan Iskandar 3 år sedan
förälder
incheckning
b284384f0a
24 ändrade filer med 738 tillägg och 61 borttagningar
  1. 5 0
      app/build.gradle.kts
  2. 12 0
      app/src/main/AndroidManifest.xml
  3. 1 0
      app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt
  4. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
  5. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt
  6. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  7. 15 3
      app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
  8. 170 0
      app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt
  9. 105 0
      app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt
  10. 127 0
      app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt
  11. 2 2
      app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt
  12. 7 3
      app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt
  13. 82 0
      app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallService.kt
  14. 34 14
      app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt
  15. 1 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt
  16. 5 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt
  17. 31 32
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt
  18. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt
  19. 7 3
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt
  20. 41 0
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
  21. 31 0
      app/src/main/java/eu/kanade/tachiyomi/util/lang/CloseableExtensions.kt
  22. 22 0
      app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
  23. 17 2
      app/src/main/res/layout/extension_card_item.xml
  24. 7 0
      app/src/main/res/values/strings.xml

+ 5 - 0
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")

+ 12 - 0
app/src/main/AndroidManifest.xml

@@ -18,6 +18,7 @@
     <!-- For managing extensions -->
     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
     <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
+    <uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
     <!-- To view extension packages in API 30+ -->
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
 
@@ -188,6 +189,9 @@
             android:name=".data.backup.BackupRestoreService"
             android:exported="false" />
 
+        <service android:name=".extension.util.ExtensionInstallService"
+            android:exported="false" />
+
         <provider
             android:name="androidx.core.content.FileProvider"
             android:authorities="${applicationId}.provider"
@@ -198,6 +202,14 @@
                 android:resource="@xml/provider_paths" />
         </provider>
 
+        <provider
+            android:name="rikka.shizuku.ShizukuProvider"
+            android:authorities="${applicationId}.shizuku"
+            android:multiprocess="false"
+            android:enabled="true"
+            android:exported="true"
+            android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
+
         <meta-data android:name="android.webkit.WebView.EnableSafeBrowsing"
             android:value="false" />
         <meta-data android:name="android.webkit.WebView.MetricsOptOut"

+ 1 - 0
app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt

@@ -53,6 +53,7 @@ object Notifications {
      */
     const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
     const val ID_UPDATES_TO_EXTS = -401
+    const val ID_EXTENSION_INSTALLER = -402
 
     /**
      * Notification channel and ids used by the backup/restore system.

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt

@@ -222,6 +222,8 @@ object PreferenceKeys {
 
     const val tabletUiMode = "tablet_ui_mode"
 
+    const val extensionInstaller = "extension_installer"
+
     fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
 
     fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt

@@ -57,4 +57,10 @@ object PreferenceValues {
         LANDSCAPE,
         NEVER,
     }
+
+    enum class ExtensionInstaller {
+        LEGACY,
+        PACKAGEINSTALLER,
+        SHIZUKU
+    }
 }

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
 import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
 import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
 import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
+import eu.kanade.tachiyomi.util.system.MiuiUtil
 import eu.kanade.tachiyomi.util.system.isTablet
 import eu.kanade.tachiyomi.widget.ExtendedNavigationView
 import kotlinx.coroutines.flow.Flow
@@ -321,6 +322,11 @@ class PreferencesHelper(val context: Context) {
         if (context.applicationContext.isTablet()) Values.TabletUiMode.ALWAYS else Values.TabletUiMode.NEVER
     )
 
+    fun extensionInstaller() = flowPrefs.getEnum(
+        Keys.extensionInstaller,
+        if (MiuiUtil.isMiui()) Values.ExtensionInstaller.LEGACY else Values.ExtensionInstaller.PACKAGEINSTALLER
+    )
+
     fun setChapterSettingsDefault(manga: Manga) {
         prefs.edit {
             putInt(Keys.defaultChapterFilterByRead, manga.readFilter)

+ 15 - 3
app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt

@@ -227,14 +227,26 @@ class ExtensionManager(
         return installExtension(availableExt)
     }
 
+    fun cancelInstallUpdateExtension(extension: Extension) {
+        installer.cancelInstall(extension.pkgName)
+    }
+
     /**
-     * Sets the result of the installation of an extension.
+     * Sets to "installing" status of an extension installation.
      *
      * @param downloadId The id of the download.
-     * @param result Whether the extension was installed or not.
      */
+    fun setInstalling(downloadId: Long) {
+        installer.updateInstallStep(downloadId, InstallStep.Installing)
+    }
+
     fun setInstallationResult(downloadId: Long, result: Boolean) {
-        installer.setInstallationResult(downloadId, result)
+        val step = if (result) InstallStep.Installed else InstallStep.Error
+        installer.updateInstallStep(downloadId, step)
+    }
+
+    fun updateInstallStep(downloadId: Long, step: InstallStep) {
+        installer.updateInstallStep(downloadId, step)
     }
 
     /**

+ 170 - 0
app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt

@@ -0,0 +1,170 @@
+package eu.kanade.tachiyomi.extension.installer
+
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import androidx.annotation.CallSuper
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.model.InstallStep
+import uy.kohesive.injekt.injectLazy
+import java.util.Collections
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Base implementation class for extension installer. To be used inside a foreground [Service].
+ */
+abstract class Installer(private val service: Service) {
+
+    private val extensionManager: ExtensionManager by injectLazy()
+
+    private var waitingInstall = AtomicReference<Entry>(null)
+    private val queue = Collections.synchronizedList(mutableListOf<Entry>())
+
+    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)
+        }
+    }
+}

+ 105 - 0
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>(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<Entry, Int>? = 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"

+ 127 - 0
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("(?<=\\[).+?(?=])")

+ 2 - 2
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
     }
 }

+ 7 - 3
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>()
-        extensionManager.setInstallationResult(downloadId, success)
+        val newStep = when (resultCode) {
+            RESULT_OK -> InstallStep.Installed
+            RESULT_CANCELED -> InstallStep.Idle
+            else -> InstallStep.Error
+        }
+        extensionManager.updateInstallStep(downloadId, newStep)
     }
 }
 

+ 82 - 0
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)
+        }
+    }
+}

+ 34 - 14
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<Pair<Long, InstallStep>>()
 
+    private val installerPref = Injekt.get<PreferencesHelper>().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)
+            }
+            else -> {
+                val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
+                ContextCompat.startForegroundService(context, intent)
+            }
+        }
+    }
 
-        context.startActivity(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

+ 1 - 0
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)
     }
 }

+ 5 - 0
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)
 

+ 31 - 32
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
     }
 }

+ 2 - 2
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<ExtensionHolder, ExtensionGroupItem>(header) {
 
@@ -49,7 +49,7 @@ data class ExtensionItem(
         if (payloads == null || payloads.isEmpty()) {
             holder.bind(this)
         } else {
-            holder.bindButton(this)
+            holder.bindButtons(this)
         }
     }
 

+ 7 - 3
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<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
         this.doOnNext { currentDownloads[extension.pkgName] = it }
             .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }

+ 41 - 0
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
 

+ 31 - 0
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 <T : Closeable?> Array<T>.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)
+                }
+            }
+        }
+    }
+}

+ 22 - 0
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
+    }
+}

+ 17 - 2
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">
 
     <ImageView
@@ -79,10 +80,24 @@
         style="?attr/borderlessButtonStyle"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_marginEnd="16dp"
         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"
+        android:background="?selectableItemBackgroundBorderless"
+        android:contentDescription="@android:string/cancel"
+        android:padding="12dp"
+        android:src="@drawable/ic_close_24dp"
+        android:visibility="gone"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:tint="?android:attr/textColorPrimary"
+        tools:visibility="visible" />
+
 </androidx.constraintlayout.widget.ConstraintLayout>

+ 7 - 0
app/src/main/res/values/strings.xml

@@ -264,6 +264,13 @@
     <string name="ext_language_info">Language: %1$s</string>
     <string name="ext_nsfw_short">18+</string>
     <string name="ext_nsfw_warning">May contain NSFW (18+) content</string>
+    <string name="ext_install_service_notif">Installing extension…</string>
+    <string name="ext_installer_pref">Installer</string>
+    <string name="ext_installer_legacy">Legacy</string>
+    <string name="ext_installer_packageinstaller">PackageInstaller</string>
+    <string name="ext_installer_shizuku">Shizuku</string>
+    <string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
+    <string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
 
       <!-- Reader section -->
     <string name="pref_fullscreen">Fullscreen</string>