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.