mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-22 10:01:48 +01: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
@ -261,6 +261,11 @@ dependencies {
|
||||
// Licenses
|
||||
implementation("com.mikepenz:aboutlibraries-core:${BuildPluginsVersion.ABOUTLIB_PLUGIN}")
|
||||
|
||||
// Shizuku
|
||||
val shizukuVersion = "12.0.0"
|
||||
implementation("dev.rikka.shizuku:api:$shizukuVersion")
|
||||
implementation("dev.rikka.shizuku:provider:$shizukuVersion")
|
||||
|
||||
// Tests
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.assertj:assertj-core:3.16.1")
|
||||
|
@ -18,6 +18,7 @@
|
||||
<!-- For managing extensions -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_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+ -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
|
||||
@ -188,6 +189,9 @@
|
||||
android:name=".data.backup.BackupRestoreService"
|
||||
android:exported="false" />
|
||||
|
||||
<service android:name=".extension.util.ExtensionInstallService"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
@ -198,6 +202,14 @@
|
||||
android:resource="@xml/provider_paths" />
|
||||
</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"
|
||||
android:value="false" />
|
||||
<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 ID_UPDATES_TO_EXTS = -401
|
||||
const val ID_EXTENSION_INSTALLER = -402
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the backup/restore system.
|
||||
|
@ -222,6 +222,8 @@ object PreferenceKeys {
|
||||
|
||||
const val tabletUiMode = "tablet_ui_mode"
|
||||
|
||||
const val extensionInstaller = "extension_installer"
|
||||
|
||||
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||
|
||||
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||
|
@ -57,4 +57,10 @@ object PreferenceValues {
|
||||
LANDSCAPE,
|
||||
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.reader.setting.OrientationType
|
||||
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.widget.ExtendedNavigationView
|
||||
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
|
||||
)
|
||||
|
||||
fun extensionInstaller() = flowPrefs.getEnum(
|
||||
Keys.extensionInstaller,
|
||||
if (MiuiUtil.isMiui()) Values.ExtensionInstaller.LEGACY else Values.ExtensionInstaller.PACKAGEINSTALLER
|
||||
)
|
||||
|
||||
fun setChapterSettingsDefault(manga: Manga) {
|
||||
prefs.edit {
|
||||
putInt(Keys.defaultChapterFilterByRead, manga.readFilter)
|
||||
|
@ -227,14 +227,26 @@ class ExtensionManager(
|
||||
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 result Whether the extension was installed or not.
|
||||
*/
|
||||
fun setInstalling(downloadId: Long) {
|
||||
installer.updateInstallStep(downloadId, InstallStep.Installing)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
enum class InstallStep {
|
||||
Pending, Downloading, Installing, Installed, Error;
|
||||
Idle, Pending, Downloading, Installing, Installed, Error;
|
||||
|
||||
fun isCompleted(): Boolean {
|
||||
return this == Installed || this == Error
|
||||
return this == Installed || this == Error || this == Idle
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
@ -40,10 +41,13 @@ class ExtensionInstallActivity : Activity() {
|
||||
|
||||
private fun checkInstallationResult(resultCode: Int) {
|
||||
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||
val success = resultCode == RESULT_OK
|
||||
|
||||
val extensionManager = Injekt.get<ExtensionManager>()
|
||||
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.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ -47,6 +53,8 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
*/
|
||||
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||
|
||||
private val installerPref = Injekt.get<PreferencesHelper>().extensionInstaller()
|
||||
|
||||
/**
|
||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||
* step in the installation process.
|
||||
@ -79,8 +87,6 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
.map { it.second }
|
||||
// Poll download status
|
||||
.mergeWith(pollStatus(id))
|
||||
// Force an error if the download takes more than 3 minutes
|
||||
.mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error })
|
||||
// Stop when the application is installed or errors
|
||||
.takeUntil { it.isCompleted() }
|
||||
// Always notify on main thread
|
||||
@ -126,6 +132,8 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
* @param uri The uri of the extension to install.
|
||||
*/
|
||||
fun installApk(downloadId: Long, uri: Uri) {
|
||||
when (val installer = installerPref.get()) {
|
||||
PreferenceValues.ExtensionInstaller.LEGACY -> {
|
||||
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
@ -133,6 +141,21 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
|
||||
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.
|
||||
@ -147,13 +170,12 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the result of the installation of an extension.
|
||||
* Sets the step of the installation of an extension.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
* @param result Whether the extension was installed or not.
|
||||
* @param step New install step.
|
||||
*/
|
||||
fun setInstallationResult(downloadId: Long, result: Boolean) {
|
||||
val step = if (result) InstallStep.Installed else InstallStep.Error
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
downloadsRelay.call(downloadId to step)
|
||||
}
|
||||
|
||||
@ -216,9 +238,7 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||
|
||||
// Set next installation step
|
||||
if (uri != null) {
|
||||
downloadsRelay.call(id to InstallStep.Installing)
|
||||
} else {
|
||||
if (uri == null) {
|
||||
Timber.e("Couldn't locate downloaded APK")
|
||||
downloadsRelay.call(id to InstallStep.Error)
|
||||
return
|
||||
|
@ -22,5 +22,6 @@ class ExtensionAdapter(controller: ExtensionController) :
|
||||
|
||||
interface OnButtonClickListener {
|
||||
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) {
|
||||
inflater.inflate(R.menu.browse_extensions, menu)
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.extension
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import coil.clear
|
||||
import coil.load
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
@ -9,7 +10,6 @@ import eu.kanade.tachiyomi.databinding.ExtensionCardItemBinding
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||
FlexibleViewHolder(view, adapter) {
|
||||
@ -20,6 +20,9 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||
binding.extButton.setOnClickListener {
|
||||
adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
|
||||
}
|
||||
binding.cancelButton.setOnClickListener {
|
||||
adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: ExtensionItem) {
|
||||
@ -42,18 +45,14 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||
} else {
|
||||
extension.getApplicationIcon(itemView.context)?.let { binding.image.setImageDrawable(it) }
|
||||
}
|
||||
bindButton(item)
|
||||
bindButtons(item)
|
||||
}
|
||||
|
||||
@Suppress("ResourceType")
|
||||
fun bindButton(item: ExtensionItem) = with(binding.extButton) {
|
||||
isEnabled = true
|
||||
isClickable = true
|
||||
|
||||
fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
|
||||
val extension = item.extension
|
||||
|
||||
val installStep = item.installStep
|
||||
if (installStep != null) {
|
||||
setText(
|
||||
when (installStep) {
|
||||
InstallStep.Pending -> R.string.ext_pending
|
||||
@ -61,25 +60,25 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||
InstallStep.Installing -> R.string.ext_installing
|
||||
InstallStep.Installed -> R.string.ext_installed
|
||||
InstallStep.Error -> R.string.action_retry
|
||||
InstallStep.Idle -> {
|
||||
when (extension) {
|
||||
is Extension.Installed -> {
|
||||
if (extension.hasUpdate) {
|
||||
R.string.ext_update
|
||||
} else {
|
||||
R.string.action_settings
|
||||
}
|
||||
}
|
||||
is Extension.Untrusted -> R.string.ext_trust
|
||||
is Extension.Available -> R.string.ext_install
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (installStep != InstallStep.Error) {
|
||||
isEnabled = false
|
||||
isClickable = false
|
||||
}
|
||||
} else if (extension is Extension.Installed) {
|
||||
when {
|
||||
extension.hasUpdate -> {
|
||||
setText(R.string.ext_update)
|
||||
}
|
||||
else -> {
|
||||
setText(R.string.action_settings)
|
||||
}
|
||||
}
|
||||
} else if (extension is Extension.Untrusted) {
|
||||
setText(R.string.ext_trust)
|
||||
} else {
|
||||
setText(R.string.ext_install)
|
||||
}
|
||||
|
||||
val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
|
||||
binding.cancelButton.isVisible = !isIdle
|
||||
isEnabled = isIdle
|
||||
isClickable = isIdle
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
data class ExtensionItem(
|
||||
val extension: Extension,
|
||||
val header: ExtensionGroupItem? = null,
|
||||
val installStep: InstallStep? = null
|
||||
val installStep: InstallStep = InstallStep.Idle
|
||||
) :
|
||||
AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
|
||||
|
||||
@ -49,7 +49,7 @@ data class ExtensionItem(
|
||||
if (payloads == null || payloads.isEmpty()) {
|
||||
holder.bind(this)
|
||||
} else {
|
||||
holder.bindButton(this)
|
||||
holder.bindButtons(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,14 +77,14 @@ open class ExtensionPresenter(
|
||||
if (updatesSorted.isNotEmpty()) {
|
||||
val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
|
||||
items += updatesSorted.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
|
||||
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
|
||||
|
||||
items += installedSorted.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
}
|
||||
|
||||
items += untrustedSorted.map { extension ->
|
||||
@ -100,7 +100,7 @@ open class ExtensionPresenter(
|
||||
.forEach {
|
||||
val header = ExtensionGroupItem(it.key, it.value.size)
|
||||
items += it.value.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -133,6 +133,10 @@ open class ExtensionPresenter(
|
||||
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
|
||||
}
|
||||
|
||||
fun cancelInstallUpdateExtension(extension: Extension) {
|
||||
extensionManager.cancelInstallUpdateExtension(extension)
|
||||
}
|
||||
|
||||
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
|
||||
this.doOnNext { currentDownloads[extension.pkgName] = it }
|
||||
.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.switchPreference
|
||||
import eu.kanade.tachiyomi.util.preference.titleRes
|
||||
import eu.kanade.tachiyomi.util.system.MiuiUtil
|
||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||
import eu.kanade.tachiyomi.util.system.isTablet
|
||||
import eu.kanade.tachiyomi.util.system.powerManager
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
@ -187,6 +189,45 @@ class SettingsAdvancedController : SettingsController() {
|
||||
}
|
||||
}
|
||||
|
||||
preferenceCategory {
|
||||
titleRes = R.string.label_extensions
|
||||
|
||||
listPreference {
|
||||
key = Keys.extensionInstaller
|
||||
titleRes = R.string.ext_installer_pref
|
||||
summary = "%s"
|
||||
entriesRes = arrayOf(
|
||||
R.string.ext_installer_legacy,
|
||||
R.string.ext_installer_packageinstaller,
|
||||
R.string.ext_installer_shizuku
|
||||
)
|
||||
entryValues = PreferenceValues.ExtensionInstaller.values().map { it.name }.toTypedArray()
|
||||
defaultValue = if (MiuiUtil.isMiui()) {
|
||||
PreferenceValues.ExtensionInstaller.LEGACY
|
||||
} else {
|
||||
PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER
|
||||
}.name
|
||||
|
||||
onChange {
|
||||
if (it == PreferenceValues.ExtensionInstaller.SHIZUKU.name &&
|
||||
!context.isPackageInstalled("moe.shizuku.privileged.api")
|
||||
) {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.ext_installer_shizuku)
|
||||
.setMessage(R.string.ext_installer_shizuku_unavailable_dialog)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
openInBrowser("https://shizuku.rikka.app/download")
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preferenceCategory {
|
||||
titleRes = R.string.pref_category_display
|
||||
|
||||
|
@ -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.net.toUri
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
@ -377,3 +378,24 @@ fun Context.isOnline(): Boolean {
|
||||
}
|
||||
return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(actNw::hasTransport)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets document size of provided [Uri]
|
||||
*
|
||||
* @return document size of [uri] or null if size can't be obtained
|
||||
*/
|
||||
fun Context.getUriSize(uri: Uri): Long? {
|
||||
return UniFile.fromUri(this, uri).length().takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [packageName] is installed.
|
||||
*/
|
||||
fun Context.isPackageInstalled(packageName: String): Boolean {
|
||||
return try {
|
||||
packageManager.getApplicationInfo(packageName, 0)
|
||||
true
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:background="@drawable/list_item_selector_background">
|
||||
|
||||
<ImageView
|
||||
@ -79,10 +80,24 @@
|
||||
style="?attr/borderlessButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/cancel_button"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
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>
|
||||
|
@ -264,6 +264,13 @@
|
||||
<string name="ext_language_info">Language: %1$s</string>
|
||||
<string name="ext_nsfw_short">18+</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 -->
|
||||
<string name="pref_fullscreen">Fullscreen</string>
|
||||
|
Loading…
Reference in New Issue
Block a user