fix anime extension install thing

This commit is contained in:
jmir1 2021-09-26 19:54:59 +02:00
parent e4ef21c70b
commit 2bf1c77f1c
10 changed files with 503 additions and 18 deletions

View file

@ -224,6 +224,9 @@
<service android:name=".extension.util.ExtensionInstallService"
android:exported="false" />
<service android:name=".extension.util.AnimeExtensionInstallService"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View file

@ -0,0 +1,170 @@
package eu.kanade.tachiyomi.extension.installer
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import androidx.annotation.CallSuper
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import eu.kanade.tachiyomi.extension.AnimeExtensionManager
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 InstallerAnime(private val service: Service) {
private val extensionManager: AnimeExtensionManager by injectLazy()
private var waitingInstall = AtomicReference<Entry>(null)
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
private val cancelReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1).takeIf { it >= 0 } ?: return
cancelQueue(downloadId)
}
}
/**
* Installer readiness. If false, queue check will not run.
*
* @see checkQueue
*/
abstract var ready: Boolean
/**
* Add an item to install queue.
*
* @param downloadId Download ID as known by [ExtensionManager]
* @param uri Uri of APK to install
*/
fun addToQueue(downloadId: Long, uri: Uri) {
queue.add(Entry(downloadId, uri))
checkQueue()
}
/**
* Proceeds to install the APK of this entry inside this method. Call [continueQueue]
* when the install process for this entry is finished to continue the queue.
*
* @param entry The [Entry] of item to process
* @see continueQueue
*/
@CallSuper
open fun processEntry(entry: Entry) {
extensionManager.setInstalling(entry.downloadId)
}
/**
* Called before queue continues. Override this to handle when the removed entry is
* currently being processed.
*
* @return true if this entry can be removed from queue.
*/
open fun cancelEntry(entry: Entry): Boolean {
return true
}
/**
* Tells the queue to continue processing the next entry and updates the install step
* of the completed entry ([waitingInstall]) to [ExtensionManager].
*
* @param resultStep new install step for the processed entry.
* @see waitingInstall
*/
fun continueQueue(resultStep: InstallStep) {
val completedEntry = waitingInstall.getAndSet(null)
if (completedEntry != null) {
extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
checkQueue()
}
}
/**
* Checks the queue. The provided service will be stopped if the queue is empty.
* Will not be run when not ready.
*
* @see ready
*/
fun checkQueue() {
if (!ready) {
return
}
if (queue.isEmpty()) {
service.stopSelf()
return
}
val nextEntry = queue.first()
if (waitingInstall.compareAndSet(null, nextEntry)) {
queue.removeFirst()
processEntry(nextEntry)
}
}
/**
* Call this method when the provided service is destroyed.
*/
@CallSuper
open fun onDestroy() {
LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
queue.clear()
waitingInstall.set(null)
}
protected fun getActiveEntry(): Entry? = waitingInstall.get()
/**
* Cancels queue for the provided download ID if exists.
*
* @param downloadId Download ID as known by [ExtensionManager]
*/
private fun cancelQueue(downloadId: Long) {
val waitingInstall = this.waitingInstall.get()
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
if (cancelEntry(toCancel)) {
queue.remove(toCancel)
if (waitingInstall == toCancel) {
// Currently processing removed entry, continue queue
this.waitingInstall.set(null)
checkQueue()
}
extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
}
}
/**
* Install item to queue.
*
* @param downloadId Download ID as known by [ExtensionManager]
* @param uri Uri of APK to install
*/
data class Entry(val downloadId: Long, val uri: Uri)
init {
val filter = IntentFilter(ACTION_CANCEL_QUEUE)
LocalBroadcastManager.getInstance(service).registerReceiver(cancelReceiver, filter)
}
companion object {
private const val ACTION_CANCEL_QUEUE = "Installer.action.CANCEL_QUEUE"
private const val EXTRA_DOWNLOAD_ID = "Installer.extra.DOWNLOAD_ID"
/**
* Attempts to cancel the installation entry for the provided download ID.
*
* @param downloadId Download ID as known by [ExtensionManager]
*/
fun cancelInstallQueue(context: Context, downloadId: Long) {
val intent = Intent(ACTION_CANCEL_QUEUE)
intent.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
}
}

View file

