Cleanup DownloadService
This commit is contained in:
parent
4b238a9cd0
commit
abc5457136
@ -0,0 +1,201 @@
|
|||||||
|
package com.topjohnwu.magisk.core.download
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import com.topjohnwu.magisk.R
|
||||||
|
import com.topjohnwu.magisk.core.ForegroundTracker
|
||||||
|
import com.topjohnwu.magisk.core.base.BaseService
|
||||||
|
import com.topjohnwu.magisk.core.utils.ProgressInputStream
|
||||||
|
import com.topjohnwu.magisk.data.network.GithubRawServices
|
||||||
|
import com.topjohnwu.magisk.ktx.checkSum
|
||||||
|
import com.topjohnwu.magisk.ktx.writeTo
|
||||||
|
import com.topjohnwu.magisk.view.Notifications
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koin.core.KoinComponent
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.HashMap
|
||||||
|
import kotlin.random.Random.Default.nextInt
|
||||||
|
|
||||||
|
abstract class BaseDownloadService : BaseService(), KoinComponent {
|
||||||
|
|
||||||
|
private val hasNotifications get() = notifications.isNotEmpty()
|
||||||
|
private val notifications = Collections.synchronizedMap(HashMap<Int, Notification.Builder>())
|
||||||
|
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
val service: GithubRawServices by inject()
|
||||||
|
|
||||||
|
// -- Service overrides
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
intent?.getParcelableExtra<DownloadSubject>(ARG_URL)?.let { subject ->
|
||||||
|
update(subject.notifyID())
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
subject.startDownload()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Timber.e(e)
|
||||||
|
notifyFail(subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
notifications.forEach { cancel(it.key) }
|
||||||
|
notifications.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
coroutineScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Download logic
|
||||||
|
|
||||||
|
private suspend fun DownloadSubject.startDownload() {
|
||||||
|
val skip = this is DownloadSubject.Magisk && file.exists() && file.checkSum("MD5", magisk.md5)
|
||||||
|
if (!skip) {
|
||||||
|
val stream = service.fetchFile(url).toProgressStream(this)
|
||||||
|
when (this) {
|
||||||
|
is DownloadSubject.Module -> // Download and process on-the-fly
|
||||||
|
stream.toModule(file, service.fetchInstaller().byteStream())
|
||||||
|
else ->
|
||||||
|
stream.writeTo(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newId = notifyFinish(this)
|
||||||
|
if (ForegroundTracker.hasForeground)
|
||||||
|
onFinish(this, newId)
|
||||||
|
if (!hasNotifications)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResponseBody.toProgressStream(subject: DownloadSubject): InputStream {
|
||||||
|
val max = contentLength()
|
||||||
|
val total = max.toFloat() / 1048576
|
||||||
|
val id = subject.notifyID()
|
||||||
|
|
||||||
|
update(id) { it.setContentTitle(subject.title) }
|
||||||
|
|
||||||
|
return ProgressInputStream(byteStream()) {
|
||||||
|
val progress = it.toFloat() / 1048576
|
||||||
|
update(id) { notification ->
|
||||||
|
if (max > 0) {
|
||||||
|
broadcast(progress / total, subject)
|
||||||
|
notification
|
||||||
|
.setProgress(max.toInt(), it.toInt(), false)
|
||||||
|
.setContentText("%.2f / %.2f MB".format(progress, total))
|
||||||
|
} else {
|
||||||
|
broadcast(-1f, subject)
|
||||||
|
notification.setContentText("%.2f MB / ??".format(progress))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notification managements
|
||||||
|
|
||||||
|
fun DownloadSubject.notifyID() = hashCode()
|
||||||
|
|
||||||
|
private fun notifyFail(subject: DownloadSubject) = lastNotify(subject.notifyID()) {
|
||||||
|
broadcast(-1f, subject)
|
||||||
|
it.setContentText(getString(R.string.download_file_error))
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setOngoing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyFinish(subject: DownloadSubject) = lastNotify(subject.notifyID()) {
|
||||||
|
broadcast(1f, subject)
|
||||||
|
it.setIntent(subject)
|
||||||
|
.setContentText(getString(R.string.download_complete))
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setProgress(0, 0, false)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun create() = Notifications.progress(this, "")
|
||||||
|
|
||||||
|
fun update(id: Int, editor: (Notification.Builder) -> Unit = {}) {
|
||||||
|
val wasEmpty = !hasNotifications
|
||||||
|
val notification = notifications.getOrPut(id, ::create).also(editor)
|
||||||
|
if (wasEmpty)
|
||||||
|
updateForeground()
|
||||||
|
else
|
||||||
|
notify(id, notification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lastNotify(
|
||||||
|
id: Int,
|
||||||
|
editor: (Notification.Builder) -> Notification.Builder? = { null }
|
||||||
|
) : Int {
|
||||||
|
val notification = remove(id)?.run(editor) ?: return -1
|
||||||
|
val newId: Int = nextInt()
|
||||||
|
notify(newId, notification.build())
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun remove(id: Int) = notifications.remove(id)?.also {
|
||||||
|
updateForeground()
|
||||||
|
cancel(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notify(id: Int, notification: Notification) {
|
||||||
|
Notifications.mgr.notify(id, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun cancel(id: Int) {
|
||||||
|
Notifications.mgr.cancel(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateForeground() {
|
||||||
|
if (hasNotifications) {
|
||||||
|
val (id, notification) = notifications.entries.first()
|
||||||
|
startForeground(id, notification.build())
|
||||||
|
} else {
|
||||||
|
stopForeground(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Implement custom logic
|
||||||
|
|
||||||
|
protected abstract suspend fun onFinish(subject: DownloadSubject, id: Int)
|
||||||
|
|
||||||
|
protected abstract fun Notification.Builder.setIntent(subject: DownloadSubject)
|
||||||
|
: Notification.Builder
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
companion object : KoinComponent {
|
||||||
|
const val ARG_URL = "arg_url"
|
||||||
|
|
||||||
|
private val progressBroadcast = MutableLiveData<Pair<Float, DownloadSubject>>()
|
||||||
|
|
||||||
|
fun observeProgress(owner: LifecycleOwner, callback: (Float, DownloadSubject) -> Unit) {
|
||||||
|
progressBroadcast.value = null
|
||||||
|
progressBroadcast.observe(owner) {
|
||||||
|
val (progress, subject) = it ?: return@observe
|
||||||
|
callback(progress, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcast(progress: Float, subject: DownloadSubject) {
|
||||||
|
progressBroadcast.postValue(progress to subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,139 +6,84 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import com.topjohnwu.magisk.R
|
|
||||||
import com.topjohnwu.magisk.core.download.Configuration.*
|
import com.topjohnwu.magisk.core.download.Configuration.*
|
||||||
import com.topjohnwu.magisk.core.download.Configuration.Flash.Secondary
|
import com.topjohnwu.magisk.core.download.Configuration.Flash.Secondary
|
||||||
import com.topjohnwu.magisk.core.download.DownloadSubject.*
|
import com.topjohnwu.magisk.core.download.DownloadSubject.*
|
||||||
import com.topjohnwu.magisk.core.intent
|
import com.topjohnwu.magisk.core.intent
|
||||||
import com.topjohnwu.magisk.core.tasks.EnvFixTask
|
import com.topjohnwu.magisk.core.tasks.EnvFixTask
|
||||||
import com.topjohnwu.magisk.ktx.chooser
|
|
||||||
import com.topjohnwu.magisk.ktx.exists
|
|
||||||
import com.topjohnwu.magisk.ktx.provide
|
|
||||||
import com.topjohnwu.magisk.ui.flash.FlashFragment
|
import com.topjohnwu.magisk.ui.flash.FlashFragment
|
||||||
import com.topjohnwu.magisk.utils.APKInstall
|
import com.topjohnwu.magisk.utils.APKInstall
|
||||||
import org.koin.core.get
|
|
||||||
import java.io.File
|
|
||||||
import kotlin.random.Random.Default.nextInt
|
import kotlin.random.Random.Default.nextInt
|
||||||
|
|
||||||
/* More of a facade for [RemoteFileService], but whatever... */
|
|
||||||
@SuppressLint("Registered")
|
@SuppressLint("Registered")
|
||||||
open class DownloadService : RemoteFileService() {
|
open class DownloadService : BaseDownloadService() {
|
||||||
|
|
||||||
private val context get() = this
|
private val context get() = this
|
||||||
private val File.type
|
|
||||||
get() = MimeTypeMap.getSingleton()
|
|
||||||
.getMimeTypeFromExtension(extension)
|
|
||||||
?: "resource/folder"
|
|
||||||
|
|
||||||
override suspend fun onFinished(subject: DownloadSubject, id: Int) = when (subject) {
|
override suspend fun onFinish(subject: DownloadSubject, id: Int) = when (subject) {
|
||||||
is Magisk -> onFinished(subject, id)
|
is Magisk -> subject.onFinish(id)
|
||||||
is Module -> onFinished(subject, id)
|
is Module -> subject.onFinish(id)
|
||||||
is Manager -> onFinished(subject, id)
|
is Manager -> subject.onFinish(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onFinished(
|
private suspend fun Magisk.onFinish(id: Int) = when (val conf = configuration) {
|
||||||
subject: Magisk,
|
Uninstall -> FlashFragment.uninstall(file, id)
|
||||||
id: Int
|
|
||||||
) = when (val conf = subject.configuration) {
|
|
||||||
Uninstall -> FlashFragment.uninstall(subject.file, id)
|
|
||||||
EnvFix -> {
|
EnvFix -> {
|
||||||
remove(id)
|
cancel(id)
|
||||||
EnvFixTask(subject.file).exec()
|
EnvFixTask(file).exec()
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
is Patch -> FlashFragment.patch(subject.file, conf.fileUri, id)
|
is Patch -> FlashFragment.patch(file, conf.fileUri, id)
|
||||||
is Flash -> FlashFragment.flash(subject.file, conf is Secondary, id)
|
is Flash -> FlashFragment.flash(file, conf is Secondary, id)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onFinished(
|
private fun Module.onFinish(id: Int) = when (configuration) {
|
||||||
subject: Module,
|
is Flash -> FlashFragment.install(file, id)
|
||||||
id: Int
|
|
||||||
) = when (subject.configuration) {
|
|
||||||
is Flash -> FlashFragment.install(subject.file, id)
|
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onFinished(
|
private suspend fun Manager.onFinish(id: Int) {
|
||||||
subject: Manager,
|
handleAPK(this)
|
||||||
id: Int
|
cancel(id)
|
||||||
) {
|
|
||||||
handleAPK(subject)
|
|
||||||
remove(id)
|
|
||||||
when (subject.configuration) {
|
|
||||||
is APK.Upgrade -> APKInstall.install(this, subject.file)
|
|
||||||
is APK.Restore -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---
|
// --- Customize finish notification
|
||||||
|
|
||||||
override fun Notification.Builder.addActions(subject: DownloadSubject)
|
override fun Notification.Builder.setIntent(subject: DownloadSubject)
|
||||||
= when (subject) {
|
= when (subject) {
|
||||||
is Magisk -> addActions(subject)
|
is Magisk -> setIntent(subject)
|
||||||
is Module -> addActions(subject)
|
is Module -> setIntent(subject)
|
||||||
is Manager -> addActions(subject)
|
is Manager -> setIntent(subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Notification.Builder.addActions(subject: Magisk)
|
private fun Notification.Builder.setIntent(subject: Magisk)
|
||||||
= when (val conf = subject.configuration) {
|
= when (val conf = subject.configuration) {
|
||||||
Download -> apply {
|
|
||||||
fileIntent(subject.file.parentFile!!)
|
|
||||||
.takeIf { it.exists(get()) }
|
|
||||||
?.let { addAction(0, R.string.download_open_parent, it.chooser()) }
|
|
||||||
fileIntent(subject.file)
|
|
||||||
.takeIf { it.exists(get()) }
|
|
||||||
?.let { addAction(0, R.string.download_open_self, it.chooser()) }
|
|
||||||
}
|
|
||||||
Uninstall -> setContentIntent(FlashFragment.uninstallIntent(context, subject.file))
|
Uninstall -> setContentIntent(FlashFragment.uninstallIntent(context, subject.file))
|
||||||
is Flash -> setContentIntent(FlashFragment.flashIntent(context, subject.file, conf is Secondary))
|
is Flash -> setContentIntent(FlashFragment.flashIntent(context, subject.file, conf is Secondary))
|
||||||
is Patch -> setContentIntent(FlashFragment.patchIntent(context, subject.file, conf.fileUri))
|
is Patch -> setContentIntent(FlashFragment.patchIntent(context, subject.file, conf.fileUri))
|
||||||
else -> this
|
else -> setContentIntent(Intent())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Notification.Builder.addActions(subject: Module)
|
private fun Notification.Builder.setIntent(subject: Module)
|
||||||
= when (subject.configuration) {
|
= when (subject.configuration) {
|
||||||
Download -> this.apply {
|
|
||||||
fileIntent(subject.file.parentFile!!)
|
|
||||||
.takeIf { it.exists(get()) }
|
|
||||||
?.let { addAction(0, R.string.download_open_parent, it.chooser()) }
|
|
||||||
fileIntent(subject.file)
|
|
||||||
.takeIf { it.exists(get()) }
|
|
||||||
?.let { addAction(0, R.string.download_open_self, it.chooser()) }
|
|
||||||
}
|
|
||||||
is Flash -> setContentIntent(FlashFragment.installIntent(context, subject.file))
|
is Flash -> setContentIntent(FlashFragment.installIntent(context, subject.file))
|
||||||
else -> this
|
else -> setContentIntent(Intent())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Notification.Builder.addActions(subject: Manager)
|
private fun Notification.Builder.setIntent(subject: Manager)
|
||||||
= when (subject.configuration) {
|
= when (subject.configuration) {
|
||||||
APK.Upgrade -> setContentIntent(APKInstall.installIntent(context, subject.file))
|
APK.Upgrade -> setContentIntent(APKInstall.installIntent(context, subject.file))
|
||||||
else -> this
|
else -> setContentIntent(Intent())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ReplaceSingleLineLet")
|
|
||||||
private fun Notification.Builder.setContentIntent(intent: Intent) =
|
private fun Notification.Builder.setContentIntent(intent: Intent) =
|
||||||
setContentIntent(
|
setContentIntent(
|
||||||
PendingIntent.getActivity(context, nextInt(), intent, PendingIntent.FLAG_ONE_SHOT)
|
PendingIntent.getActivity(context, nextInt(), intent, PendingIntent.FLAG_ONE_SHOT)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Suppress("ReplaceSingleLineLet")
|
|
||||||
private fun Notification.Builder.addAction(icon: Int, title: Int, intent: Intent) =
|
|
||||||
addAction(icon, getString(title),
|
|
||||||
PendingIntent.getActivity(context, nextInt(), intent, PendingIntent.FLAG_ONE_SHOT)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---
|
// ---
|
||||||
|
|
||||||
private fun fileIntent(file: File): Intent {
|
|
||||||
return Intent(Intent.ACTION_VIEW)
|
|
||||||
.setDataAndType(file.provide(this), file.type)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Builder {
|
class Builder {
|
||||||
lateinit var subject: DownloadSubject
|
lateinit var subject: DownloadSubject
|
||||||
}
|
}
|
||||||
@ -150,7 +95,7 @@ open class DownloadService : RemoteFileService() {
|
|||||||
val builder = Builder().apply(argBuilder)
|
val builder = Builder().apply(argBuilder)
|
||||||
val intent = app.intent<DownloadService>().putExtra(ARG_URL, builder.subject)
|
val intent = app.intent<DownloadService>().putExtra(ARG_URL, builder.subject)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= 26) {
|
||||||
app.startForegroundService(intent)
|
app.startForegroundService(intent)
|
||||||
} else {
|
} else {
|
||||||
app.startService(intent)
|
app.startService(intent)
|
||||||
|
@ -12,10 +12,11 @@ import com.topjohnwu.magisk.core.intent
|
|||||||
import com.topjohnwu.magisk.core.isRunningAsStub
|
import com.topjohnwu.magisk.core.isRunningAsStub
|
||||||
import com.topjohnwu.magisk.core.utils.PatchAPK
|
import com.topjohnwu.magisk.core.utils.PatchAPK
|
||||||
import com.topjohnwu.magisk.ktx.writeTo
|
import com.topjohnwu.magisk.ktx.writeTo
|
||||||
|
import com.topjohnwu.magisk.utils.APKInstall
|
||||||
import com.topjohnwu.superuser.Shell
|
import com.topjohnwu.superuser.Shell
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
private fun RemoteFileService.patch(apk: File, id: Int) {
|
private fun DownloadService.patch(apk: File, id: Int) {
|
||||||
if (packageName == BuildConfig.APPLICATION_ID)
|
if (packageName == BuildConfig.APPLICATION_ID)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -31,7 +32,7 @@ private fun RemoteFileService.patch(apk: File, id: Int) {
|
|||||||
patched.renameTo(apk)
|
patched.renameTo(apk)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun RemoteFileService.upgrade(apk: File, id: Int) {
|
private suspend fun DownloadService.upgrade(apk: File, id: Int) {
|
||||||
if (isRunningAsStub) {
|
if (isRunningAsStub) {
|
||||||
// Move to upgrade location
|
// Move to upgrade location
|
||||||
apk.copyTo(DynAPK.update(this), overwrite = true)
|
apk.copyTo(DynAPK.update(this), overwrite = true)
|
||||||
@ -49,9 +50,10 @@ private suspend fun RemoteFileService.upgrade(apk: File, id: Int) {
|
|||||||
} else {
|
} else {
|
||||||
patch(apk, id)
|
patch(apk, id)
|
||||||
}
|
}
|
||||||
|
APKInstall.install(this, apk)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RemoteFileService.restore(apk: File, id: Int) {
|
private fun DownloadService.restore(apk: File, id: Int) {
|
||||||
update(id) {
|
update(id) {
|
||||||
it.setProgress(0, 0, true)
|
it.setProgress(0, 0, true)
|
||||||
.setProgress(0, 0, true)
|
.setProgress(0, 0, true)
|
||||||
@ -64,8 +66,8 @@ private fun RemoteFileService.restore(apk: File, id: Int) {
|
|||||||
Shell.su("pm install $apk && pm uninstall $packageName").exec()
|
Shell.su("pm install $apk && pm uninstall $packageName").exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun RemoteFileService.handleAPK(subject: DownloadSubject.Manager) =
|
suspend fun DownloadService.handleAPK(subject: DownloadSubject.Manager) =
|
||||||
when (subject.configuration) {
|
when (subject.configuration) {
|
||||||
is Upgrade -> upgrade(subject.file, subject.hashCode())
|
is Upgrade -> upgrade(subject.file, subject.notifyID())
|
||||||
is Restore -> restore(subject.file, subject.hashCode())
|
is Restore -> restore(subject.file, subject.notifyID())
|
||||||
}
|
}
|
||||||
|
@ -1,96 +0,0 @@
|
|||||||
package com.topjohnwu.magisk.core.download
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.IBinder
|
|
||||||
import com.topjohnwu.magisk.core.base.BaseService
|
|
||||||
import com.topjohnwu.magisk.view.Notifications
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import org.koin.core.KoinComponent
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.collections.HashMap
|
|
||||||
import kotlin.random.Random.Default.nextInt
|
|
||||||
|
|
||||||
abstract class NotificationService : BaseService(), KoinComponent {
|
|
||||||
|
|
||||||
private val hasNotifications get() = notifications.isNotEmpty()
|
|
||||||
|
|
||||||
private val notifications = Collections.synchronizedMap(HashMap<Int, Notification.Builder>())
|
|
||||||
|
|
||||||
val coroutineScope = CoroutineScope(Dispatchers.IO)
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
notifications.forEach { cancel(it.key) }
|
|
||||||
notifications.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
coroutineScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun createNotification(): Notification.Builder
|
|
||||||
|
|
||||||
// --
|
|
||||||
|
|
||||||
fun update(
|
|
||||||
id: Int,
|
|
||||||
body: (Notification.Builder) -> Unit = {}
|
|
||||||
) {
|
|
||||||
val wasEmpty = notifications.isEmpty()
|
|
||||||
val notification = notifications.getOrPut(id, ::createNotification).also(body)
|
|
||||||
if (wasEmpty)
|
|
||||||
updateForeground()
|
|
||||||
else
|
|
||||||
notify(id, notification.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun lastNotify(
|
|
||||||
id: Int,
|
|
||||||
editBody: (Notification.Builder) -> Notification.Builder? = { null }
|
|
||||||
) : Int {
|
|
||||||
val currentNotification = remove(id)?.run(editBody)
|
|
||||||
|
|
||||||
var newId = -1
|
|
||||||
currentNotification?.let {
|
|
||||||
newId = nextInt(Int.MAX_VALUE)
|
|
||||||
notify(newId, it.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasNotifications) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
return newId
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun remove(id: Int) = notifications.remove(id).also {
|
|
||||||
cancel(id)
|
|
||||||
updateForeground()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
private fun notify(id: Int, notification: Notification) {
|
|
||||||
Notifications.mgr.notify(id, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancel(id: Int) {
|
|
||||||
Notifications.mgr.cancel(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateForeground() {
|
|
||||||
if (hasNotifications) {
|
|
||||||
val first = notifications.entries.first()
|
|
||||||
startForeground(first.key, first.value.build())
|
|
||||||
} else {
|
|
||||||
stopForeground(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --
|
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder? = null
|
|
||||||
}
|
|
@ -1,128 +0,0 @@
|
|||||||
package com.topjohnwu.magisk.core.download
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.topjohnwu.magisk.R
|
|
||||||
import com.topjohnwu.magisk.core.ForegroundTracker
|
|
||||||
import com.topjohnwu.magisk.core.download.DownloadSubject.Magisk
|
|
||||||
import com.topjohnwu.magisk.core.download.DownloadSubject.Module
|
|
||||||
import com.topjohnwu.magisk.core.utils.ProgressInputStream
|
|
||||||
import com.topjohnwu.magisk.data.network.GithubRawServices
|
|
||||||
import com.topjohnwu.magisk.ktx.checkSum
|
|
||||||
import com.topjohnwu.magisk.ktx.writeTo
|
|
||||||
import com.topjohnwu.magisk.view.Notifications
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import org.koin.android.ext.android.inject
|
|
||||||
import org.koin.core.KoinComponent
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
abstract class RemoteFileService : NotificationService() {
|
|
||||||
|
|
||||||
val service: GithubRawServices by inject()
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
intent?.getParcelableExtra<DownloadSubject>(ARG_URL)?.let {
|
|
||||||
update(it.hashCode())
|
|
||||||
coroutineScope.launch {
|
|
||||||
try {
|
|
||||||
start(it)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Timber.e(e)
|
|
||||||
failNotify(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return START_REDELIVER_INTENT
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createNotification() = Notifications.progress(this, "")
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
private suspend fun start(subject: DownloadSubject) {
|
|
||||||
if (subject !is Magisk ||
|
|
||||||
!subject.file.exists() ||
|
|
||||||
!subject.file.checkSum("MD5", subject.magisk.md5)) {
|
|
||||||
val stream = service.fetchFile(subject.url).toProgressStream(subject)
|
|
||||||
when (subject) {
|
|
||||||
is Module ->
|
|
||||||
stream.toModule(subject.file, service.fetchInstaller().byteStream())
|
|
||||||
else ->
|
|
||||||
stream.writeTo(subject.file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val newId = finishNotify(subject)
|
|
||||||
if (ForegroundTracker.hasForeground) {
|
|
||||||
onFinished(subject, newId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ResponseBody.toProgressStream(subject: DownloadSubject): InputStream {
|
|
||||||
val maxRaw = contentLength()
|
|
||||||
val max = maxRaw / 1_000_000f
|
|
||||||
val id = subject.hashCode()
|
|
||||||
|
|
||||||
update(id) { it.setContentTitle(subject.title) }
|
|
||||||
|
|
||||||
return ProgressInputStream(byteStream()) {
|
|
||||||
val progress = it / 1_000_000f
|
|
||||||
update(id) { notification ->
|
|
||||||
if (maxRaw > 0) {
|
|
||||||
send(progress / max, subject)
|
|
||||||
notification
|
|
||||||
.setProgress(maxRaw.toInt(), it.toInt(), false)
|
|
||||||
.setContentText("%.2f / %.2f MB".format(progress, max))
|
|
||||||
} else {
|
|
||||||
send(-1f, subject)
|
|
||||||
notification.setContentText("%.2f MB / ??".format(progress))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun failNotify(subject: DownloadSubject) = lastNotify(subject.hashCode()) {
|
|
||||||
send(0f, subject)
|
|
||||||
it.setContentText(getString(R.string.download_file_error))
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
.setOngoing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finishNotify(subject: DownloadSubject) = lastNotify(subject.hashCode()) {
|
|
||||||
send(1f, subject)
|
|
||||||
it.addActions(subject)
|
|
||||||
.setContentText(getString(R.string.download_complete))
|
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
.setProgress(0, 0, false)
|
|
||||||
.setOngoing(false)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
|
|
||||||
protected abstract suspend fun onFinished(subject: DownloadSubject, id: Int)
|
|
||||||
|
|
||||||
protected abstract fun Notification.Builder.addActions(subject: DownloadSubject)
|
|
||||||
: Notification.Builder
|
|
||||||
|
|
||||||
companion object : KoinComponent {
|
|
||||||
const val ARG_URL = "arg_url"
|
|
||||||
|
|
||||||
private val internalProgressBroadcast = MutableLiveData<Pair<Float, DownloadSubject>>()
|
|
||||||
val progressBroadcast: LiveData<Pair<Float, DownloadSubject>> get() = internalProgressBroadcast
|
|
||||||
|
|
||||||
fun send(progress: Float, subject: DownloadSubject) {
|
|
||||||
internalProgressBroadcast.postValue(progress to subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
internalProgressBroadcast.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,6 +1,5 @@
|
|||||||
package com.topjohnwu.magisk.core.utils
|
package com.topjohnwu.magisk.core.utils
|
||||||
|
|
||||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
|
||||||
import java.io.FilterInputStream
|
import java.io.FilterInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ class ProgressInputStream(
|
|||||||
val cur = System.currentTimeMillis()
|
val cur = System.currentTimeMillis()
|
||||||
if (cur - lastUpdate > 1000) {
|
if (cur - lastUpdate > 1000) {
|
||||||
lastUpdate = cur
|
lastUpdate = cur
|
||||||
UiThreadHandler.run { progressEmitter(bytesRead) }
|
progressEmitter(bytesRead)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import android.os.Bundle
|
|||||||
import android.view.*
|
import android.view.*
|
||||||
import com.topjohnwu.magisk.R
|
import com.topjohnwu.magisk.R
|
||||||
import com.topjohnwu.magisk.arch.BaseUIFragment
|
import com.topjohnwu.magisk.arch.BaseUIFragment
|
||||||
|
import com.topjohnwu.magisk.core.download.BaseDownloadService
|
||||||
import com.topjohnwu.magisk.databinding.FragmentHomeMd2Binding
|
import com.topjohnwu.magisk.databinding.FragmentHomeMd2Binding
|
||||||
import com.topjohnwu.magisk.events.RebootEvent
|
import com.topjohnwu.magisk.events.RebootEvent
|
||||||
import com.topjohnwu.superuser.Shell
|
import com.topjohnwu.superuser.Shell
|
||||||
@ -18,6 +19,7 @@ class HomeFragment : BaseUIFragment<HomeViewModel, FragmentHomeMd2Binding>() {
|
|||||||
super.onStart()
|
super.onStart()
|
||||||
activity.title = resources.getString(R.string.section_home)
|
activity.title = resources.getString(R.string.section_home)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
BaseDownloadService.observeProgress(this, viewModel::onProgressUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
|
@ -8,8 +8,8 @@ import com.topjohnwu.magisk.R
|
|||||||
import com.topjohnwu.magisk.arch.*
|
import com.topjohnwu.magisk.arch.*
|
||||||
import com.topjohnwu.magisk.core.Config
|
import com.topjohnwu.magisk.core.Config
|
||||||
import com.topjohnwu.magisk.core.Info
|
import com.topjohnwu.magisk.core.Info
|
||||||
|
import com.topjohnwu.magisk.core.download.DownloadSubject
|
||||||
import com.topjohnwu.magisk.core.download.DownloadSubject.Manager
|
import com.topjohnwu.magisk.core.download.DownloadSubject.Manager
|
||||||
import com.topjohnwu.magisk.core.download.RemoteFileService
|
|
||||||
import com.topjohnwu.magisk.core.model.MagiskJson
|
import com.topjohnwu.magisk.core.model.MagiskJson
|
||||||
import com.topjohnwu.magisk.core.model.ManagerJson
|
import com.topjohnwu.magisk.core.model.ManagerJson
|
||||||
import com.topjohnwu.magisk.data.repository.MagiskRepository
|
import com.topjohnwu.magisk.data.repository.MagiskRepository
|
||||||
@ -77,14 +77,6 @@ class HomeViewModel(
|
|||||||
|
|
||||||
private var shownDialog = false
|
private var shownDialog = false
|
||||||
|
|
||||||
init {
|
|
||||||
RemoteFileService.progressBroadcast.observeForever {
|
|
||||||
when (it?.second) {
|
|
||||||
is Manager -> stateManagerProgress = it.first.times(100f).roundToInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun refresh() = viewModelScope.launch {
|
override fun refresh() = viewModelScope.launch {
|
||||||
notifyPropertyChanged(BR.showUninstall)
|
notifyPropertyChanged(BR.showUninstall)
|
||||||
repoMagisk.fetchUpdate()?.apply {
|
repoMagisk.fetchUpdate()?.apply {
|
||||||
@ -119,6 +111,12 @@ class HomeViewModel(
|
|||||||
}
|
}
|
||||||
}.publish()
|
}.publish()
|
||||||
|
|
||||||
|
fun onProgressUpdate(progress: Float, subject: DownloadSubject) {
|
||||||
|
when (subject) {
|
||||||
|
is Manager -> stateManagerProgress = progress.times(100f).roundToInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onLinkPressed(link: String) = OpenInappLinkEvent(link).publish()
|
fun onLinkPressed(link: String) = OpenInappLinkEvent(link).publish()
|
||||||
|
|
||||||
fun onDeletePressed() = UninstallDialog().publish()
|
fun onDeletePressed() = UninstallDialog().publish()
|
||||||
|
@ -4,6 +4,7 @@ import android.content.Intent
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.topjohnwu.magisk.R
|
import com.topjohnwu.magisk.R
|
||||||
import com.topjohnwu.magisk.arch.BaseUIFragment
|
import com.topjohnwu.magisk.arch.BaseUIFragment
|
||||||
|
import com.topjohnwu.magisk.core.download.BaseDownloadService
|
||||||
import com.topjohnwu.magisk.databinding.FragmentInstallMd2Binding
|
import com.topjohnwu.magisk.databinding.FragmentInstallMd2Binding
|
||||||
import com.topjohnwu.magisk.events.RequestFileEvent
|
import com.topjohnwu.magisk.events.RequestFileEvent
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
@ -24,6 +25,7 @@ class InstallFragment : BaseUIFragment<InstallViewModel, FragmentInstallMd2Bindi
|
|||||||
|
|
||||||
// Allow markwon to run in viewmodel scope
|
// Allow markwon to run in viewmodel scope
|
||||||
binding.releaseNotes.tag = viewModel.viewModelScope
|
binding.releaseNotes.tag = viewModel.viewModelScope
|
||||||
|
BaseDownloadService.observeProgress(this, viewModel::onProgressUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,6 @@ import com.topjohnwu.magisk.core.Info
|
|||||||
import com.topjohnwu.magisk.core.download.Configuration
|
import com.topjohnwu.magisk.core.download.Configuration
|
||||||
import com.topjohnwu.magisk.core.download.DownloadService
|
import com.topjohnwu.magisk.core.download.DownloadService
|
||||||
import com.topjohnwu.magisk.core.download.DownloadSubject
|
import com.topjohnwu.magisk.core.download.DownloadSubject
|
||||||
import com.topjohnwu.magisk.core.download.RemoteFileService
|
|
||||||
import com.topjohnwu.magisk.data.repository.StringRepository
|
import com.topjohnwu.magisk.data.repository.StringRepository
|
||||||
import com.topjohnwu.magisk.events.RequestFileEvent
|
import com.topjohnwu.magisk.events.RequestFileEvent
|
||||||
import com.topjohnwu.magisk.events.dialog.SecondSlotWarningDialog
|
import com.topjohnwu.magisk.events.dialog.SecondSlotWarningDialog
|
||||||
@ -60,21 +59,20 @@ class InstallViewModel(
|
|||||||
set(value) = set(value, field, { field = it }, BR.notes)
|
set(value) = set(value, field, { field = it }, BR.notes)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
RemoteFileService.reset()
|
viewModelScope.launch {
|
||||||
RemoteFileService.progressBroadcast.observeForever {
|
notes = stringRepo.getString(Info.remote.magisk.note)
|
||||||
val (progress, subject) = it ?: return@observeForever
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onProgressUpdate(progress: Float, subject: DownloadSubject) {
|
||||||
if (subject !is DownloadSubject.Magisk) {
|
if (subject !is DownloadSubject.Magisk) {
|
||||||
return@observeForever
|
return
|
||||||
}
|
}
|
||||||
this.progress = progress.times(100).roundToInt()
|
this.progress = progress.times(100).roundToInt()
|
||||||
if (this.progress >= 100) {
|
if (this.progress >= 100) {
|
||||||
state = State.LOADED
|
state = State.LOADED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
|
||||||
notes = stringRepo.getString(Info.remote.magisk.note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun step(nextStep: Int) {
|
fun step(nextStep: Int) {
|
||||||
step = nextStep
|
step = nextStep
|
||||||
|
@ -12,6 +12,7 @@ import com.topjohnwu.magisk.R
|
|||||||
import com.topjohnwu.magisk.arch.BaseUIFragment
|
import com.topjohnwu.magisk.arch.BaseUIFragment
|
||||||
import com.topjohnwu.magisk.arch.ReselectionTarget
|
import com.topjohnwu.magisk.arch.ReselectionTarget
|
||||||
import com.topjohnwu.magisk.arch.ViewEvent
|
import com.topjohnwu.magisk.arch.ViewEvent
|
||||||
|
import com.topjohnwu.magisk.core.download.BaseDownloadService
|
||||||
import com.topjohnwu.magisk.databinding.FragmentModuleMd2Binding
|
import com.topjohnwu.magisk.databinding.FragmentModuleMd2Binding
|
||||||
import com.topjohnwu.magisk.events.InstallExternalModuleEvent
|
import com.topjohnwu.magisk.events.InstallExternalModuleEvent
|
||||||
import com.topjohnwu.magisk.ktx.hideKeyboard
|
import com.topjohnwu.magisk.ktx.hideKeyboard
|
||||||
@ -50,6 +51,7 @@ class ModuleFragment : BaseUIFragment<ModuleViewModel, FragmentModuleMd2Binding>
|
|||||||
super.onStart()
|
super.onStart()
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
activity.title = resources.getString(R.string.modules)
|
activity.title = resources.getString(R.string.modules)
|
||||||
|
BaseDownloadService.observeProgress(this, viewModel::onProgressUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
@ -2,14 +2,12 @@ package com.topjohnwu.magisk.ui.module
|
|||||||
|
|
||||||
import androidx.databinding.Bindable
|
import androidx.databinding.Bindable
|
||||||
import androidx.databinding.ObservableArrayList
|
import androidx.databinding.ObservableArrayList
|
||||||
import androidx.lifecycle.Observer
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.topjohnwu.magisk.BR
|
import com.topjohnwu.magisk.BR
|
||||||
import com.topjohnwu.magisk.R
|
import com.topjohnwu.magisk.R
|
||||||
import com.topjohnwu.magisk.arch.*
|
import com.topjohnwu.magisk.arch.*
|
||||||
import com.topjohnwu.magisk.core.Config
|
import com.topjohnwu.magisk.core.Config
|
||||||
import com.topjohnwu.magisk.core.download.DownloadSubject
|
import com.topjohnwu.magisk.core.download.DownloadSubject
|
||||||
import com.topjohnwu.magisk.core.download.RemoteFileService
|
|
||||||
import com.topjohnwu.magisk.core.model.module.Module
|
import com.topjohnwu.magisk.core.model.module.Module
|
||||||
import com.topjohnwu.magisk.core.tasks.RepoUpdater
|
import com.topjohnwu.magisk.core.tasks.RepoUpdater
|
||||||
import com.topjohnwu.magisk.data.database.RepoByNameDao
|
import com.topjohnwu.magisk.data.database.RepoByNameDao
|
||||||
@ -47,7 +45,7 @@ class ModuleViewModel(
|
|||||||
private val repoName: RepoByNameDao,
|
private val repoName: RepoByNameDao,
|
||||||
private val repoUpdated: RepoByUpdatedDao,
|
private val repoUpdated: RepoByUpdatedDao,
|
||||||
private val repoUpdater: RepoUpdater
|
private val repoUpdater: RepoUpdater
|
||||||
) : BaseViewModel(), Queryable, Observer<Pair<Float, DownloadSubject>> {
|
) : BaseViewModel(), Queryable {
|
||||||
|
|
||||||
override val queryDelay = 1000L
|
override val queryDelay = 1000L
|
||||||
private var queryJob: Job? = null
|
private var queryJob: Job? = null
|
||||||
@ -125,9 +123,6 @@ class ModuleViewModel(
|
|||||||
// ---
|
// ---
|
||||||
|
|
||||||
init {
|
init {
|
||||||
RemoteFileService.reset()
|
|
||||||
RemoteFileService.progressBroadcast.observeForever(this)
|
|
||||||
|
|
||||||
itemsInstalled.addOnListChangedCallback(
|
itemsInstalled.addOnListChangedCallback(
|
||||||
onItemRangeInserted = { _, _, _ ->
|
onItemRangeInserted = { _, _, _ ->
|
||||||
if (installSectionList.isEmpty())
|
if (installSectionList.isEmpty())
|
||||||
@ -152,13 +147,7 @@ class ModuleViewModel(
|
|||||||
|
|
||||||
// ---
|
// ---
|
||||||
|
|
||||||
override fun onCleared() {
|
fun onProgressUpdate(progress: Float, subject: DownloadSubject) {
|
||||||
super.onCleared()
|
|
||||||
RemoteFileService.progressBroadcast.removeObserver(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onChanged(it: Pair<Float, DownloadSubject>?) {
|
|
||||||
val (progress, subject) = it ?: return
|
|
||||||
if (subject !is DownloadSubject.Module)
|
if (subject !is DownloadSubject.Module)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user