diff --git a/apex/jobscheduler/framework/java/android/app/job/IJobCallback.aidl b/apex/jobscheduler/framework/java/android/app/job/IJobCallback.aidl index a3390b75b8bf6..96494ec28204b 100644 --- a/apex/jobscheduler/framework/java/android/app/job/IJobCallback.aidl +++ b/apex/jobscheduler/framework/java/android/app/job/IJobCallback.aidl @@ -16,6 +16,7 @@ package android.app.job; +import android.app.Notification; import android.app.job.JobWorkItem; /** @@ -104,4 +105,17 @@ interface IJobCallback { */ void updateTransferredNetworkBytes(int jobId, in JobWorkItem item, long transferredDownloadBytes, long transferredUploadBytes); + /** + * Provide JobScheduler with a notification to post and tie to this job's + * lifecycle. + * This is required for all user-initiated job and optional for other jobs. + * + * @param jobId Unique integer used to identify this job. + * @param notificationId The ID for this notification, as per + * {@link android.app.NotificationManager#notify(int, Notification)}. + * @param notification The notification to be displayed. + * @param jobEndNotificationPolicy The policy to apply to the notification when the job stops. + */ + void setNotification(int jobId, int notificationId, + in Notification notification, int jobEndNotificationPolicy); } diff --git a/apex/jobscheduler/framework/java/android/app/job/JobService.java b/apex/jobscheduler/framework/java/android/app/job/JobService.java index bad641cf8bf63..9297fee6b0d83 100644 --- a/apex/jobscheduler/framework/java/android/app/job/JobService.java +++ b/apex/jobscheduler/framework/java/android/app/job/JobService.java @@ -19,13 +19,18 @@ package android.app.job; import static android.app.job.JobScheduler.THROW_ON_INVALID_DATA_TRANSFER_IMPLEMENTATION; import android.annotation.BytesLong; +import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; +import android.app.Notification; import android.app.Service; import android.compat.Compatibility; import android.content.Intent; import android.os.IBinder; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + /** *

Entry point for the callback from the {@link android.app.job.JobScheduler}.

*

This is the base class that handles asynchronous requests that were previously scheduled. You @@ -63,6 +68,32 @@ public abstract class JobService extends Service { public static final String PERMISSION_BIND = "android.permission.BIND_JOB_SERVICE"; + /** + * Detach the notification supplied to + * {@link #setNotification(JobParameters, int, Notification, int)} when the job ends. + * The notification will remain shown even after JobScheduler stops the job. + * + * @hide + */ + public static final int JOB_END_NOTIFICATION_POLICY_DETACH = 0; + /** + * Cancel and remove the notification supplied to + * {@link #setNotification(JobParameters, int, Notification, int)} when the job ends. + * The notification will be removed from the notification shade. + * + * @hide + */ + public static final int JOB_END_NOTIFICATION_POLICY_REMOVE = 1; + + /** @hide */ + @IntDef(prefix = {"JOB_END_NOTIFICATION_POLICY_"}, value = { + JOB_END_NOTIFICATION_POLICY_DETACH, + JOB_END_NOTIFICATION_POLICY_REMOVE, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface JobEndNotificationPolicy { + } + private JobServiceEngine mEngine; /** @hide */ @@ -364,4 +395,37 @@ public abstract class JobService extends Service { } return 0; } + + /** + * Provide JobScheduler with a notification to post and tie to this job's lifecycle. + * This is required for all user-initiated jobs + * (scheduled via {link JobInfo.Builder#setUserInitiated(boolean)}) and optional for + * other jobs. If the app does not call this method for a required notification within + * 10 seconds after {@link #onStartJob(JobParameters)} is called, + * the system will trigger an ANR and stop this job. + * + *

+ * Note that certain types of jobs + * (e.g. {@link JobInfo.Builder#setDataTransfer data transfer jobs}) may require the + * notification to have certain characteristics and their documentation will state + * any such requirements. + * + *

+ * JobScheduler will not remember this notification after the job has finished running, + * so apps must call this every time the job is started (if required or desired). + * + * @param params The parameters identifying this job, as supplied to + * the job in the {@link #onStartJob(JobParameters)} callback. + * @param notificationId The ID for this notification, as per + * {@link android.app.NotificationManager#notify(int, + * Notification)}. + * @param notification The notification to be displayed. + * @param jobEndNotificationPolicy The policy to apply to the notification when the job stops. + * @hide + */ + public final void setNotification(@NonNull JobParameters params, int notificationId, + @NonNull Notification notification, + @JobEndNotificationPolicy int jobEndNotificationPolicy) { + mEngine.setNotification(params, notificationId, notification, jobEndNotificationPolicy); + } } diff --git a/apex/jobscheduler/framework/java/android/app/job/JobServiceEngine.java b/apex/jobscheduler/framework/java/android/app/job/JobServiceEngine.java index 83296a63205ae..53e452f68b115 100644 --- a/apex/jobscheduler/framework/java/android/app/job/JobServiceEngine.java +++ b/apex/jobscheduler/framework/java/android/app/job/JobServiceEngine.java @@ -21,6 +21,7 @@ import static android.app.job.JobScheduler.THROW_ON_INVALID_DATA_TRANSFER_IMPLEM import android.annotation.BytesLong; import android.annotation.NonNull; import android.annotation.Nullable; +import android.app.Notification; import android.app.Service; import android.compat.Compatibility; import android.content.Intent; @@ -73,6 +74,8 @@ public abstract class JobServiceEngine { private static final int MSG_UPDATE_TRANSFERRED_NETWORK_BYTES = 5; /** Message that the client wants to update JobScheduler of the estimated transfer size. */ private static final int MSG_UPDATE_ESTIMATED_NETWORK_BYTES = 6; + /** Message that the client wants to give JobScheduler a notification to tie to the job. */ + private static final int MSG_SET_NOTIFICATION = 7; private final IJobService mBinder; @@ -250,6 +253,24 @@ public abstract class JobServiceEngine { args.recycle(); break; } + case MSG_SET_NOTIFICATION: { + final SomeArgs args = (SomeArgs) msg.obj; + final JobParameters params = (JobParameters) args.arg1; + final Notification notification = (Notification) args.arg2; + IJobCallback callback = params.getCallback(); + if (callback != null) { + try { + callback.setNotification(params.getJobId(), + args.argi1, notification, args.argi2); + } catch (RemoteException e) { + Log.e(TAG, "Error providing notification: binder has gone away."); + } + } else { + Log.e(TAG, "setNotification() called for a nonexistent job."); + } + args.recycle(); + break; + } default: Log.e(TAG, "Unrecognised message received."); break; @@ -432,4 +453,27 @@ public abstract class JobServiceEngine { args.argl2 = uploadBytes; mHandler.obtainMessage(MSG_UPDATE_ESTIMATED_NETWORK_BYTES, args).sendToTarget(); } -} \ No newline at end of file + + /** + * Give JobScheduler a notification to tie to this job's lifecycle. + * + * @hide + * @see JobService#setNotification(JobParameters, int, Notification, int) + */ + public void setNotification(@NonNull JobParameters params, int notificationId, + @NonNull Notification notification, + @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy) { + if (params == null) { + throw new NullPointerException("params"); + } + if (notification == null) { + throw new NullPointerException("notification"); + } + SomeArgs args = SomeArgs.obtain(); + args.arg1 = params; + args.arg2 = notification; + args.argi1 = notificationId; + args.argi2 = jobEndNotificationPolicy; + mHandler.obtainMessage(MSG_SET_NOTIFICATION, args).sendToTarget(); + } +} diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java b/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java index 9aa6b1c298ef1..b20eedca776f9 100644 --- a/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java +++ b/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java @@ -17,6 +17,8 @@ package com.android.server.job; import static android.app.job.JobInfo.getPriorityString; +import static android.app.job.JobService.JOB_END_NOTIFICATION_POLICY_DETACH; +import static android.app.job.JobService.JOB_END_NOTIFICATION_POLICY_REMOVE; import static com.android.server.job.JobConcurrencyManager.WORK_TYPE_NONE; import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock; @@ -24,6 +26,7 @@ import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock; import android.annotation.BytesLong; import android.annotation.NonNull; import android.annotation.Nullable; +import android.app.Notification; import android.app.job.IJobCallback; import android.app.job.IJobService; import android.app.job.JobInfo; @@ -58,6 +61,7 @@ import com.android.internal.util.FrameworkStatsLog; import com.android.server.EventLogTags; import com.android.server.LocalServices; import com.android.server.job.controllers.JobStatus; +import com.android.server.notification.NotificationManagerInternal; import com.android.server.tare.EconomicPolicy; import com.android.server.tare.EconomyManagerInternal; import com.android.server.tare.JobSchedulerEconomicPolicy; @@ -117,6 +121,7 @@ public final class JobServiceContext implements ServiceConnection { private final EconomyManagerInternal mEconomyManagerInternal; private final JobPackageTracker mJobPackageTracker; private final PowerManager mPowerManager; + private final NotificationManagerInternal mNotificationManagerInternal; private PowerManager.WakeLock mWakeLock; // Execution state. @@ -169,6 +174,11 @@ public final class JobServiceContext implements ServiceConnection { /** The absolute maximum amount of time the job can run */ private long mMaxExecutionTimeMillis; + private int mNotificationId; + private Notification mNotification; + private int mNotificationPid; + private int mNotificationJobStopPolicy; + /** * The stop reason for a pending cancel. If there's not pending cancel, then the value should be * {@link JobParameters#STOP_REASON_UNDEFINED}. @@ -235,6 +245,12 @@ public final class JobServiceContext implements ServiceConnection { long downloadBytes, long uploadBytes) { doUpdateTransferredNetworkBytes(this, jobId, item, downloadBytes, uploadBytes); } + + @Override + public void setNotification(int jobId, int notificationId, + Notification notification, int jobEndNotificationPolicy) { + doSetNotification(this, jobId, notificationId, notification, jobEndNotificationPolicy); + } } JobServiceContext(JobSchedulerService service, JobConcurrencyManager concurrencyManager, @@ -244,6 +260,7 @@ public final class JobServiceContext implements ServiceConnection { mService = service; mBatteryStats = batteryStats; mEconomyManagerInternal = LocalServices.getService(EconomyManagerInternal.class); + mNotificationManagerInternal = LocalServices.getService(NotificationManagerInternal.class); mJobPackageTracker = tracker; mCallbackHandler = new JobServiceHandler(looper); mJobConcurrencyManager = concurrencyManager; @@ -593,6 +610,49 @@ public final class JobServiceContext implements ServiceConnection { } } + private void doSetNotification(JobCallback cb, int jodId, int notificationId, + Notification notification, int jobEndNotificationPolicy) { + final int callingPid = Binder.getCallingPid(); + final int callingUid = Binder.getCallingUid(); + final long ident = Binder.clearCallingIdentity(); + try { + synchronized (mLock) { + if (!verifyCallerLocked(cb)) { + return; + } + if (callingUid != mRunningJob.getUid()) { + Slog.wtfStack(TAG, "Calling UID isn't the same as running job's UID..."); + throw new SecurityException("Can't post notification on behalf of another app"); + } + if (notification == null) { + throw new NullPointerException("notification"); + } + if (notification.getSmallIcon() == null) { + throw new IllegalArgumentException("small icon required"); + } + final String callingPkgName = mRunningJob.getServiceComponent().getPackageName(); + if (null == mNotificationManagerInternal.getNotificationChannel( + callingPkgName, callingUid, notification.getChannelId())) { + throw new IllegalArgumentException("invalid notification channel"); + } + if (jobEndNotificationPolicy != JOB_END_NOTIFICATION_POLICY_DETACH + && jobEndNotificationPolicy != JOB_END_NOTIFICATION_POLICY_REMOVE) { + throw new IllegalArgumentException("invalid job end notification policy"); + } + // TODO(260848384): ensure apps can't cancel the notification for user-initiated job + mNotificationManagerInternal.enqueueNotification( + callingPkgName, callingPkgName, callingUid, callingPid, /* tag */ null, + notificationId, notification, UserHandle.getUserId(callingUid)); + mNotificationId = notificationId; + mNotification = notification; + mNotificationPid = callingPid; + mNotificationJobStopPolicy = jobEndNotificationPolicy; + } + } finally { + Binder.restoreCallingIdentity(ident); + } + } + private void doUpdateTransferredNetworkBytes(JobCallback jobCallback, int jobId, @Nullable JobWorkItem item, long downloadBytes, long uploadBytes) { // TODO(255393346): Make sure apps call this appropriately and monitor for abuse @@ -1116,6 +1176,13 @@ public final class JobServiceContext implements ServiceConnection { JobSchedulerEconomicPolicy.ACTION_JOB_TIMEOUT, String.valueOf(mRunningJob.getJobId())); } + if (mNotification != null + && mNotificationJobStopPolicy == JOB_END_NOTIFICATION_POLICY_REMOVE) { + final String callingPkgName = completedJob.getServiceComponent().getPackageName(); + mNotificationManagerInternal.cancelNotification( + callingPkgName, callingPkgName, completedJob.getUid(), mNotificationPid, + /* tag */ null, mNotificationId, UserHandle.getUserId(completedJob.getUid())); + } if (mWakeLock != null) { mWakeLock.release(); } @@ -1133,6 +1200,7 @@ public final class JobServiceContext implements ServiceConnection { mPendingStopReason = JobParameters.STOP_REASON_UNDEFINED; mPendingInternalStopReason = 0; mPendingDebugStopReason = null; + mNotification = null; removeOpTimeOutLocked(); mCompletedListener.onJobCompletedLocked(completedJob, internalStopReason, reschedule); mJobConcurrencyManager.onJobCompletedLocked(this, completedJob, workType); @@ -1157,6 +1225,7 @@ public final class JobServiceContext implements ServiceConnection { private void scheduleOpTimeOutLocked() { removeOpTimeOutLocked(); + // TODO(260848384): enforce setNotification timeout for user-initiated jobs final long timeoutMillis; switch (mVerb) { case VERB_EXECUTING: