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;
+ }
+}