diff --git a/apex/jobscheduler/framework/java/android/app/job/JobService.java b/apex/jobscheduler/framework/java/android/app/job/JobService.java index e88e979460b85..62799598cf6ac 100644 --- a/apex/jobscheduler/framework/java/android/app/job/JobService.java +++ b/apex/jobscheduler/framework/java/android/app/job/JobService.java @@ -416,6 +416,11 @@ public abstract class JobService extends Service { * 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). * + *

+ * If separate jobs use the same notification ID with this API, the most recently provided + * notification will be shown to the user, and the + * {@code jobEndNotificationPolicy} of the last job to stop will be applied. + * * @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 diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java b/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java index 16201b29571ee..30986dde6b911 100644 --- a/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java +++ b/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java @@ -193,6 +193,7 @@ class JobConcurrencyManager { } private final Object mLock; + private final JobNotificationCoordinator mNotificationCoordinator; private final JobSchedulerService mService; private final Context mContext; private final Handler mHandler; @@ -418,6 +419,7 @@ class JobConcurrencyManager { mLock = mService.getLock(); mContext = service.getTestableContext(); mInjector = injector; + mNotificationCoordinator = new JobNotificationCoordinator(); mHandler = JobSchedulerBackgroundThread.getHandler(); @@ -451,7 +453,8 @@ class JobConcurrencyManager { ServiceManager.getService(BatteryStats.SERVICE_NAME)); for (int i = 0; i < STANDARD_CONCURRENCY_LIMIT; i++) { mIdleContexts.add( - mInjector.createJobServiceContext(mService, this, batteryStats, + mInjector.createJobServiceContext(mService, this, + mNotificationCoordinator, batteryStats, mService.mJobPackageTracker, mContext.getMainLooper())); } } @@ -1687,7 +1690,7 @@ class JobConcurrencyManager { @NonNull private JobServiceContext createNewJobServiceContext() { - return mInjector.createJobServiceContext(mService, this, + return mInjector.createJobServiceContext(mService, this, mNotificationCoordinator, IBatteryStats.Stub.asInterface( ServiceManager.getService(BatteryStats.SERVICE_NAME)), mService.mJobPackageTracker, mContext.getMainLooper()); @@ -2612,10 +2615,11 @@ class JobConcurrencyManager { static class Injector { @NonNull JobServiceContext createJobServiceContext(JobSchedulerService service, - JobConcurrencyManager concurrencyManager, IBatteryStats batteryStats, + JobConcurrencyManager concurrencyManager, + JobNotificationCoordinator notificationCoordinator, IBatteryStats batteryStats, JobPackageTracker tracker, Looper looper) { - return new JobServiceContext(service, concurrencyManager, batteryStats, - tracker, looper); + return new JobServiceContext(service, concurrencyManager, notificationCoordinator, + batteryStats, tracker, looper); } } } diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobNotificationCoordinator.java b/apex/jobscheduler/service/java/com/android/server/job/JobNotificationCoordinator.java new file mode 100644 index 0000000000000..ce5ade5531ec9 --- /dev/null +++ b/apex/jobscheduler/service/java/com/android/server/job/JobNotificationCoordinator.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.job; + +import static android.app.job.JobService.JOB_END_NOTIFICATION_POLICY_DETACH; +import static android.app.job.JobService.JOB_END_NOTIFICATION_POLICY_REMOVE; + +import android.annotation.NonNull; +import android.app.Notification; +import android.app.job.JobService; +import android.content.pm.UserPackage; +import android.os.UserHandle; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.util.Slog; +import android.util.SparseSetArray; + +import com.android.server.LocalServices; +import com.android.server.notification.NotificationManagerInternal; + +class JobNotificationCoordinator { + private static final String TAG = "JobNotificationCoordinator"; + + /** + * Mapping of UserPackage -> {notificationId -> List} to track which jobs + * are associated with each app's notifications. + */ + private final ArrayMap> mCurrentAssociations = + new ArrayMap<>(); + /** + * Set of NotificationDetails for each running job. + */ + private final ArrayMap mNotificationDetails = + new ArrayMap<>(); + + private static final class NotificationDetails { + @NonNull + public final UserPackage userPackage; + public final int notificationId; + public final int appPid; + public final int appUid; + @JobService.JobEndNotificationPolicy + public final int jobEndNotificationPolicy; + + NotificationDetails(@NonNull UserPackage userPackage, int appPid, int appUid, + int notificationId, + @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy) { + this.userPackage = userPackage; + this.notificationId = notificationId; + this.appPid = appPid; + this.appUid = appUid; + this.jobEndNotificationPolicy = jobEndNotificationPolicy; + } + } + + private final NotificationManagerInternal mNotificationManagerInternal; + + JobNotificationCoordinator() { + mNotificationManagerInternal = LocalServices.getService(NotificationManagerInternal.class); + } + + void enqueueNotification(@NonNull JobServiceContext hostingContext, @NonNull String packageName, + int callingPid, int callingUid, int notificationId, @NonNull Notification notification, + @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy) { + validateNotification(packageName, callingUid, notification, jobEndNotificationPolicy); + 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); + } + final int userId = UserHandle.getUserId(callingUid); + // TODO(260848384): ensure apps can't cancel the notification for user-initiated job + // eg., by calling NotificationManager.cancel/All or deleting the notification channel + mNotificationManagerInternal.enqueueNotification( + packageName, packageName, callingUid, callingPid, /* tag */ null, + notificationId, notification, userId); + final UserPackage userPackage = UserPackage.of(userId, packageName); + final NotificationDetails details = new NotificationDetails( + userPackage, callingPid, callingUid, notificationId, jobEndNotificationPolicy); + SparseSetArray appNotifications = mCurrentAssociations.get(userPackage); + if (appNotifications == null) { + appNotifications = new SparseSetArray<>(); + mCurrentAssociations.put(userPackage, appNotifications); + } + appNotifications.add(notificationId, hostingContext); + mNotificationDetails.put(hostingContext, details); + } + + void removeNotificationAssociation(@NonNull JobServiceContext hostingContext) { + final NotificationDetails details = mNotificationDetails.remove(hostingContext); + if (details == null) { + return; + } + final SparseSetArray associations = + mCurrentAssociations.get(details.userPackage); + if (associations == null || !associations.remove(details.notificationId, hostingContext)) { + Slog.wtf(TAG, "Association data structures not in sync"); + return; + } + 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) { + final String packageName = details.userPackage.packageName; + mNotificationManagerInternal.cancelNotification( + packageName, packageName, details.appUid, details.appPid, /* tag */ null, + details.notificationId, UserHandle.getUserId(details.appUid)); + } + } + } + + private void validateNotification(@NonNull String packageName, int callingUid, + @NonNull Notification notification, + @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy) { + if (notification == null) { + throw new NullPointerException("notification"); + } + if (notification.getSmallIcon() == null) { + throw new IllegalArgumentException("small icon required"); + } + if (null == mNotificationManagerInternal.getNotificationChannel( + packageName, 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"); + } + } +} 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 fead68e021dbc..df47f1787fdc6 100644 --- a/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java +++ b/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java @@ -17,8 +17,6 @@ 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; @@ -61,7 +59,6 @@ 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; @@ -113,6 +110,7 @@ public final class JobServiceContext implements ServiceConnection { /** Make callbacks to {@link JobSchedulerService} to inform on job completion status. */ private final JobCompletedListener mCompletedListener; private final JobConcurrencyManager mJobConcurrencyManager; + private final JobNotificationCoordinator mNotificationCoordinator; private final JobSchedulerService mService; /** Used for service binding, etc. */ private final Context mContext; @@ -121,7 +119,6 @@ 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. @@ -174,11 +171,6 @@ 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}. @@ -254,16 +246,17 @@ public final class JobServiceContext implements ServiceConnection { } JobServiceContext(JobSchedulerService service, JobConcurrencyManager concurrencyManager, + JobNotificationCoordinator notificationCoordinator, IBatteryStats batteryStats, JobPackageTracker tracker, Looper looper) { mContext = service.getContext(); mLock = service.getLock(); mService = service; mBatteryStats = batteryStats; mEconomyManagerInternal = LocalServices.getService(EconomyManagerInternal.class); - mNotificationManagerInternal = LocalServices.getService(NotificationManagerInternal.class); mJobPackageTracker = tracker; mCallbackHandler = new JobServiceHandler(looper); mJobConcurrencyManager = concurrencyManager; + mNotificationCoordinator = notificationCoordinator; mCompletedListener = service; mPowerManager = mContext.getSystemService(PowerManager.class); mAvailable = true; @@ -624,29 +617,10 @@ public final class JobServiceContext implements ServiceConnection { 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; + mNotificationCoordinator.enqueueNotification(this, callingPkgName, + callingPid, callingUid, notificationId, + notification, jobEndNotificationPolicy); } } finally { Binder.restoreCallingIdentity(ident); @@ -1179,13 +1153,7 @@ 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())); - } + mNotificationCoordinator.removeNotificationAssociation(this); if (mWakeLock != null) { mWakeLock.release(); } @@ -1203,7 +1171,6 @@ public final class JobServiceContext implements ServiceConnection { mPendingStopReason = JobParameters.STOP_REASON_UNDEFINED; mPendingInternalStopReason = 0; mPendingDebugStopReason = null; - mNotification = null; removeOpTimeOutLocked(); if (completedJob.isUserVisibleJob()) { mService.informObserversOfUserVisibleJobChange(this, completedJob, false); diff --git a/services/tests/mockingservicestests/src/com/android/server/job/JobConcurrencyManagerTest.java b/services/tests/mockingservicestests/src/com/android/server/job/JobConcurrencyManagerTest.java index 6e4d21456cd08..a9dc4af07bab5 100644 --- a/services/tests/mockingservicestests/src/com/android/server/job/JobConcurrencyManagerTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/job/JobConcurrencyManagerTest.java @@ -113,7 +113,8 @@ public final class JobConcurrencyManagerTest { @Override JobServiceContext createJobServiceContext(JobSchedulerService service, - JobConcurrencyManager concurrencyManager, IBatteryStats batteryStats, + JobConcurrencyManager concurrencyManager, + JobNotificationCoordinator notificationCoordinator, IBatteryStats batteryStats, JobPackageTracker tracker, Looper looper) { final JobServiceContext context = mock(JobServiceContext.class); doAnswer((Answer) invocationOnMock -> { diff --git a/services/tests/mockingservicestests/src/com/android/server/job/JobNotificationCoordinatorTest.java b/services/tests/mockingservicestests/src/com/android/server/job/JobNotificationCoordinatorTest.java new file mode 100644 index 0000000000000..b4104dbc89f9d --- /dev/null +++ b/services/tests/mockingservicestests/src/com/android/server/job/JobNotificationCoordinatorTest.java @@ -0,0 +1,440 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.job; + + +import static com.android.dx.mockito.inline.extended.ExtendedMockito.doNothing; +import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn; +import static com.android.dx.mockito.inline.extended.ExtendedMockito.inOrder; +import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock; +import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession; + +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.job.JobService; +import android.graphics.drawable.Icon; +import android.os.UserHandle; + +import com.android.server.LocalServices; +import com.android.server.notification.NotificationManagerInternal; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoSession; +import org.mockito.quality.Strictness; + +public class JobNotificationCoordinatorTest { + private static final String TEST_PACKAGE = "com.android.test"; + private static final String NOTIFICATION_CHANNEL_ID = "validNotificationChannelId"; + + private MockitoSession mMockingSession; + + @Mock + private NotificationManagerInternal mNotificationManagerInternal; + + @Before + public void setUp() { + mMockingSession = mockitoSession() + .initMocks(this) + .mockStatic(LocalServices.class) + .strictness(Strictness.LENIENT) + .startMocking(); + doReturn(mNotificationManagerInternal) + .when(() -> LocalServices.getService(NotificationManagerInternal.class)); + doNothing().when(mNotificationManagerInternal) + .enqueueNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), any(), anyInt()); + doNothing().when(mNotificationManagerInternal) + .enqueueNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), any(), anyInt()); + doReturn(mock(NotificationChannel.class)).when(mNotificationManagerInternal) + .getNotificationChannel(anyString(), anyInt(), eq(NOTIFICATION_CHANNEL_ID)); + } + + @After + public void tearDown() throws Exception { + if (mMockingSession != null) { + mMockingSession.finishMocking(); + } + } + + @Test + public void testParameterValidation() { + final JobNotificationCoordinator coordinator = new JobNotificationCoordinator(); + final JobServiceContext jsc = mock(JobServiceContext.class); + final int uid = 10123; + final int pid = 42; + final int notificationId = 23; + + try { + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId, null, + JobService.JOB_END_NOTIFICATION_POLICY_DETACH); + fail("Successfully enqueued a null notification"); + } catch (NullPointerException e) { + // Success + } + + Notification notification = createValidNotification(); + doReturn(null).when(notification).getSmallIcon(); + try { + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId, + notification, JobService.JOB_END_NOTIFICATION_POLICY_DETACH); + fail("Successfully enqueued a notification with no small icon"); + } catch (IllegalArgumentException e) { + // Success + } + + notification = createValidNotification(); + doReturn(null).when(notification).getChannelId(); + try { + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId, + notification, JobService.JOB_END_NOTIFICATION_POLICY_DETACH); + fail("Successfully enqueued a notification with no valid channel"); + } catch (IllegalArgumentException e) { + // Success + } + + notification = createValidNotification(); + try { + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId, + notification, Integer.MAX_VALUE); + fail("Successfully enqueued a notification with an invalid job end notification " + + "policy"); + } catch (IllegalArgumentException e) { + // Success + } + } + + @Test + public void testSingleJob_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); + verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), anyInt()); + } + + @Test + public void testSingleJob_RemoveOnStop() { + 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_REMOVE); + 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); + verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId), eq(UserHandle.getUserId(uid))); + } + + @Test + public void testSingleJob_EnqueueDifferentNotificationId_DetachOnStop() { + final JobNotificationCoordinator coordinator = new JobNotificationCoordinator(); + final JobServiceContext jsc = mock(JobServiceContext.class); + final Notification notification1 = createValidNotification(); + final Notification notification2 = createValidNotification(); + final int uid = 10123; + final int pid = 42; + final int notificationId1 = 23; + final int notificationId2 = 46; + + InOrder inOrder = inOrder(mNotificationManagerInternal); + + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId1, notification1, + JobService.JOB_END_NOTIFICATION_POLICY_DETACH); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId1), eq(notification1), eq(UserHandle.getUserId(uid))); + + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId2, 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(notificationId2), eq(notification2), eq(UserHandle.getUserId(uid))); + } + + @Test + public void testSingleJob_EnqueueDifferentNotificationId_RemoveOnStop() { + final JobNotificationCoordinator coordinator = new JobNotificationCoordinator(); + final JobServiceContext jsc = mock(JobServiceContext.class); + final Notification notification1 = createValidNotification(); + final Notification notification2 = createValidNotification(); + final int uid = 10123; + final int pid = 42; + final int notificationId1 = 23; + final int notificationId2 = 46; + + InOrder inOrder = inOrder(mNotificationManagerInternal); + + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId1, notification1, + JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId1), eq(notification1), eq(UserHandle.getUserId(uid))); + + coordinator.enqueueNotification(jsc, TEST_PACKAGE, pid, uid, notificationId2, notification2, + JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId1), eq(UserHandle.getUserId(uid))); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId2), eq(notification2), eq(UserHandle.getUserId(uid))); + } + + @Test + public void testSingleJob_EnqueueSameNotificationId() { + final JobNotificationCoordinator coordinator = new JobNotificationCoordinator(); + final JobServiceContext jsc = 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(jsc, 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(jsc, 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))); + } + + @Test + public void testMultipleJobs_sameApp_EnqueueDifferentNotificationId() { + 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 notificationId1 = 23; + final int notificationId2 = 46; + + InOrder inOrder = inOrder(mNotificationManagerInternal); + + coordinator.enqueueNotification(jsc1, TEST_PACKAGE, pid, uid, notificationId1, + notification1, JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId1), eq(notification1), eq(UserHandle.getUserId(uid))); + + coordinator.enqueueNotification(jsc2, TEST_PACKAGE, pid, uid, notificationId2, + notification2, JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + 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(notificationId2), eq(notification2), eq(UserHandle.getUserId(uid))); + + // Remove the first job. Only the first notification should be removed. + coordinator.removeNotificationAssociation(jsc1); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId1), eq(UserHandle.getUserId(uid))); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + eq(notificationId2), anyInt()); + + coordinator.removeNotificationAssociation(jsc2); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId2), eq(UserHandle.getUserId(uid))); + } + + @Test + public void testMultipleJobs_sameApp_EnqueueSameNotificationId() { + 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_REMOVE); + 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); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), anyInt()); + + coordinator.removeNotificationAssociation(jsc2); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid), eq(pid), any(), + eq(notificationId), eq(UserHandle.getUserId(uid))); + } + + @Test + public void testMultipleJobs_sameApp_DifferentUsers() { + 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 uid1 = 10123; + final int uid2 = 1010123; + final int pid = 42; + final int notificationId = 23; + + InOrder inOrder = inOrder(mNotificationManagerInternal); + + coordinator.enqueueNotification(jsc1, TEST_PACKAGE, pid, uid1, notificationId, + notification1, JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid1), eq(pid), any(), + eq(notificationId), eq(notification1), eq(UserHandle.getUserId(uid1))); + + coordinator.enqueueNotification(jsc2, TEST_PACKAGE, pid, uid2, notificationId, + notification2, JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), anyInt()); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid2), eq(pid), any(), + eq(notificationId), eq(notification2), eq(UserHandle.getUserId(uid2))); + + // Remove the first job. Only the first notification should be removed. + coordinator.removeNotificationAssociation(jsc1); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid1), eq(pid), any(), + eq(notificationId), eq(UserHandle.getUserId(uid1))); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), eq(uid2), anyInt(), any(), + anyInt(), anyInt()); + + coordinator.removeNotificationAssociation(jsc2); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(TEST_PACKAGE), eq(TEST_PACKAGE), eq(uid2), eq(pid), any(), + eq(notificationId), eq(UserHandle.getUserId(uid2))); + } + + @Test + public void testMultipleJobs_differentApps() { + final JobNotificationCoordinator coordinator = new JobNotificationCoordinator(); + final String pkg1 = "pkg1"; + final String pkg2 = "pkg2"; + 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, pkg1, pid, uid, notificationId, notification1, + JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(pkg1), eq(pkg1), eq(uid), eq(pid), any(), + eq(notificationId), eq(notification1), eq(UserHandle.getUserId(uid))); + + coordinator.enqueueNotification(jsc2, pkg2, pid, uid, notificationId, notification2, + JobService.JOB_END_NOTIFICATION_POLICY_REMOVE); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), anyInt(), anyInt(), any(), + anyInt(), anyInt()); + inOrder.verify(mNotificationManagerInternal) + .enqueueNotification(eq(pkg2), eq(pkg2), eq(uid), eq(pid), any(), + eq(notificationId), eq(notification2), eq(UserHandle.getUserId(uid))); + + // Remove the first job. Only the first notification should be removed. + coordinator.removeNotificationAssociation(jsc1); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(pkg1), eq(pkg1), eq(uid), eq(pid), any(), + eq(notificationId), eq(UserHandle.getUserId(uid))); + inOrder.verify(mNotificationManagerInternal, never()) + .cancelNotification(anyString(), anyString(), eq(uid), anyInt(), any(), + anyInt(), anyInt()); + + coordinator.removeNotificationAssociation(jsc2); + inOrder.verify(mNotificationManagerInternal) + .cancelNotification(eq(pkg2), eq(pkg2), 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(); + doReturn(NOTIFICATION_CHANNEL_ID).when(notification).getChannelId(); + return notification; + } +}