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:
Ivan Iskandar 2021-09-26 01:31:52 +07:00 committed by GitHub
parent 1ae0d1b5d0
commit b284384f0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 738 additions and 61 deletions

View File

@ -261,6 +261,11 @@ dependencies {
// Licenses // Licenses
implementation("com.mikepenz:aboutlibraries-core:${BuildPluginsVersion.ABOUTLIB_PLUGIN}") 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 // Tests
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.assertj:assertj-core:3.16.1") testImplementation("org.assertj:assertj-core:3.16.1")

View File

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

View File

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

View File

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

View File

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

View File

@ -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.library.setting.SortModeSetting
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType 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.util.system.isTablet
import eu.kanade.tachiyomi.widget.ExtendedNavigationView import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import kotlinx.coroutines.flow.Flow 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 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) { fun setChapterSettingsDefault(manga: Manga) {
prefs.edit { prefs.edit {
putInt(Keys.defaultChapterFilterByRead, manga.readFilter) putInt(Keys.defaultChapterFilterByRead, manga.readFilter)

View File

@ -227,14 +227,26 @@ class ExtensionManager(
return installExtension(availableExt) 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 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) { 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)
} }
/** /**

View File

@ -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)
}
}
}

View File

@ -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"

View File

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

View File

@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.extension.model package eu.kanade.tachiyomi.extension.model
enum class InstallStep { enum class InstallStep {
Pending, Downloading, Installing, Installed, Error; Idle, Pending, Downloading, Installing, Installed, Error;
fun isCompleted(): Boolean { fun isCompleted(): Boolean {
return this == Installed || this == Error return this == Installed || this == Error || this == Idle
} }
} }

View File

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -40,10 +41,13 @@ class ExtensionInstallActivity : Activity() {
private fun checkInstallationResult(resultCode: Int) { private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val success = resultCode == RESULT_OK
val extensionManager = Injekt.get<ExtensionManager>() 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)
} }
} }

View File

@ -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)
}
}
}

View File

@ -7,15 +7,21 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import com.jakewharton.rxrelay.PublishRelay 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.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit 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 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 * Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process. * step in the installation process.
@ -79,8 +87,6 @@ internal class ExtensionInstaller(private val context: Context) {
.map { it.second } .map { it.second }
// Poll download status // Poll download status
.mergeWith(pollStatus(id)) .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 // Stop when the application is installed or errors
.takeUntil { it.isCompleted() } .takeUntil { it.isCompleted() }
// Always notify on main thread // 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. * @param uri The uri of the extension to install.
*/ */
fun installApk(downloadId: Long, uri: Uri) { fun installApk(downloadId: Long, uri: Uri) {
val intent = Intent(context, ExtensionInstallActivity::class.java) when (val installer = installerPref.get()) {
.setDataAndType(uri, APK_MIME) PreferenceValues.ExtensionInstaller.LEGACY -> {
.putExtra(EXTRA_DOWNLOAD_ID, downloadId) val intent = Intent(context, ExtensionInstallActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) .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 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) { fun updateInstallStep(downloadId: Long, step: InstallStep) {
val step = if (result) InstallStep.Installed else InstallStep.Error
downloadsRelay.call(downloadId to step) downloadsRelay.call(downloadId to step)
} }
@ -216,9 +238,7 @@ internal class ExtensionInstaller(private val context: Context) {
val uri = downloadManager.getUriForDownloadedFile(id) val uri = downloadManager.getUriForDownloadedFile(id)
// Set next installation step // Set next installation step
if (uri != null) { if (uri == null) {
downloadsRelay.call(id to InstallStep.Installing)
} else {
Timber.e("Couldn't locate downloaded APK") Timber.e("Couldn't locate downloaded APK")
downloadsRelay.call(id to InstallStep.Error) downloadsRelay.call(id to InstallStep.Error)
return return

View File

@ -22,5 +22,6 @@ class ExtensionAdapter(controller: ExtensionController) :
interface OnButtonClickListener { interface OnButtonClickListener {
fun onButtonClick(position: Int) fun onButtonClick(position: Int)
fun onCancelButtonClick(position: Int)
} }
} }

View File

@ -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) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.browse_extensions, menu) inflater.inflate(R.menu.browse_extensions, menu)

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.browse.extension package eu.kanade.tachiyomi.ui.browse.extension
import android.view.View import android.view.View
import androidx.core.view.isVisible
import coil.clear import coil.clear
import coil.load import coil.load
import eu.davidea.viewholders.FlexibleViewHolder 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.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import uy.kohesive.injekt.api.get
class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
FlexibleViewHolder(view, adapter) { FlexibleViewHolder(view, adapter) {
@ -20,6 +20,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
binding.extButton.setOnClickListener { binding.extButton.setOnClickListener {
adapter.buttonClickListener.onButtonClick(bindingAdapterPosition) adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
} }
binding.cancelButton.setOnClickListener {
adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
}
} }
fun bind(item: ExtensionItem) { fun bind(item: ExtensionItem) {
@ -42,44 +45,40 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
} else { } else {
extension.getApplicationIcon(itemView.context)?.let { binding.image.setImageDrawable(it) } extension.getApplicationIcon(itemView.context)?.let { binding.image.setImageDrawable(it) }
} }
bindButton(item) bindButtons(item)
} }
@Suppress("ResourceType") @Suppress("ResourceType")
fun bindButton(item: ExtensionItem) = with(binding.extButton) { fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
isEnabled = true
isClickable = true
val extension = item.extension val extension = item.extension
val installStep = item.installStep val installStep = item.installStep
if (installStep != null) { setText(
setText( when (installStep) {
when (installStep) { InstallStep.Pending -> R.string.ext_pending
InstallStep.Pending -> R.string.ext_pending InstallStep.Downloading -> R.string.ext_downloading
InstallStep.Downloading -> R.string.ext_downloading InstallStep.Installing -> R.string.ext_installing
InstallStep.Installing -> R.string.ext_installing InstallStep.Installed -> R.string.ext_installed
InstallStep.Installed -> R.string.ext_installed InstallStep.Error -> R.string.action_retry
InstallStep.Error -> R.string.action_retry InstallStep.Idle -> {
} when (extension) {
) is Extension.Installed -> {
if (installStep != InstallStep.Error) { if (extension.hasUpdate) {
isEnabled = false R.string.ext_update
isClickable = false } else {
} R.string.action_settings
} else if (extension is Extension.Installed) { }
when { }
extension.hasUpdate -> { is Extension.Untrusted -> R.string.ext_trust
setText(R.string.ext_update) is Extension.Available -> R.string.ext_install
} }
else -> {
setText(R.string.action_settings)
} }
} }
} else if (extension is Extension.Untrusted) { )
setText(R.string.ext_trust)
} else { val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
setText(R.string.ext_install) binding.cancelButton.isVisible = !isIdle
} isEnabled = isIdle
isClickable = isIdle
} }
} }

View File

@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
data class ExtensionItem( data class ExtensionItem(
val extension: Extension, val extension: Extension,
val header: ExtensionGroupItem? = null, val header: ExtensionGroupItem? = null,
val installStep: InstallStep? = null val installStep: InstallStep = InstallStep.Idle
) : ) :
AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) { AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
@ -49,7 +49,7 @@ data class ExtensionItem(
if (payloads == null || payloads.isEmpty()) { if (payloads == null || payloads.isEmpty()) {
holder.bind(this) holder.bind(this)
} else { } else {
holder.bindButton(this) holder.bindButtons(this)
} }
} }

View File

@ -77,14 +77,14 @@ open class ExtensionPresenter(
if (updatesSorted.isNotEmpty()) { if (updatesSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true) val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
items += updatesSorted.map { extension -> items += updatesSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName]) ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
} }
} }
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) { if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size) val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
items += installedSorted.map { extension -> items += installedSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName]) ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
} }
items += untrustedSorted.map { extension -> items += untrustedSorted.map { extension ->
@ -100,7 +100,7 @@ open class ExtensionPresenter(
.forEach { .forEach {
val header = ExtensionGroupItem(it.key, it.value.size) val header = ExtensionGroupItem(it.key, it.value.size)
items += it.value.map { extension -> 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) extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
} }
fun cancelInstallUpdateExtension(extension: Extension) {
extensionManager.cancelInstallUpdateExtension(extension)
}
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) { private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
this.doOnNext { currentDownloads[extension.pkgName] = it } this.doOnNext { currentDownloads[extension.pkgName] = it }
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }

View File

@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes 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.isTablet
import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.powerManager
import eu.kanade.tachiyomi.util.system.toast 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 { preferenceCategory {
titleRes = R.string.pref_category_display titleRes = R.string.pref_category_display

View File

@ -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)
}
}
}
}
}

View File

@ -41,6 +41,7 @@ import androidx.core.graphics.green
import androidx.core.graphics.red import androidx.core.graphics.red
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -377,3 +378,24 @@ fun Context.isOnline(): Boolean {
} }
return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(actNw::hasTransport) 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
}
}

View File

@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="64dp" android:layout_height="64dp"
android:layout_marginEnd="16dp"
android:background="@drawable/list_item_selector_background"> android:background="@drawable/list_item_selector_background">
<ImageView <ImageView
@ -79,10 +80,24 @@
style="?attr/borderlessButtonStyle" style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toStartOf="@+id/cancel_button"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="Details" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -264,6 +264,13 @@
<string name="ext_language_info">Language: %1$s</string> <string name="ext_language_info">Language: %1$s</string>
<string name="ext_nsfw_short">18+</string> <string name="ext_nsfw_short">18+</string>
<string name="ext_nsfw_warning">May contain NSFW (18+) content</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 --> <!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string> <string name="pref_fullscreen">Fullscreen</string>