mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-21 20:47:03 -05: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:
parent
1ae0d1b5d0
commit
b284384f0a
24 changed files with 738 additions and 61 deletions
|
@ -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")
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -57,4 +57,10 @@ object PreferenceValues {
|
||||||
LANDSCAPE,
|
LANDSCAPE,
|
||||||
NEVER,
|
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.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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.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,6 +132,8 @@ 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) {
|
||||||
|
when (val installer = installerPref.get()) {
|
||||||
|
PreferenceValues.ExtensionInstaller.LEGACY -> {
|
||||||
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||||
.setDataAndType(uri, APK_MIME)
|
.setDataAndType(uri, APK_MIME)
|
||||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||||
|
@ -133,6 +141,21 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts an intent to uninstall the extension by the given package name.
|
* Starts an intent to uninstall the extension by the given package name.
|
||||||
|
@ -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
|
||||||
|
|
|
@ -22,5 +22,6 @@ class ExtensionAdapter(controller: ExtensionController) :
|
||||||
|
|
||||||
interface OnButtonClickListener {
|
interface OnButtonClickListener {
|
||||||
fun onButtonClick(position: Int)
|
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) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
inflater.inflate(R.menu.browse_extensions, menu)
|
inflater.inflate(R.menu.browse_extensions, menu)
|
||||||
|
|
||||||
|
|
|
@ -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,18 +45,14 @@ 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
|
||||||
|
@ -61,25 +60,25 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||||
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 (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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (installStep != InstallStep.Error) {
|
|
||||||
isEnabled = false
|
val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
|
||||||
isClickable = false
|
binding.cancelButton.isVisible = !isIdle
|
||||||
}
|
isEnabled = isIdle
|
||||||
} else if (extension is Extension.Installed) {
|
isClickable = isIdle
|
||||||
when {
|
|
||||||
extension.hasUpdate -> {
|
|
||||||
setText(R.string.ext_update)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
setText(R.string.action_settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (extension is Extension.Untrusted) {
|
|
||||||
setText(R.string.ext_trust)
|
|
||||||
} else {
|
|
||||||
setText(R.string.ext_install)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue