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.HIDE_OVERLAY_WINDOWS" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />

View File

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

View File

@ -1,5 +1,8 @@
package com.topjohnwu.magisk.core 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.JobInfo
import android.app.job.JobParameters import android.app.job.JobParameters
import android.app.job.JobScheduler import android.app.job.JobScheduler
@ -8,38 +11,79 @@ import androidx.core.content.getSystemService
import com.topjohnwu.magisk.BuildConfig import com.topjohnwu.magisk.BuildConfig
import com.topjohnwu.magisk.core.base.BaseJobService import com.topjohnwu.magisk.core.base.BaseJobService
import com.topjohnwu.magisk.core.di.ServiceLocator 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.Notifications
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class JobService : BaseJobService() { class JobService : BaseJobService() {
private val job = Job() private var mSession: Session? = null
private val svc get() = ServiceLocator.networkService private var mDm: DownloadManager? = null
override fun onStartJob(params: JobParameters): Boolean { @TargetApi(value = 34)
val coroutineScope = CoroutineScope(Dispatchers.IO + job) inner class Session(
coroutineScope.launch { var params: JobParameters
doWork() ) : 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) jobFinished(params, false)
} }
return false
} }
private suspend fun doWork() { @SuppressLint("NewApi")
svc.fetchUpdate()?.let { override fun onStartJob(params: JobParameters): Boolean {
Info.remote = it return when (params.jobId) {
if (Info.env.isActive && BuildConfig.VERSION_CODE < it.magisk.versionCode) Const.ID.CHECK_UPDATE_JOB_ID -> checkUpdate(params)
Notifications.updateAvailable() Const.ID.DOWNLOAD_JOB_ID -> downloadFile(params)
else -> false
} }
} }
override fun onStopJob(params: JobParameters): Boolean { override fun onStopJob(params: JobParameters?) = false
job.cancel()
return 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 { companion object {
@ -47,14 +91,14 @@ class JobService : BaseJobService() {
val scheduler = context.getSystemService<JobScheduler>() ?: return val scheduler = context.getSystemService<JobScheduler>() ?: return
if (Config.checkUpdate) { if (Config.checkUpdate) {
val cmp = JobService::class.java.cmp(context.packageName) 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)) .setPeriodic(TimeUnit.HOURS.toMillis(12))
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.setRequiresDeviceIdle(true) .setRequiresDeviceIdle(true)
.build() .build()
scheduler.schedule(info) scheduler.schedule(info)
} else { } 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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.IntentCompat
import com.topjohnwu.magisk.core.base.BaseReceiver import com.topjohnwu.magisk.core.base.BaseReceiver
import com.topjohnwu.magisk.core.di.ServiceLocator 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.Notifications
import com.topjohnwu.magisk.view.Shortcuts import com.topjohnwu.magisk.view.Shortcuts
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
@ -35,6 +38,12 @@ open class Receiver : BaseReceiver() {
} }
when (intent.action ?: return) { when (intent.action ?: return) {
DownloadManager.ACTION -> {
IntentCompat.getParcelableExtra(
intent, DownloadManager.SUBJECT_KEY, Subject::class.java)?.let {
DownloadManager.start(context, it)
}
}
Intent.ACTION_PACKAGE_REPLACED -> { Intent.ACTION_PACKAGE_REPLACED -> {
// This will only work pre-O // This will only work pre-O
if (Config.suReAuth) if (Config.suReAuth)

View File

@ -4,19 +4,26 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Notification import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import androidx.collection.SparseArrayCompat import androidx.collection.SparseArrayCompat
import androidx.collection.isNotEmpty import androidx.collection.isNotEmpty
import androidx.core.content.getSystemService
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.topjohnwu.magisk.R import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.StubApk import com.topjohnwu.magisk.StubApk
import com.topjohnwu.magisk.core.ActivityTracker import com.topjohnwu.magisk.core.ActivityTracker
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.JobService
import com.topjohnwu.magisk.core.base.BaseActivity 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.di.ServiceLocator
import com.topjohnwu.magisk.core.intent import com.topjohnwu.magisk.core.intent
import com.topjohnwu.magisk.core.isRunningAsStub import com.topjohnwu.magisk.core.isRunningAsStub
@ -55,7 +62,7 @@ class DownloadManager(
interface Session { interface Session {
val context: Context val context: Context
fun attach(id: Int, notification: Notification.Builder) fun attach(id: Int, builder: Notification.Builder)
fun stop() fun stop()
} }
@ -79,9 +86,16 @@ class DownloadManager(
} }
private fun createIntent(context: Context, subject: Subject) = private fun createIntent(context: Context, subject: Subject) =
context.intent<com.topjohnwu.magisk.core.Service>() if (Build.VERSION.SDK_INT >= 34) {
.setAction(ACTION) context.intent<com.topjohnwu.magisk.core.Receiver>()
.putExtra(SUBJECT_KEY, subject) .setAction(ACTION)
.putExtra(SUBJECT_KEY, subject)
} else {
context.intent<com.topjohnwu.magisk.core.Service>()
.setAction(ACTION)
.putExtra(SUBJECT_KEY, subject)
}
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
fun getPendingIntent(context: Context, subject: Subject): PendingIntent { fun getPendingIntent(context: Context, subject: Subject): PendingIntent {
@ -89,7 +103,13 @@ class DownloadManager(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_ONE_SHOT PendingIntent.FLAG_ONE_SHOT
val intent = createIntent(context, subject) 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) PendingIntent.getForegroundService(context, REQUEST_CODE, intent, flag)
} else { } else {
PendingIntent.getService(context, REQUEST_CODE, intent, flag) PendingIntent.getService(context, REQUEST_CODE, intent, flag)
@ -97,15 +117,29 @@ class DownloadManager(
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
fun start(activity: BaseActivity, subject: Subject) { fun startWithActivity(activity: BaseActivity, subject: Subject) {
activity.withPermission(Manifest.permission.POST_NOTIFICATIONS) { activity.withPermission(Manifest.permission.POST_NOTIFICATIONS) {
// Always download regardless of notification permission status // Always download regardless of notification permission status
val app = activity.applicationContext start(activity.applicationContext, subject)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { }
app.startForegroundService(createIntent(app, subject)) }
} else {
app.startService(createIntent(app, 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 val notifications = SparseArrayCompat<Notification.Builder>()
private var attachedId = -1 private var attachedId = -1
@ -205,15 +245,12 @@ class DownloadManager(
// There are still remaining notifications, pick one and attach to the session // There are still remaining notifications, pick one and attach to the session
val anotherId = notifications.keyAt(0) val anotherId = notifications.keyAt(0)
val notification = notifications.valueAt(0) val notification = notifications.valueAt(0)
// Attaching a new notification will automatically remove the current one
attachNotification(anotherId, notification) attachNotification(anotherId, notification)
} else { } else {
// No more notifications left, terminate the session // No more notifications left, terminate the session
attachedId = -1 attachedId = -1
session.stop() session.stop()
} }
return n
} }
} }

View File

@ -81,7 +81,7 @@ sealed class Subject : Parcelable {
override val notifyId: Int = Notifications.nextId(), override val notifyId: Int = Notifications.nextId(),
override val title: String = UUID.randomUUID().toString().substring(0, 6) override val title: String = UUID.randomUUID().toString().substring(0, 6)
) : Subject() { ) : 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 file get() = File("/dev/null").toUri()
override val autoLaunch get() = false override val autoLaunch get() = false
} }

View File

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

View File

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

View File

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