diff --git a/apex/jobscheduler/framework/java/android/app/job/JobParameters.java b/apex/jobscheduler/framework/java/android/app/job/JobParameters.java index 242b52c5845bc..32502eddc9f82 100644 --- a/apex/jobscheduler/framework/java/android/app/job/JobParameters.java +++ b/apex/jobscheduler/framework/java/android/app/job/JobParameters.java @@ -19,6 +19,7 @@ package android.app.job; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; +import android.annotation.TestApi; import android.app.ActivityManager; import android.app.usage.UsageStatsManager; import android.compat.annotation.UnsupportedAppUsage; @@ -102,6 +103,7 @@ public class JobParameters implements Parcelable { * The user stopped the job via some UI (eg. Task Manager). * @hide */ + @TestApi public static final int INTERNAL_STOP_REASON_USER_UI_STOP = JobProtoEnums.INTERNAL_STOP_REASON_USER_UI_STOP; // 11. /** diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobNotificationCoordinator.java b/apex/jobscheduler/service/java/com/android/server/job/JobNotificationCoordinator.java index ce5ade5531ec9..5a1214296526d 100644 --- a/apex/jobscheduler/service/java/com/android/server/job/JobNotificationCoordinator.java +++ b/apex/jobscheduler/service/java/com/android/server/job/JobNotificationCoordinator.java @@ -21,6 +21,7 @@ import static android.app.job.JobService.JOB_END_NOTIFICATION_POLICY_REMOVE; import android.annotation.NonNull; import android.app.Notification; +import android.app.job.JobParameters; import android.app.job.JobService; import android.content.pm.UserPackage; import android.os.UserHandle; @@ -80,7 +81,7 @@ class JobNotificationCoordinator { final NotificationDetails oldDetails = mNotificationDetails.get(hostingContext); if (oldDetails != null && oldDetails.notificationId != notificationId) { // App is switching notification IDs. Remove association with the old one. - removeNotificationAssociation(hostingContext); + removeNotificationAssociation(hostingContext, JobParameters.STOP_REASON_UNDEFINED); } final int userId = UserHandle.getUserId(callingUid); // TODO(260848384): ensure apps can't cancel the notification for user-initiated job @@ -100,7 +101,8 @@ class JobNotificationCoordinator { mNotificationDetails.put(hostingContext, details); } - void removeNotificationAssociation(@NonNull JobServiceContext hostingContext) { + void removeNotificationAssociation(@NonNull JobServiceContext hostingContext, + @JobParameters.StopReason int stopReason) { final NotificationDetails details = mNotificationDetails.remove(hostingContext); if (details == null) { return; @@ -114,7 +116,10 @@ class JobNotificationCoordinator { ArraySet associatedContexts = associations.get(details.notificationId); if (associatedContexts == null || associatedContexts.isEmpty()) { // No more jobs using this notification. Apply the final job stop policy. - if (details.jobEndNotificationPolicy == JOB_END_NOTIFICATION_POLICY_REMOVE) { + // If the user attempted to stop the job/app, then always remove the notification + // so the user doesn't get confused about the app state. + if (details.jobEndNotificationPolicy == JOB_END_NOTIFICATION_POLICY_REMOVE + || stopReason == JobParameters.STOP_REASON_USER) { final String packageName = details.userPackage.packageName; mNotificationManagerInternal.cancelNotification( packageName, packageName, details.appUid, details.appPid, /* tag */ null, diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerShellCommand.java b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerShellCommand.java index 1d0fdd908f2ce..4357d4f39dce3 100644 --- a/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerShellCommand.java +++ b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerShellCommand.java @@ -670,6 +670,7 @@ public final class JobSchedulerShellCommand extends BasicShellCommandHandler { + " [PACKAGE] [JOB_ID]"); pw.println(" Trigger immediate timeout of currently executing jobs, as if their"); pw.println(" execution timeout had expired."); + pw.println(" This is the equivalent of calling `stop -s 3 -i 3`."); pw.println(" Options:"); pw.println(" -u or --user: specify which user's job is to be run; the default is"); pw.println(" all users"); 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 ceb47eaa69068..dcfadca0c7b36 100644 --- a/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java +++ b/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java @@ -1373,7 +1373,7 @@ public final class JobServiceContext implements ServiceConnection { JobSchedulerEconomicPolicy.ACTION_JOB_TIMEOUT, String.valueOf(mRunningJob.getJobId())); } - mNotificationCoordinator.removeNotificationAssociation(this); + mNotificationCoordinator.removeNotificationAssociation(this, reschedulingStopReason); if (mWakeLock != null) { mWakeLock.release(); } diff --git a/core/api/test-current.txt b/core/api/test-current.txt index a53fa349291cd..640506dace918 100644 --- a/core/api/test-current.txt +++ b/core/api/test-current.txt @@ -725,6 +725,14 @@ package android.app.contentsuggestions { } +package android.app.job { + + public class JobParameters implements android.os.Parcelable { + field public static final int INTERNAL_STOP_REASON_USER_UI_STOP = 11; // 0xb + } + +} + package android.app.prediction { public final class AppPredictor { diff --git a/services/tests/mockingservicestests/src/com/android/server/job/JobNotificationCoordinatorTest.java b/services/tests/mockingservicestests/src/com/android/server/job/JobNotificationCoordinatorTest.java index b4104dbc89f9d..03f667f0d336e 100644 --- a/services/tests/mockingservicestests/src/com/android/server/job/JobNotificationCoordinatorTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/job/JobNotificationCoordinatorTest.java @@ -33,6 +33,7 @@ import static org.mockito.Mockito.verify; import android.app.Notification; import android.app.NotificationChannel; +import android.app.job.JobParameters; import android.app.job.JobService; import android.graphics.drawable.Icon; import android.os.UserHandle; @@ -145,7 +146,7 @@ public class JobNotificationCoordinatorTest { .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), eq(notificationId), eq(notification), eq(UserHandle.getUserId(uid))); - coordinator.removeNotificationAssociation(jsc); + coordinator.removeNotificationAssociation(jsc, JobParameters.STOP_REASON_UNDEFINED); verify(mNotificationManagerInternal, never()) .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), anyInt(), anyInt()); @@ -166,7 +167,7 @@ public class JobNotificationCoordinatorTest { .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), eq(notificationId), eq(notification), eq(UserHandle.getUserId(uid))); - coordinator.removeNotificationAssociation(jsc); + coordinator.removeNotificationAssociation(jsc, JobParameters.STOP_REASON_UNDEFINED); verify(mNotificationManagerInternal) .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), eq(notificationId), eq(UserHandle.getUserId(uid))); @@ -288,7 +289,7 @@ public class JobNotificationCoordinatorTest { eq(notificationId2), eq(notification2), eq(UserHandle.getUserId(uid))); // Remove the first job. Only the first notification should be removed. - coordinator.removeNotificationAssociation(jsc1); + coordinator.removeNotificationAssociation(jsc1, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal) .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), eq(notificationId1), eq(UserHandle.getUserId(uid))); @@ -296,7 +297,7 @@ public class JobNotificationCoordinatorTest { .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), eq(notificationId2), anyInt()); - coordinator.removeNotificationAssociation(jsc2); + coordinator.removeNotificationAssociation(jsc2, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal) .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), eq(notificationId2), eq(UserHandle.getUserId(uid))); @@ -331,12 +332,12 @@ public class JobNotificationCoordinatorTest { eq(notificationId), eq(notification2), eq(UserHandle.getUserId(uid))); // Remove the first job. The notification shouldn't be touched because of the 2nd job. - coordinator.removeNotificationAssociation(jsc1); + coordinator.removeNotificationAssociation(jsc1, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal, never()) .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), anyInt(), anyInt()); - coordinator.removeNotificationAssociation(jsc2); + coordinator.removeNotificationAssociation(jsc2, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal) .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), eq(notificationId), eq(UserHandle.getUserId(uid))); @@ -372,7 +373,7 @@ public class JobNotificationCoordinatorTest { eq(notificationId), eq(notification2), eq(UserHandle.getUserId(uid2))); // Remove the first job. Only the first notification should be removed. - coordinator.removeNotificationAssociation(jsc1); + coordinator.removeNotificationAssociation(jsc1, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal) .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid1), eq(pid), any(), eq(notificationId), eq(UserHandle.getUserId(uid1))); @@ -380,7 +381,7 @@ public class JobNotificationCoordinatorTest { .cancelNotification(anyString(), anyString(), eq(uid2), anyInt(), any(), anyInt(), anyInt()); - coordinator.removeNotificationAssociation(jsc2); + coordinator.removeNotificationAssociation(jsc2, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal) .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid2), eq(pid), any(), eq(notificationId), eq(UserHandle.getUserId(uid2))); @@ -417,7 +418,7 @@ public class JobNotificationCoordinatorTest { eq(notificationId), eq(notification2), eq(UserHandle.getUserId(uid))); // Remove the first job. Only the first notification should be removed. - coordinator.removeNotificationAssociation(jsc1); + coordinator.removeNotificationAssociation(jsc1, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal) .cancelNotification(eq(pkg1), eq(pkg1), eq(uid), eq(pid), any(), eq(notificationId), eq(UserHandle.getUserId(uid))); @@ -425,12 +426,73 @@ public class JobNotificationCoordinatorTest { .cancelNotification(anyString(), anyString(), eq(uid), anyInt(), any(), anyInt(), anyInt()); - coordinator.removeNotificationAssociation(jsc2); + coordinator.removeNotificationAssociation(jsc2, JobParameters.STOP_REASON_UNDEFINED); inOrder.verify(mNotificationManagerInternal) .cancelNotification(eq(pkg2), eq(pkg2), eq(uid), eq(pid), any(), eq(notificationId), eq(UserHandle.getUserId(uid))); } + @Test + public void testUserStop_SingleJob_DetachOnStop() { + final JobNotificationCoordinator coordinator = new JobNotificationCoordinator(); + final JobServiceContext jsc = mock(JobServiceContext.class); + final Notification notification = createValidNotification(); + final int uid = 10123; + final int pid = 42; + final int notificationId = 23; + + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId, notification, + JobService.JOB_END_NOTIFICATION_POLICY_DETACH); + verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId), eq(notification), eq(UserHandle.getUserId(uid))); + + coordinator.removeNotificationAssociation(jsc, JobParameters.STOP_REASON_USER); + verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId), eq(UserHandle.getUserId(uid))); + } + + @Test + public void testUserStop_MultipleJobs_sameApp_EnqueueSameNotificationId_DetachOnStop() { + final JobNotificationCoordinator coordinator = new JobNotificationCoordinator(); + final JobServiceContext jsc1 = mock(JobServiceContext.class); + final JobServiceContext jsc2 = mock(JobServiceContext.class); + final Notification notification1 = createValidNotification(); + final Notification notification2 = createValidNotification(); + final int uid = 10123; + final int pid = 42; + final int notificationId = 23; + + InOrder inOrder = inOrder(mNotificationManagerInternal); + + coordinator.enqueueNotification(jsc1, TEST_PACKAGE, pid, uid, notificationId, notification1, + JobService.JOB_END_NOTIFICATION_POLICY_DETACH); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId), eq(notification1), eq(UserHandle.getUserId(uid))); + + coordinator.enqueueNotification(jsc2, TEST_PACKAGE, pid, uid, notificationId, notification2, + JobService.JOB_END_NOTIFICATION_POLICY_DETACH); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), anyInt()); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId), eq(notification2), eq(UserHandle.getUserId(uid))); + + // Remove the first job. The notification shouldn't be touched because of the 2nd job. + coordinator.removeNotificationAssociation(jsc1, JobParameters.STOP_REASON_USER); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), anyInt()); + + coordinator.removeNotificationAssociation(jsc2, JobParameters.STOP_REASON_USER); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId), eq(UserHandle.getUserId(uid))); + } + private Notification createValidNotification() { final Notification notification = mock(Notification.class); doReturn(mock(Icon.class)).when(notification).getSmallIcon();