diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b0f784eaa3..45feeb2f59 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -226,6 +226,11 @@ dependencies {
implementation("com.jakewharton.rxbinding:rxbinding-support-v4-kotlin:${Versions.RX_BINDING}")
implementation("com.jakewharton.rxbinding:rxbinding-recyclerview-v7-kotlin:${Versions.RX_BINDING}")
+ // Shizuku
+ val shizukuVersion = "12.1.0"
+ implementation("dev.rikka.shizuku:api:$shizukuVersion")
+ implementation("dev.rikka.shizuku:provider:$shizukuVersion")
+
// Tests
testImplementation("junit:junit:4.13.2")
testImplementation("org.assertj:assertj-core:3.16.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e7f8e5908d..1bef98d488 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -244,6 +244,14 @@
android:name=".data.backup.BackupRestoreService"
android:exported="false"/>
+
+
= Build.VERSION_CODES.S) {
+ addAutoUpdateExtensionsNotifications(true, context)
+ context.notificationManager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_UPDATED,
+ context.getString(R.string.update_completed),
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ setShowBadge(false)
+ }
+ )
+ } else {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ addAutoUpdateExtensionsNotifications(
+ prefs.getBoolean(PreferenceKeys.useShizuku, false),
+ context
+ )
+ }
+ }
+
+ fun addAutoUpdateExtensionsNotifications(canAutoUpdate: Boolean, context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ if (canAutoUpdate) {
val newChannels = listOf(
NotificationChannel(
CHANNEL_EXT_PROGRESS,
@@ -191,16 +221,12 @@ object Notifications {
NotificationManager.IMPORTANCE_DEFAULT
).apply {
group = GROUP_EXTENSION_UPDATES
- },
- NotificationChannel(
- CHANNEL_UPDATED,
- context.getString(R.string.update_completed),
- NotificationManager.IMPORTANCE_DEFAULT
- ).apply {
- setShowBadge(false)
}
)
context.notificationManager.createNotificationChannels(newChannels)
+ } else {
+ context.notificationManager.deleteNotificationChannel(CHANNEL_EXT_PROGRESS)
+ context.notificationManager.deleteNotificationChannel(CHANNEL_EXT_UPDATED)
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
index fa471125f9..4e5943813c 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
@@ -231,6 +231,8 @@ object PreferenceKeys {
const val dohProvider = "doh_provider"
+ const val useShizuku = "use_shizuku"
+
const val showNsfwSource = "show_nsfw_source"
const val themeMangaDetails = "theme_manga_details"
diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
index 839499df4d..b299bf5786 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
@@ -422,6 +422,8 @@ class PreferencesHelper(val context: Context) {
fun autoUpdateExtensions() = prefs.getInt(Keys.autoUpdateExtensions, AutoAppUpdaterJob.ONLY_ON_UNMETERED)
+ fun useShizukuForExtensions() = prefs.getBoolean(Keys.useShizuku, false)
+
fun filterChapterByRead() = flowPrefs.getInt(Keys.defaultChapterFilterByRead, Manga.SHOW_ALL)
fun filterChapterByDownloaded() = flowPrefs.getInt(Keys.defaultChapterFilterByDownloaded, Manga.SHOW_ALL)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
index 71d121c5b6..9bf3e770da 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
@@ -2,8 +2,11 @@ package eu.kanade.tachiyomi.extension
import android.content.Context
import android.graphics.drawable.Drawable
+import android.os.Build
import android.os.Parcelable
+import androidx.preference.PreferenceManager
import com.jakewharton.rxrelay.BehaviorRelay
+import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension
@@ -467,4 +470,12 @@ class ExtensionManager(
versionCode = extension.versionCode
)
}
+
+ companion object {
+ fun canAutoInstallUpdates(context: Context): Boolean {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ||
+ prefs.getBoolean(PreferenceKeys.useShizuku, false)
+ }
+ }
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt
index 6cd0093bc6..bc983ae740 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension
import android.app.PendingIntent
import android.content.Context
+import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -27,6 +28,7 @@ import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.system.connectivityManager
import eu.kanade.tachiyomi.util.system.notification
import kotlinx.coroutines.coroutineScope
+import rikka.shizuku.Shizuku
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@@ -57,9 +59,17 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
val preferences: PreferencesHelper by injectLazy()
preferences.extensionUpdatesCount().set(extensions.size)
val extensionsInstalledByApp by lazy {
- extensions.filter { Injekt.get().isInstalledByApp(it) }
+ if (preferences.useShizukuForExtensions()) {
+ if (Shizuku.pingBinder() && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
+ extensions
+ } else {
+ emptyList()
+ }
+ } else {
+ extensions.filter { Injekt.get().isInstalledByApp(it) }
+ }
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ if (ExtensionManager.canAutoInstallUpdates(context) &&
inputData.getBoolean(RUN_AUTO, true) &&
preferences.autoUpdateExtensions() != AutoAppUpdaterJob.NEVER &&
!ExtensionInstallService.isRunning() &&
@@ -84,7 +94,7 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
2
}
)
- context.startForegroundService(intent)
+ ContextCompat.startForegroundService(context, intent)
if (extensionsInstalledByApp.size == extensions.size) {
return
} else {
@@ -117,12 +127,26 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
context
)
)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ if (ExtensionManager.canAutoInstallUpdates(context) &&
extensions.size == extensionsList.size
) {
val intent = ExtensionInstallService.jobIntent(context, extensions)
val pendingIntent =
- PendingIntent.getForegroundService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ PendingIntent.getForegroundService(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ } else {
+ PendingIntent.getService(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
addAction(
R.drawable.ic_file_download_24dp,
context.getString(R.string.update_all),
diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ShizukuInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ShizukuInstaller.kt
new file mode 100644
index 0000000000..032e19b513
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ShizukuInstaller.kt
@@ -0,0 +1,221 @@
+package eu.kanade.tachiyomi.extension
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
+import eu.kanade.tachiyomi.util.system.getUriSize
+import eu.kanade.tachiyomi.util.system.isPackageInstalled
+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 uy.kohesive.injekt.injectLazy
+import java.io.BufferedReader
+import java.io.InputStream
+import java.util.Collections
+import java.util.concurrent.atomic.AtomicReference
+
+class ShizukuInstaller(private val context: Context, val finishedQueue: (ShizukuInstaller) -> Unit) {
+
+ private val extensionManager: ExtensionManager by injectLazy()
+
+ private var waitingInstall = AtomicReference(null)
+ private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ 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)
+ }
+ }
+
+ data class Entry(val downloadId: Long, val pkgName: String, val uri: Uri)
+ private val queue = Collections.synchronizedList(mutableListOf())
+
+ private val shizukuDeadListener = Shizuku.OnBinderDeadListener {
+ Timber.d("Shizuku was killed prematurely")
+ finishedQueue(this)
+ }
+
+ fun isInQueue(pkgName: String) = queue.any { it.pkgName == pkgName }
+
+ 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 {
+ finishedQueue(this@ShizukuInstaller)
+ }
+ Shizuku.removeRequestPermissionResultListener(this)
+ }
+ }
+ }
+
+ var ready = false
+
+ init {
+ Shizuku.addBinderDeadListener(shizukuDeadListener)
+ require(Shizuku.pingBinder() && context.isPackageInstalled(shizukuPkgName)) {
+ finishedQueue(this)
+ context.getString(R.string.ext_installer_shizuku_stopped)
+ }
+ ready = if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
+ true
+ } else {
+ Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
+ Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
+ false
+ }
+ }
+
+ @Suppress("BlockingMethodInNonBlockingContext")
+ fun processEntry(entry: Entry) {
+ extensionManager.setInstalling(entry.downloadId, entry.uri.hashCode())
+ ioScope.launch {
+ var sessionId: String? = null
+ try {
+ val size = context.getUriSize(entry.uri) ?: throw IllegalStateException()
+ context.contentResolver.openInputStream(entry.uri)!!.use {
+ val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ "pm install-create --user current -i ${context.packageName} -S $size"
+ } else {
+ "pm install-create -i ${context.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(true)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
+ if (sessionId != null) {
+ exec("pm install-abandon $sessionId")
+ }
+ continueQueue(false)
+ }
+ }
+ }
+
+ /**
+ * 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()) {
+ finishedQueue(this)
+ return
+ }
+ val nextEntry = queue.first()
+ if (waitingInstall.compareAndSet(null, nextEntry)) {
+ queue.removeFirst()
+ processEntry(nextEntry)
+ }
+ }
+
+ /**
+ * 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(succeeded: Boolean) {
+ val completedEntry = waitingInstall.getAndSet(null)
+ if (completedEntry != null) {
+ extensionManager.setInstallationResult(completedEntry.downloadId, succeeded)
+ checkQueue()
+ }
+ }
+
+ /**
+ * 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, pkgName: String, uri: Uri) {
+ queue.add(Entry(downloadId, pkgName, uri))
+ checkQueue()
+ }
+
+ /**
+ * 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()
+ }
+ queue.forEach { extensionManager.setInstallationResult(it.downloadId, false) }
+// extensionManager.up(downloadId, InstallStep.Idle)
+ }
+ }
+
+ // Don't cancel if entry is already started installing
+ fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
+ fun getActiveEntry(): Entry? = waitingInstall.get()
+
+ fun onDestroy() {
+ Shizuku.removeBinderDeadListener(shizukuDeadListener)
+ Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
+ ioScope.cancel()
+ LocalBroadcastManager.getInstance(context).unregisterReceiver(cancelReceiver)
+ queue.forEach { extensionManager.setInstallationResult(it.pkgName, false) }
+ queue.clear()
+ waitingInstall.set(null)
+ }
+
+ 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)
+
+ companion object {
+ const val shizukuPkgName = "moe.shizuku.privileged.api"
+ const val downloadLink = "https://shizuku.rikka.app/download"
+ private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045
+ private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")
+ }
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt
index 51cec737ec..a3a3c86341 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt
@@ -10,12 +10,18 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.net.toUri
+import androidx.preference.PreferenceManager
+import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.extension.ShizukuInstaller
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.storage.getUriCompat
+import eu.kanade.tachiyomi.util.system.launchUI
+import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -60,6 +66,28 @@ internal class ExtensionInstaller(private val context: Context) {
* returned by the download manager.
*/
val activeDownloads = hashMapOf()
+ private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private var installer: ShizukuInstaller? = null
+
+ private val shizukuInstaller: ShizukuInstaller?
+ get() = installer ?: run {
+ try {
+ installer = ShizukuInstaller(context) {
+ it.onDestroy()
+ ioScope.launch {
+ delay(500)
+ downloadsStateFlow.emit("Finished" to (InstallStep.Installed to null))
+ }
+ installer = null
+ }
+ } catch (e: Exception) {
+ ioScope.launchUI {
+ context.toast(e.message)
+ }
+ }
+ installer
+ }
/**
* StateFlow used to notify the installation step of every download.
@@ -160,6 +188,7 @@ internal class ExtensionInstaller(private val context: Context) {
cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
}
} catch (_: Exception) {
+ null
}
if (newDownloadState != null) {
emit(newDownloadState)
@@ -203,7 +232,7 @@ internal class ExtensionInstaller(private val context: Context) {
.takeWhile { info ->
val sessionId = downloadInstallerMap[pkgName]
if (sessionId != null) {
- info.second != null
+ info.second != null || installer?.isInQueue(pkgName) == true
} else {
true
}
@@ -226,19 +255,25 @@ internal class ExtensionInstaller(private val context: Context) {
val useActivity =
pkgName?.let { !ExtensionLoader.isExtensionInstalledByApp(context, pkgName) } ?: true ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S
- val intent =
- if (useActivity) {
- Intent(context, ExtensionInstallActivity::class.java)
- } else {
- Intent(context, ExtensionInstallBroadcast::class.java)
- }
- .setDataAndType(uri, APK_MIME)
- .putExtra(EXTRA_DOWNLOAD_ID, downloadId)
- .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
- if (useActivity) {
- context.startActivity(intent)
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ if (prefs.getBoolean(PreferenceKeys.useShizuku, false) && pkgName != null) {
+ setInstalling(pkgName, uri.hashCode())
+ shizukuInstaller?.addToQueue(downloadId, pkgName, uri)
} else {
- context.sendBroadcast(intent)
+ val intent =
+ if (useActivity) {
+ Intent(context, ExtensionInstallActivity::class.java)
+ } else {
+ Intent(context, ExtensionInstallBroadcast::class.java)
+ }
+ .setDataAndType(uri, APK_MIME)
+ .putExtra(EXTRA_DOWNLOAD_ID, downloadId)
+ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ if (useActivity) {
+ context.startActivity(intent)
+ } else {
+ context.sendBroadcast(intent)
+ }
}
}
@@ -297,10 +332,6 @@ internal class ExtensionInstaller(private val context: Context) {
downloadsStateFlow.tryEmit(pkgName to ExtensionIntallInfo(step, null))
}
- fun softDeleteDownload(downloadId: Long) {
- downloadManager.remove(downloadId)
- }
-
/**
* Deletes the download for the given package name.
*
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt
index de8ce60d83..1d91a75b59 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt
@@ -208,7 +208,9 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
}
override fun onUpdateAllClicked(position: Int) {
- if (!presenter.preferences.hasPromptedBeforeUpdateAll().get()) {
+ if (!presenter.preferences.useShizukuForExtensions() &&
+ !presenter.preferences.hasPromptedBeforeUpdateAll().get()
+ ) {
controller.activity!!.materialAlertDialog()
.setTitle(R.string.update_all)
.setMessage(R.string.some_extensions_may_prompt)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt
index ac2b427d9d..cbda01dab2 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.extension
import android.annotation.SuppressLint
-import android.os.Build
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
@@ -28,7 +27,7 @@ class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter= Build.VERSION_CODES.S
+ binding.extButton.isVisible = item.canUpdate != null
binding.extButton.isEnabled = item.canUpdate == true
binding.extSort.isVisible = item.installedSorting != null
binding.extSort.setText(InstalledExtensionsOrder.fromValue(item.installedSorting ?: 0).nameRes)
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
index ba6e3b1a8e..123355082a 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.content.Intent
+import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
@@ -21,7 +22,9 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target
+import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
+import eu.kanade.tachiyomi.extension.ShizukuInstaller
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
@@ -30,6 +33,7 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.util.CrashLogUtil
import eu.kanade.tachiyomi.util.system.disableItems
+import eu.kanade.tachiyomi.util.system.isPackageInstalled
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.materialAlertDialog
@@ -211,6 +215,35 @@ class SettingsAdvancedController : SettingsController() {
}
}
+ preferenceCategory {
+ titleRes = R.string.extensions
+ switchPreference {
+ key = PreferenceKeys.useShizuku
+ titleRes = R.string.use_shizuku_to_install
+ summaryRes = R.string.use_shizuku_summary
+ defaultValue = false
+ onChange {
+ it as Boolean
+ if (it && !context.isPackageInstalled(ShizukuInstaller.shizukuPkgName)) {
+ context.materialAlertDialog()
+ .setTitle(R.string.shizuku)
+ .setMessage(R.string.ext_installer_shizuku_unavailable_dialog)
+ .setPositiveButton(R.string.download) { _, _ ->
+ openInBrowser(ShizukuInstaller.downloadLink)
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ false
+ } else {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ Notifications.addAutoUpdateExtensionsNotifications(it, context)
+ }
+ true
+ }
+ }
+ }
+ }
+
preferenceCategory {
titleRes = R.string.library
preference {
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt
index 73c8801ba7..893d0f7749 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt
@@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn
import eu.kanade.tachiyomi.data.updater.AutoAppUpdaterJob
+import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity
@@ -41,7 +42,7 @@ class SettingsBrowseController : SettingsController() {
true
}
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (ExtensionManager.canAutoInstallUpdates(context)) {
val intPref = intListPreference(activity) {
key = PreferenceKeys.autoUpdateExtensions
titleRes = R.string.auto_update_extensions
@@ -53,26 +54,41 @@ class SettingsBrowseController : SettingsController() {
)
defaultValue = AutoAppUpdaterJob.ONLY_ON_UNMETERED
}
- val infoPref = infoPreference(R.string.some_extensions_may_not_update)
- val switchPref = switchPreference {
- key = "notify_ext_updated"
- isPersistent = false
- titleRes = R.string.notify_extension_updated
- isChecked = Notifications.isNotificationChannelEnabled(context, Notifications.CHANNEL_EXT_UPDATED)
- updatedExtNotifPref = this
- onChange {
- false
- }
- onClick {
- val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
- putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
- putExtra(Settings.EXTRA_CHANNEL_ID, Notifications.CHANNEL_EXT_UPDATED)
+ val infoPref = if (!preferences.useShizukuForExtensions()) {
+ infoPreference(R.string.some_extensions_may_not_update)
+ } else {
+ null
+ }
+ val switchPref = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ switchPreference {
+ key = "notify_ext_updated"
+ isPersistent = false
+ titleRes = R.string.notify_extension_updated
+ isChecked = Notifications.isNotificationChannelEnabled(
+ context,
+ Notifications.CHANNEL_EXT_UPDATED
+ )
+ updatedExtNotifPref = this
+ onChange {
+ false
+ }
+ onClick {
+ val intent =
+ Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
+ putExtra(
+ Settings.EXTRA_CHANNEL_ID,
+ Notifications.CHANNEL_EXT_UPDATED
+ )
+ }
+ startActivity(intent)
}
- startActivity(intent)
}
+ } else {
+ null
}
preferences.automaticExtUpdates().asImmediateFlowIn(viewScope) { value ->
- arrayOf(intPref, infoPref, switchPref).forEach { it.isVisible = value }
+ arrayOf(intPref, infoPref, switchPref).forEach { it?.isVisible = value }
}
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
index 52d37e2c30..5cd9c2d25a 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
@@ -33,6 +33,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
+import com.hippo.unifile.UniFile
import com.nononsenseapps.filepicker.FilePickerActivity
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@@ -222,6 +223,27 @@ fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock {
return wakeLock
}
+/**
+ * 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: Exception) {
+ false
+ }
+}
+
/**
* Property to get the notification manager from the context.
*/
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 748459a848..0cbd6d9c24 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -305,6 +305,11 @@
Trust
Untrusted
Uninstall
+ Shizuku is not running
+ Install and start Shizuku to use Shizuku as extension installer.
+ Use Shizuku to install extensions
+ Shizuku
+ Allows extensions to be installed without user prompts and enables automatic updates for devices under Android 12
Untrusted extension
This extension was signed with an untrusted certificate and wasn\'t activated.\n\nA malicious extension could read any login credentials stored in Tachiyomi or execute arbitrary code.\n\nBy trusting this certificate you accept these risks.
This extension is no longer available.