diff --git a/app/src/main/java/com/topjohnwu/magisk/core/download/BaseDownloader.kt b/app/src/main/java/com/topjohnwu/magisk/core/download/BaseDownloader.kt deleted file mode 100644 index 591ec3ade..000000000 --- a/app/src/main/java/com/topjohnwu/magisk/core/download/BaseDownloader.kt +++ /dev/null @@ -1,192 +0,0 @@ -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.di.ServiceLocator -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 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() { - - private val hasNotifications get() = notifications.isNotEmpty() - private val notifications = Collections.synchronizedMap(HashMap()) - private val coroutineScope = CoroutineScope(Dispatchers.IO) - - val service get() = ServiceLocator.networkService - - // -- Service overrides - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - intent.getParcelableExtra(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()) - is Subject.Manager -> handleAPK(this, stream) - } - 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 { - const val ACTION_KEY = "download_action" - - private val progressBroadcast = MutableLiveData?>() - - 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) - } - } -} diff --git a/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadService.kt b/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadService.kt index 8f7783219..7922896d6 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadService.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadService.kt @@ -1,27 +1,198 @@ package com.topjohnwu.magisk.core.download +import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import android.os.IBinder import androidx.core.net.toFile +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import com.topjohnwu.magisk.R import com.topjohnwu.magisk.arch.BaseUIActivity import com.topjohnwu.magisk.core.ForegroundTracker +import com.topjohnwu.magisk.core.base.BaseService import com.topjohnwu.magisk.core.download.Action.Flash import com.topjohnwu.magisk.core.download.Subject.Manager import com.topjohnwu.magisk.core.download.Subject.Module import com.topjohnwu.magisk.core.intent +import com.topjohnwu.magisk.core.utils.ProgressInputStream +import com.topjohnwu.magisk.di.ServiceLocator import com.topjohnwu.magisk.ui.flash.FlashFragment import com.topjohnwu.magisk.utils.APKInstall +import com.topjohnwu.magisk.view.Notifications import com.topjohnwu.superuser.internal.UiThreadHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import okhttp3.ResponseBody +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 -open class DownloadService : BaseDownloader() { +class DownloadService : BaseService() { private val context get() = this + private val hasNotifications get() = notifications.isNotEmpty() + private val notifications = Collections.synchronizedMap(HashMap()) + private val coroutineScope = CoroutineScope(Dispatchers.IO) - override suspend fun onFinish(subject: Subject, id: Int) = when (subject) { + val service get() = ServiceLocator.networkService + private val mgr get() = Notifications.mgr + + // -- Service overrides + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + intent.getParcelableExtra(SUBJECT_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 { mgr.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 Module -> // Download and process on-the-fly + stream.toModule(file, service.fetchInstaller().byteStream()) + is Manager -> handleAPK(this, stream) + } + 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)) + } + } + } + } + + // --- Notifications + + private fun notifyFail(subject: Subject) = finalNotify(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) = finalNotify(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 finalNotify( + id: Int, + editor: (Notification.Builder) -> Notification.Builder + ) : Int { + val notification = remove(id)?.run(editor) ?: return -1 + val newId = nextInt() + mgr.notify(newId, notification.build()) + return newId + } + + 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 + mgr.notify(id, notification.build()) + } + + private fun remove(id: Int): Notification.Builder? { + val n = notifications.remove(id)?.also { updateForeground() } + mgr.cancel(id) + return n + } + + private fun updateForeground() { + if (hasNotifications) { + val (id, notification) = notifications.entries.first() + startForeground(id, notification.build()) + } else { + stopForeground(false) + } + } + + private fun Notification.Builder.setIntent(subject: Subject) = when (subject) { + is Module -> setIntent(subject) + is Manager -> setIntent(subject) + } + + private fun Notification.Builder.setIntent(subject: Module) + = when (subject.action) { + Flash -> setContentIntent(FlashFragment.installIntent(context, subject.file)) + else -> setContentIntent(Intent()) + } + + private fun Notification.Builder.setIntent(subject: Manager) = + setContentIntent(APKInstall.installIntent(context, subject.file.toFile())) + + @SuppressLint("InlinedApi") + private fun Notification.Builder.setContentIntent(intent: Intent) = + setContentIntent(PendingIntent.getActivity(context, nextInt(), intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT)) + + // -- Post download processing + + private fun onFinish(subject: Subject, id: Int) = when (subject) { is Module -> subject.onFinish(id) is Manager -> subject.onFinish(id) } @@ -41,34 +212,27 @@ open class DownloadService : BaseDownloader() { APKInstall.install(context, file.toFile()) } - // --- Customize finish notification - - override fun Notification.Builder.setIntent(subject: Subject) - = when (subject) { - is Module -> setIntent(subject) - is Manager -> setIntent(subject) - } - - private fun Notification.Builder.setIntent(subject: Module) - = when (subject.action) { - Flash -> setContentIntent(FlashFragment.installIntent(context, subject.file)) - else -> setContentIntent(Intent()) - } - - private fun Notification.Builder.setIntent(subject: Manager) - = setContentIntent(APKInstall.installIntent(context, subject.file.toFile())) - - private fun Notification.Builder.setContentIntent(intent: Intent) = - setContentIntent(PendingIntent.getActivity(context, nextInt(), intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT)) - - // --- - companion object { + private const val SUBJECT_KEY = "download_subject" + + private val progressBroadcast = MutableLiveData?>() + + 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) + } private fun intent(context: Context, subject: Subject) = - context.intent().putExtra(ACTION_KEY, subject) + context.intent().putExtra(SUBJECT_KEY, subject) + @SuppressLint("InlinedApi") fun pendingIntent(context: Context, subject: Subject): PendingIntent { return if (Build.VERSION.SDK_INT >= 26) { PendingIntent.getForegroundService(context, nextInt(), intent(context, subject), diff --git a/app/src/main/java/com/topjohnwu/magisk/core/download/ManagerHandler.kt b/app/src/main/java/com/topjohnwu/magisk/core/download/ManagerHandler.kt index 4c72f60a2..101d4cea0 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/download/ManagerHandler.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/download/ManagerHandler.kt @@ -22,7 +22,7 @@ private fun Context.patch(apk: File) { patched.renameTo(apk) } -private fun BaseDownloader.notifyHide(id: Int) { +private fun DownloadService.notifyHide(id: Int) { update(id) { it.setProgress(0, 0, true) .setContentTitle(getString(R.string.hide_app_title)) @@ -48,7 +48,7 @@ private class DupOutputStream( } } -suspend fun BaseDownloader.handleAPK(subject: Subject.Manager, stream: InputStream) { +suspend fun DownloadService.handleAPK(subject: Subject.Manager, stream: InputStream) { fun write(output: OutputStream) { val ext = subject.externalFile.outputStream() val o = DupOutputStream(ext, output) diff --git a/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt b/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt index a801c2003..47bb50062 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt @@ -52,6 +52,8 @@ sealed class Subject : Parcelable { val externalFile get() = MediaStoreUtils.getFile("$title.apk").uri } + + fun notifyID() = hashCode() } sealed class Action : Parcelable { diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/home/HomeFragment.kt b/app/src/main/java/com/topjohnwu/magisk/ui/home/HomeFragment.kt index c68d05bbf..21c460f97 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/home/HomeFragment.kt @@ -6,7 +6,7 @@ import android.widget.ImageView import android.widget.TextView import com.topjohnwu.magisk.R import com.topjohnwu.magisk.arch.BaseUIFragment -import com.topjohnwu.magisk.core.download.BaseDownloader +import com.topjohnwu.magisk.core.download.DownloadService import com.topjohnwu.magisk.databinding.FragmentHomeMd2Binding import com.topjohnwu.magisk.di.viewModel import com.topjohnwu.magisk.events.RebootEvent @@ -21,7 +21,7 @@ class HomeFragment : BaseUIFragment() { super.onStart() activity.title = resources.getString(R.string.section_home) setHasOptionsMenu(true) - BaseDownloader.observeProgress(this, viewModel::onProgressUpdate) + DownloadService.observeProgress(this, viewModel::onProgressUpdate) } private fun checkTitle(text: TextView, icon: ImageView) {