@ -0,0 +1,105 @@
package eu.kanade.tachiyomi.extension.installer
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getUriSize
import timber.log.Timber
class PackageInstallerInstallerAnime(private val service: Service) : InstallerAnime(service) {
private val packageInstaller = service.packageManager.packageInstaller
private val packageActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val userAction = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (userAction == null) {
Timber.e("Fatal error for $intent")
continueQueue(InstallStep.Error)
return
}
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
service.startActivity(userAction)
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
continueQueue(InstallStep.Idle)
}
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
else -> continueQueue(InstallStep.Error)
}
}
}
private var activeSession: Pair<Entry, Int>? = null
// Always ready
override var ready = true
override fun processEntry(entry: Entry) {
super.processEntry(entry)
activeSession = null
try {
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
activeSession = entry to packageInstaller.createSession(installParams)
val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException()
installParams.setSize(fileSize)
val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException()
val session = packageInstaller.openSession(activeSession!!.second)
val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize)
session.use {
arrayOf(inputStream, outputStream).use {
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
val intentSender = PendingIntent.getBroadcast(
service,
activeSession!!.second,
Intent(INSTALL_ACTION),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
).intentSender
session.commit(intentSender)
}
} catch (e: Exception) {
Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
activeSession?.let { (_, sessionId) ->
packageInstaller.abandonSession(sessionId)
}
continueQueue(InstallStep.Error)
}
}
override fun cancelEntry(entry: Entry): Boolean {
activeSession?.let { (activeEntry, sessionId) ->
if (activeEntry == entry) {
packageInstaller.abandonSession(sessionId)
return false
}
}
return true
}
override fun onDestroy() {
service.unregisterReceiver(packageActionReceiver)
super.onDestroy()
}
init {
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
}
}
private const val INSTALL_ACTION = "PackageInstallerInstaller.INSTALL_ACTION"

View file

@ -0,0 +1,127 @@
package eu.kanade.tachiyomi.extension.installer
import android.app.Service
import android.content.pm.PackageManager
import android.os.Build
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.getUriSize
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStream
class ShizukuInstallerAnime(private val service: Service) : InstallerAnime(service) {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val shizukuDeadListener = Shizuku.OnBinderDeadListener {
Timber.e("Shizuku was killed prematurely")
service.stopSelf()
}
private val shizukuPermissionListener = object : Shizuku.OnRequestPermissionResultListener {
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
if (grantResult == PackageManager.PERMISSION_GRANTED) {
ready = true
checkQueue()
} else {
service.stopSelf()
}
Shizuku.removeRequestPermissionResultListener(this)
}
}
}
override var ready = false
@Suppress("BlockingMethodInNonBlockingContext")
override fun processEntry(entry: Entry) {
super.processEntry(entry)
ioScope.launch {
var sessionId: String? = null
try {
val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
service.contentResolver.openInputStream(entry.uri)!!.use {
val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
"pm install-create --user current -i ${service.packageName} -S $size"
} else {
"pm install-create -i ${service.packageName} -S $size"
}
val createResult = exec(createCommand)
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
?: throw RuntimeException("Failed to create install session")
val writeResult = exec("pm install-write -S $size $sessionId base -", it)
if (writeResult.resultCode != 0) {
throw RuntimeException("Failed to write APK to session $sessionId")
}
val commitResult = exec("pm install-commit $sessionId")
if (commitResult.resultCode != 0) {
throw RuntimeException("Failed to commit install session $sessionId")
}
continueQueue(InstallStep.Installed)
}
} catch (e: Exception) {
Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
if (sessionId != null) {
exec("pm install-abandon $sessionId")
}
continueQueue(InstallStep.Error)
}
}
}
// Don't cancel if entry is already started installing
override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
override fun onDestroy() {
Shizuku.removeBinderDeadListener(shizukuDeadListener)
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
ioScope.cancel()
super.onDestroy()
}
private fun exec(command: String, stdin: InputStream? = null): ShellResult {
@Suppress("DEPRECATION")
val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
if (stdin != null) {
process.outputStream.use { stdin.copyTo(it) }
}
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
val resultCode = process.waitFor()
return ShellResult(resultCode, output)
}
private data class ShellResult(val resultCode: Int, val out: String)
init {
Shizuku.addBinderDeadListener(shizukuDeadListener)
ready = if (Shizuku.pingBinder()) {
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
true
} else {
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
false
}
} else {
Timber.e("Shizuku is not ready to use.")
service.toast(R.string.ext_installer_shizuku_stopped)
service.stopSelf()
false
}
}
}
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045
private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")

View file

@ -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.InstallerAnime
import eu.kanade.tachiyomi.extension.installer.PackageInstallerInstallerAnime
import eu.kanade.tachiyomi.extension.installer.ShizukuInstallerAnime
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
import eu.kanade.tachiyomi.util.system.notificationBuilder
import timber.log.Timber
class AnimeExtensionInstallService : Service() {
private var installer: InstallerAnime? = 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 -> PackageInstallerInstallerAnime(this)
PreferenceValues.ExtensionInstaller.SHIZUKU -> ShizukuInstallerAnime(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, AnimeExtensionInstallService::class.java)
.setDataAndType(uri, AnimeExtensionInstaller.APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.putExtra(EXTRA_INSTALLER, installer)
}
}
}

