Use user-initiated jobs for download tasks on API 34+

This commit is contained in:
topjohnwu 2024-02-06 17:04:39 -08:00
parent c5d34670c4
commit 38ad871e33
9 changed files with 131 additions and 39 deletions

View File

@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.HIDE_OVERLAY_WINDOWS" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />

View File

@ -35,7 +35,8 @@ object Const {
}
object ID {
const val JOB_SERVICE_ID = 7
const val DOWNLOAD_JOB_ID = 6
const val CHECK_UPDATE_JOB_ID = 7
}
object Url {

View File

@ -1,5 +1,8 @@
package com.topjohnwu.magisk.core
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.Notification
import android.app.job.JobInfo
import android.app.job.JobParameters
import android.app.job.JobScheduler
@ -8,38 +11,79 @@ import androidx.core.content.getSystemService
import com.topjohnwu.magisk.BuildConfig
import com.topjohnwu.magisk.core.base.BaseJobService
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.download.DownloadManager
import com.topjohnwu.magisk.core.download.Subject
import com.topjohnwu.magisk.view.Notifications
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
class JobService : BaseJobService() {
private val job = Job()
private val svc get() = ServiceLocator.networkService
private var mSession: Session? = null
private var mDm: DownloadManager? = null
override fun onStartJob(params: JobParameters): Boolean {
val coroutineScope = CoroutineScope(Dispatchers.IO + job)
coroutineScope.launch {
doWork()
@TargetApi(value = 34)
inner class Session(
var params: JobParameters
) : DownloadManager.Session {
override val context get() = this@JobService
override fun attach(id: Int, builder: Notification.Builder) {
setNotification(params, id, builder.build(), JOB_END_NOTIFICATION_POLICY_REMOVE)
}
override fun stop() {
jobFinished(params, false)
}
return false
}
private suspend fun doWork() {
svc.fetchUpdate()?.let {
Info.remote = it
if (Info.env.isActive && BuildConfig.VERSION_CODE < it.magisk.versionCode)
Notifications.updateAvailable()
@SuppressLint("NewApi")
override fun onStartJob(params: JobParameters): Boolean {
return when (params.jobId) {
Const.ID.CHECK_UPDATE_JOB_ID -> checkUpdate(params)
Const.ID.DOWNLOAD_JOB_ID -> downloadFile(params)
else -> false
}
}
override fun onStopJob(params: JobParameters): Boolean {
job.cancel()
return false
override fun onStopJob(params: JobParameters?) = false
@TargetApi(value = 34)
private fun downloadFile(params: JobParameters): Boolean {
params.transientExtras.classLoader = Subject::class.java.classLoader
val subject = params.transientExtras
.getParcelable(DownloadManager.SUBJECT_KEY, Subject::class.java) ?:
return false
val session = mSession?.also {
it.params = params
} ?: run {
Session(params).also { mSession = it }
}
val dm = mDm?.also {
it.reattach()
} ?: run {
DownloadManager(session).also { mDm = it }
}
dm.download(subject)
return true
}
private fun checkUpdate(params: JobParameters): Boolean {
GlobalScope.launch(Dispatchers.IO) {
ServiceLocator.networkService.fetchUpdate()?.let {
Info.remote = it
if (Info.env.isActive && BuildConfig.VERSION_CODE < it.magisk.versionCode)
Notifications.updateAvailable()
jobFinished(params, false)
}
}
return true
}
companion object {
@ -47,14 +91,14 @@ class JobService : BaseJobService() {
val scheduler = context.getSystemService<JobScheduler>() ?: return
if (Config.checkUpdate) {
val cmp = JobService::class.java.cmp(context.packageName)
val info = JobInfo.Builder(Const.ID.JOB_SERVICE_ID, cmp)
val info = JobInfo.Builder(Const.ID.CHECK_UPDATE_JOB_ID, cmp)
.setPeriodic(TimeUnit.HOURS.toMillis(12))
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.setRequiresDeviceIdle(true)
.build()
scheduler.schedule(info)
} else {
scheduler.cancel(Const.ID.JOB_SERVICE_ID)
scheduler.cancel(Const.ID.CHECK_UPDATE_JOB_ID)
}
}
}

View File

@ -3,8 +3,11 @@ package com.topjohnwu.magisk.core
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import androidx.core.content.IntentCompat
import com.topjohnwu.magisk.core.base.BaseReceiver
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.download.DownloadManager
import com.topjohnwu.magisk.core.download.Subject
import com.topjohnwu.magisk.view.Notifications
import com.topjohnwu.magisk.view.Shortcuts
import com.topjohnwu.superuser.Shell
@ -35,6 +38,12 @@ open class Receiver : BaseReceiver() {
}
when (intent.action ?: return) {
DownloadManager.ACTION -> {
IntentCompat.getParcelableExtra(
intent, DownloadManager.SUBJECT_KEY, Subject::class.java)?.let {
DownloadManager.start(context, it)
}
}
Intent.ACTION_PACKAGE_REPLACED -> {
// This will only work pre-O
if (Config.suReAuth)

View File

@ -4,19 +4,26 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.collection.SparseArrayCompat
import androidx.collection.isNotEmpty
import androidx.core.content.getSystemService
import androidx.core.net.toFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.StubApk
import com.topjohnwu.magisk.core.ActivityTracker
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.JobService
import com.topjohnwu.magisk.core.base.BaseActivity
import com.topjohnwu.magisk.core.cmp
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.intent
import com.topjohnwu.magisk.core.isRunningAsStub
@ -55,7 +62,7 @@ class DownloadManager(
interface Session {
val context: Context
fun attach(id: Int, notification: Notification.Builder)
fun attach(id: Int, builder: Notification.Builder)
fun stop()
}
@ -79,9 +86,16 @@ class DownloadManager(
}
private fun createIntent(context: Context, subject: Subject) =
context.intent<com.topjohnwu.magisk.core.Service>()
.setAction(ACTION)
.putExtra(SUBJECT_KEY, subject)
if (Build.VERSION.SDK_INT >= 34) {
context.intent<com.topjohnwu.magisk.core.Receiver>()
.setAction(ACTION)
.putExtra(SUBJECT_KEY, subject)
} else {
context.intent<com.topjohnwu.magisk.core.Service>()
.setAction(ACTION)
.putExtra(SUBJECT_KEY, subject)
}
@SuppressLint("InlinedApi")
fun getPendingIntent(context: Context, subject: Subject): PendingIntent {
@ -89,7 +103,13 @@ class DownloadManager(
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_ONE_SHOT
val intent = createIntent(context, subject)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return if (Build.VERSION.SDK_INT >= 34) {
// On API 34+, download tasks are handled with a user-initiated job.
// However, there is no way to schedule a new job directly with a pending intent.
// As a workaround, we send the subject to a broadcast receiver and have it
// schedule the job for us.
PendingIntent.getBroadcast(context, REQUEST_CODE, intent, flag)
} else if (Build.VERSION.SDK_INT >= 26) {
PendingIntent.getForegroundService(context, REQUEST_CODE, intent, flag)
} else {
PendingIntent.getService(context, REQUEST_CODE, intent, flag)
@ -97,15 +117,29 @@ class DownloadManager(
}
@SuppressLint("InlinedApi")
fun start(activity: BaseActivity, subject: Subject) {
fun startWithActivity(activity: BaseActivity, subject: Subject) {
activity.withPermission(Manifest.permission.POST_NOTIFICATIONS) {
// Always download regardless of notification permission status
val app = activity.applicationContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
app.startForegroundService(createIntent(app, subject))
} else {
app.startService(createIntent(app, subject))
}
start(activity.applicationContext, subject)
}
}
fun start(context: Context, subject: Subject) {
if (Build.VERSION.SDK_INT >= 34) {
val scheduler = context.getSystemService<JobScheduler>()!!
val cmp = JobService::class.java.cmp(context.packageName)
val extras = Bundle()
extras.putParcelable(SUBJECT_KEY, subject)
val info = JobInfo.Builder(Const.ID.DOWNLOAD_JOB_ID, cmp)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.setUserInitiated(true)
.setTransientExtras(extras)
.build()
scheduler.schedule(info)
} else if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(createIntent(context, subject))
} else {
context.startService(createIntent(context, subject))
}
}
}
@ -140,6 +174,12 @@ class DownloadManager(
}
}
@Synchronized
fun reattach() {
val builder = notifications[attachedId] ?: return
session.attach(attachedId, builder)
}
private val notifications = SparseArrayCompat<Notification.Builder>()
private var attachedId = -1
@ -205,15 +245,12 @@ class DownloadManager(
// There are still remaining notifications, pick one and attach to the session
val anotherId = notifications.keyAt(0)
val notification = notifications.valueAt(0)
// Attaching a new notification will automatically remove the current one
attachNotification(anotherId, notification)
} else {
// No more notifications left, terminate the session
attachedId = -1
session.stop()
}
return n
}
}

View File

@ -81,7 +81,7 @@ sealed class Subject : Parcelable {
override val notifyId: Int = Notifications.nextId(),
override val title: String = UUID.randomUUID().toString().substring(0, 6)
) : Subject() {
override val url get() = "http://link.testfile.org/150MB"
override val url get() = "https://link.testfile.org/250MB"
override val file get() = File("/dev/null").toUri()
override val autoLaunch get() = false
}

View File

@ -29,7 +29,7 @@ class ManagerInstallDialog : MarkDownDialog() {
setCancelable(true)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = R.string.install
onClick { DownloadManager.start(activity, Subject.App()) }
onClick { DownloadManager.startWithActivity(activity, Subject.App()) }
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel

View File

@ -24,7 +24,7 @@ class OnlineModuleInstallDialog(private val item: OnlineModule) : MarkDownDialog
fun download(install: Boolean) {
val action = if (install) Action.Flash else Action.Download
val subject = Subject.Module(item, action)
DownloadManager.start(activity, subject)
DownloadManager.startWithActivity(activity, subject)
}
val title = context.getString(R.string.repo_install_title,

View File

@ -26,6 +26,6 @@ android.injected.testOnly=false
android.nonFinalResIds=false
# Magisk
magisk.stubVersion=38
magisk.stubVersion=39
magisk.versionCode=27001
magisk.ondkVersion=r26.3