mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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. | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -57,4 +57,10 @@ object PreferenceValues { | ||||
|         LANDSCAPE, | ||||
|         NEVER, | ||||
|     } | ||||
|  | ||||
|     enum class ExtensionInstaller { | ||||
|         LEGACY, | ||||
|         PACKAGEINSTALLER, | ||||
|         SHIZUKU | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
| @@ -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("(?<=\\[).+?(?=])") | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|                 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 | ||||
|   | ||||
| @@ -22,5 +22,6 @@ class ExtensionAdapter(controller: ExtensionController) : | ||||
|  | ||||
|     interface OnButtonClickListener { | ||||
|         fun onButtonClick(position: Int) | ||||
|         fun onCancelButtonClick(position: Int) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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) } | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user