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