ec8fffe61c
Distribute Magisk directly with Magisk Manager APK. The APK will contain all required binaries and scripts for installation and uninstallation. App versions will now align with Magisk releases. Extra effort is spent to make the APK itself also a flashable zip that can be used in custom recoveries, so those still prefer to install Magisk with recoveries will not be affected with this change. As a bonus, this makes the whole installation and uninstallation process 100% offline. The existing Magisk Manager was not really functional without an Internet connection, as the installation process was highly tied to zips hosted on the server. An additional bonus: since all binaries are now shipped as "native libraries" of the APK, we can finally bump the target SDK version higher than 28. The target SDK version was stuck at 28 for a long time because newer SELinux restricts running executables from internal storage. More details can be found here: https://github.com/termux/termux-app/issues/1072 The target SDK bump will be addressed in a future commit. Co-authored with @vvb2060
201 lines
6.6 KiB
Kotlin
201 lines
6.6 KiB
Kotlin
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.MediaStoreUtils.outputStream
|
|
import com.topjohnwu.magisk.core.utils.ProgressInputStream
|
|
import com.topjohnwu.magisk.data.repository.NetworkService
|
|
import com.topjohnwu.magisk.ktx.withStreams
|
|
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 BaseDownloader : 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: NetworkService by inject()
|
|
|
|
// -- Service overrides
|
|
|
|
override fun onBind(intent: Intent?): IBinder? = null
|
|
|
|
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
|
intent.getParcelableExtra<Subject>(ACTION_KEY)?.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 Subject.startDownload() {
|
|
val stream = service.fetchFile(url).toProgressStream(this)
|
|
when (this) {
|
|
is Subject.Module -> // Download and process on-the-fly
|
|
stream.toModule(file, service.fetchInstaller().byteStream())
|
|
else -> {
|
|
withStreams(stream, file.outputStream()) { it, out -> it.copyTo(out) }
|
|
if (this is Subject.Manager)
|
|
handleAPK(this)
|
|
}
|
|
}
|
|
val newId = notifyFinish(this)
|
|
if (ForegroundTracker.hasForeground)
|
|
onFinish(this, newId)
|
|
if (!hasNotifications)
|
|
stopSelf()
|
|
}
|
|
|
|
private fun ResponseBody.toProgressStream(subject: Subject): 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 Subject.notifyID() = hashCode()
|
|
|
|
private fun notifyFail(subject: Subject) = lastNotify(subject.notifyID()) {
|
|
broadcast(-2f, subject)
|
|
it.setContentText(getString(R.string.download_file_error))
|
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
.setOngoing(false)
|
|
}
|
|
|
|
private fun notifyFinish(subject: Subject) = lastNotify(subject.notifyID()) {
|
|
broadcast(1f, subject)
|
|
it.setIntent(subject)
|
|
.setContentTitle(subject.title)
|
|
.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
|
|
}
|
|
|
|
protected fun remove(id: Int) = notifications.remove(id)
|
|
?.also { updateForeground(); cancel(id) }
|
|
?: { cancel(id); null }()
|
|
|
|
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 (id, notification) = notifications.entries.first()
|
|
startForeground(id, notification.build())
|
|
} else {
|
|
stopForeground(false)
|
|
}
|
|
}
|
|
|
|
// --- Implement custom logic
|
|
|
|
protected abstract suspend fun onFinish(subject: Subject, id: Int)
|
|
|
|
protected abstract fun Notification.Builder.setIntent(subject: Subject): Notification.Builder
|
|
|
|
// ---
|
|
|
|
companion object : KoinComponent {
|
|
const val ACTION_KEY = "download_action"
|
|
|
|
private val progressBroadcast = MutableLiveData<Pair<Float, Subject>>()
|
|
|
|
fun observeProgress(owner: LifecycleOwner, callback: (Float, Subject) -> Unit) {
|
|
progressBroadcast.value = null
|
|
progressBroadcast.observe(owner) {
|
|
val (progress, subject) = it ?: return@observe
|
|
callback(progress, subject)
|
|
}
|
|
}
|
|
|
|
private fun broadcast(progress: Float, subject: Subject) {
|
|
progressBroadcast.postValue(progress to subject)
|
|
}
|
|
}
|
|
}
|