View file

@ -13,7 +13,7 @@ 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.installer.InstallerAnime
import eu.kanade.tachiyomi.extension.model.AnimeExtension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.storage.getUriCompat
@ -142,7 +142,7 @@ internal class AnimeExtensionInstaller(private val context: Context) {
context.startActivity(intent)
}
else -> {
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
val intent = AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer)
ContextCompat.startForegroundService(context, intent)
}
}
@ -154,7 +154,7 @@ internal class AnimeExtensionInstaller(private val context: Context) {
fun cancelInstall(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName) ?: return
downloadManager.remove(downloadId)
Installer.cancelInstallQueue(context, downloadId)
InstallerAnime.cancelInstallQueue(context, downloadId)
}
/**

View file

@ -44,8 +44,9 @@ open class AnimeExtensionPresenter(
val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
val availableObservable = extensionManager.getAvailableExtensionsObservable()
.startWith(emptyList<AnimeExtension.Available>())
return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) }
.debounce(500, TimeUnit.MILLISECONDS)
.debounce(100, TimeUnit.MILLISECONDS)
.map(::toItems)
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, _ -> view.setExtensions(extensions) })

View file

@ -144,7 +144,7 @@ class AnimeExtensionDetailsController(bundle: Bundle? = null) :
block()
onSettingsClick = View.OnClickListener {
router.pushController(
SourcePreferencesController(source.id).withFadeTransaction()
AnimeSourcePreferencesController(source.id).withFadeTransaction()
)
}
}

View file

@ -24,7 +24,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.getPreferenceKey
import eu.kanade.tachiyomi.data.preference.EmptyPreferenceDataStore
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
import eu.kanade.tachiyomi.databinding.SourcePreferencesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
@ -32,8 +31,8 @@ import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncogn
import timber.log.Timber
@SuppressLint("RestrictedApi")
class SourcePreferencesController(bundle: Bundle? = null) :
NucleusController<SourcePreferencesControllerBinding, SourcePreferencesPresenter>(bundle),
class AnimeSourcePreferencesController(bundle: Bundle? = null) :
NucleusController<SourcePreferencesControllerBinding, AnimeSourcePreferencesPresenter>(bundle),
PreferenceManager.OnDisplayPreferenceDialogListener,
DialogPreference.TargetFragment {
@ -50,8 +49,8 @@ class SourcePreferencesController(bundle: Bundle? = null) :
return SourcePreferencesControllerBinding.inflate(themedInflater)
}
override fun createPresenter(): SourcePreferencesPresenter {
return SourcePreferencesPresenter(args.getLong(SOURCE_ID))
override fun createPresenter(): AnimeSourcePreferencesPresenter {
return AnimeSourcePreferencesPresenter(args.getLong(SOURCE_ID))
}
override fun getTitle(): String? {
@ -67,7 +66,10 @@ class SourcePreferencesController(bundle: Bundle? = null) :
val themedContext by lazy { getPreferenceThemeContext() }
val manager = PreferenceManager(themedContext)
manager.preferenceDataStore = EmptyPreferenceDataStore()
val dataStore = SharedPreferencesDataStore(
context.getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE)
)
manager.preferenceDataStore = dataStore
manager.onDisplayPreferenceDialogListener = this
val screen = manager.createPreferenceScreen(themedContext)
preferenceScreen = screen
@ -102,10 +104,6 @@ class SourcePreferencesController(bundle: Bundle? = null) :
private fun addPreferencesForSource(screen: PreferenceScreen, source: AnimeSource) {
val context = screen.context
val dataStore = SharedPreferencesDataStore(
context.getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE)
)
if (source is ConfigurableAnimeSource) {
val newScreen = screen.preferenceManager.createPreferenceScreen(context)
source.setupPreferenceScreen(newScreen)
@ -114,7 +112,6 @@ class SourcePreferencesController(bundle: Bundle? = null) :
while (newScreen.preferenceCount != 0) {
val pref = newScreen.getPreference(0)
pref.isIconSpaceReserved = false
pref.preferenceDataStore = dataStore
pref.order = Int.MAX_VALUE // reset to default order
// Apply incognito IME for EditTextPreference

View file

@ -5,10 +5,10 @@ import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SourcePreferencesPresenter(
class AnimeSourcePreferencesPresenter(
val sourceId: Long,
sourceManager: AnimeSourceManager = Injekt.get()
) : BasePresenter<SourcePreferencesController>() {
) : BasePresenter<AnimeSourcePreferencesController>() {
val source = sourceManager.get(sourceId)
}