1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.job; 18 19 import static android.app.job.JobService.JOB_END_NOTIFICATION_POLICY_DETACH; 20 import static android.app.job.JobService.JOB_END_NOTIFICATION_POLICY_REMOVE; 21 22 import android.annotation.NonNull; 23 import android.app.Notification; 24 import android.app.job.JobParameters; 25 import android.app.job.JobService; 26 import android.content.pm.UserPackage; 27 import android.os.UserHandle; 28 import android.util.ArrayMap; 29 import android.util.ArraySet; 30 import android.util.IntArray; 31 import android.util.Slog; 32 import android.util.SparseArrayMap; 33 import android.util.SparseSetArray; 34 35 import com.android.internal.annotations.GuardedBy; 36 import com.android.modules.expresslog.Counter; 37 import com.android.server.LocalServices; 38 import com.android.server.job.controllers.JobStatus; 39 import com.android.server.notification.NotificationManagerInternal; 40 41 class JobNotificationCoordinator { 42 private static final String TAG = "JobNotificationCoordinator"; 43 44 /** 45 * Local lock for independent objects like mUijNotifications and mUijNotificationChannels which 46 * don't depend on other JS objects such as JobServiceContext which require the global JS lock. 47 * 48 * Note: do <b>NOT</b> acquire the global lock while this one is held. 49 */ 50 private final Object mUijLock = new Object(); 51 52 /** 53 * Mapping of UserPackage -> {notificationId -> List<JobServiceContext>} to track which jobs 54 * are associated with each app's notifications. 55 */ 56 private final ArrayMap<UserPackage, SparseSetArray<JobServiceContext>> mCurrentAssociations = 57 new ArrayMap<>(); 58 /** 59 * Set of NotificationDetails for each running job. 60 */ 61 private final ArrayMap<JobServiceContext, NotificationDetails> mNotificationDetails = 62 new ArrayMap<>(); 63 64 /** 65 * Mapping of userId -> {packageName, notificationIds} tracking which notifications 66 * associated with each app belong to user-initiated jobs. 67 * 68 * Note: this map can be accessed without holding the main JS lock, so that other services like 69 * NotificationManagerService can call into JS and verify associations. 70 */ 71 @GuardedBy("mUijLock") 72 private final SparseArrayMap<String, IntArray> mUijNotifications = new SparseArrayMap<>(); 73 74 /** 75 * Mapping of userId -> {packageName, notificationChannels} tracking which notification channels 76 * associated with each app are hosting a user-initiated job notification. 77 * 78 * Note: this map can be accessed without holding the main JS lock, so that other services like 79 * NotificationManagerService can call into JS and verify associations. 80 */ 81 @GuardedBy("mUijLock") 82 private final SparseArrayMap<String, ArraySet<String>> mUijNotificationChannels = 83 new SparseArrayMap<>(); 84 85 private static final class NotificationDetails { 86 @NonNull 87 public final UserPackage userPackage; 88 public final int notificationId; 89 public final String notificationChannel; 90 public final int appPid; 91 public final int appUid; 92 @JobService.JobEndNotificationPolicy 93 public final int jobEndNotificationPolicy; 94 NotificationDetails(@onNull UserPackage userPackage, int appPid, int appUid, int notificationId, String notificationChannel, @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy)95 NotificationDetails(@NonNull UserPackage userPackage, int appPid, int appUid, 96 int notificationId, String notificationChannel, 97 @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy) { 98 this.userPackage = userPackage; 99 this.notificationId = notificationId; 100 this.notificationChannel = notificationChannel; 101 this.appPid = appPid; 102 this.appUid = appUid; 103 this.jobEndNotificationPolicy = jobEndNotificationPolicy; 104 } 105 } 106 107 private final NotificationManagerInternal mNotificationManagerInternal; 108 JobNotificationCoordinator()109 JobNotificationCoordinator() { 110 mNotificationManagerInternal = LocalServices.getService(NotificationManagerInternal.class); 111 } 112 enqueueNotification(@onNull JobServiceContext hostingContext, @NonNull String packageName, int callingPid, int callingUid, int notificationId, @NonNull Notification notification, @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy)113 void enqueueNotification(@NonNull JobServiceContext hostingContext, @NonNull String packageName, 114 int callingPid, int callingUid, int notificationId, @NonNull Notification notification, 115 @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy) { 116 validateNotification(packageName, callingUid, notification, jobEndNotificationPolicy); 117 final JobStatus jobStatus = hostingContext.getRunningJobLocked(); 118 if (jobStatus == null) { 119 Slog.wtfStack(TAG, "enqueueNotification called with no running job"); 120 return; 121 } 122 final NotificationDetails oldDetails = mNotificationDetails.get(hostingContext); 123 if (oldDetails == null) { 124 if (jobStatus.startedAsUserInitiatedJob) { 125 Counter.logIncrementWithUid( 126 "job_scheduler.value_cntr_w_uid_initial_set_notification_call_required", 127 jobStatus.getUid()); 128 } else { 129 Counter.logIncrementWithUid( 130 "job_scheduler.value_cntr_w_uid_initial_set_notification_call_optional", 131 jobStatus.getUid()); 132 } 133 } else { 134 if (jobStatus.startedAsUserInitiatedJob) { 135 Counter.logIncrementWithUid( 136 "job_scheduler.value_cntr_w_uid_subsequent_set_notification_call_required", 137 jobStatus.getUid()); 138 } else { 139 Counter.logIncrementWithUid( 140 "job_scheduler.value_cntr_w_uid_subsequent_set_notification_call_optional", 141 jobStatus.getUid()); 142 } 143 if (oldDetails.notificationId != notificationId) { 144 // App is switching notification IDs. Remove association with the old one. 145 removeNotificationAssociation(hostingContext, JobParameters.STOP_REASON_UNDEFINED, 146 jobStatus); 147 Counter.logIncrementWithUid( 148 "job_scheduler.value_cntr_w_uid_set_notification_changed_notification_ids", 149 jobStatus.getUid()); 150 } 151 } 152 final int userId = UserHandle.getUserId(callingUid); 153 if (jobStatus != null && jobStatus.startedAsUserInitiatedJob) { 154 notification.flags |= Notification.FLAG_USER_INITIATED_JOB; 155 synchronized (mUijLock) { 156 maybeCreateUijNotificationSetsLocked(userId, packageName); 157 final IntArray notificationIds = mUijNotifications.get(userId, packageName); 158 if (notificationIds.indexOf(notificationId) == -1) { 159 notificationIds.add(notificationId); 160 } 161 mUijNotificationChannels.get(userId, packageName).add(notification.getChannelId()); 162 } 163 } 164 final UserPackage userPackage = UserPackage.of(userId, packageName); 165 final NotificationDetails details = new NotificationDetails( 166 userPackage, callingPid, callingUid, notificationId, notification.getChannelId(), 167 jobEndNotificationPolicy); 168 SparseSetArray<JobServiceContext> appNotifications = mCurrentAssociations.get(userPackage); 169 if (appNotifications == null) { 170 appNotifications = new SparseSetArray<>(); 171 mCurrentAssociations.put(userPackage, appNotifications); 172 } 173 appNotifications.add(notificationId, hostingContext); 174 mNotificationDetails.put(hostingContext, details); 175 // Call into NotificationManager after internal data structures have been updated since 176 // NotificationManager calls into this class to check for any existing associations. 177 mNotificationManagerInternal.enqueueNotification( 178 packageName, packageName, callingUid, callingPid, /* tag */ null, 179 notificationId, notification, userId); 180 } 181 removeNotificationAssociation(@onNull JobServiceContext hostingContext, @JobParameters.StopReason int stopReason, JobStatus completedJob)182 void removeNotificationAssociation(@NonNull JobServiceContext hostingContext, 183 @JobParameters.StopReason int stopReason, JobStatus completedJob) { 184 final NotificationDetails details = mNotificationDetails.remove(hostingContext); 185 if (details == null) { 186 return; 187 } 188 final SparseSetArray<JobServiceContext> associations = 189 mCurrentAssociations.get(details.userPackage); 190 if (associations == null || !associations.remove(details.notificationId, hostingContext)) { 191 Slog.wtf(TAG, "Association data structures not in sync"); 192 return; 193 } 194 final int userId = UserHandle.getUserId(details.appUid); 195 final String packageName = details.userPackage.packageName; 196 final int notificationId = details.notificationId; 197 boolean stripUijFlag = true; 198 ArraySet<JobServiceContext> associatedContexts = associations.get(notificationId); 199 if (associatedContexts == null || associatedContexts.isEmpty()) { 200 // No more jobs using this notification. Apply the final job stop policy. 201 // If the user attempted to stop the job/app, then always remove the notification 202 // so the user doesn't get confused about the app state. 203 // Similarly, if the user background restricted the app, remove the notification so 204 // the user doesn't think the app is continuing to run in the background. 205 if (details.jobEndNotificationPolicy == JOB_END_NOTIFICATION_POLICY_REMOVE 206 || stopReason == JobParameters.STOP_REASON_BACKGROUND_RESTRICTION 207 || stopReason == JobParameters.STOP_REASON_USER) { 208 mNotificationManagerInternal.cancelNotification( 209 packageName, packageName, details.appUid, details.appPid, /* tag */ null, 210 notificationId, userId); 211 stripUijFlag = false; 212 } 213 } else { 214 // Strip the UIJ flag only if there are no other UIJs associated with the notification 215 stripUijFlag = !isNotificationUsedForAnyUij(userId, packageName, notificationId); 216 } 217 if (stripUijFlag) { 218 mNotificationManagerInternal.removeUserInitiatedJobFlagFromNotification( 219 packageName, notificationId, userId); 220 } 221 222 // Clean up UIJ related objects if the just completed job was a UIJ 223 if (completedJob != null && completedJob.startedAsUserInitiatedJob) { 224 maybeDeleteNotificationIdAssociation(userId, packageName, notificationId); 225 maybeDeleteNotificationChannelAssociation( 226 userId, packageName, details.notificationChannel); 227 } 228 } 229 isNotificationAssociatedWithAnyUserInitiatedJobs(int notificationId, int userId, @NonNull String packageName)230 boolean isNotificationAssociatedWithAnyUserInitiatedJobs(int notificationId, 231 int userId, @NonNull String packageName) { 232 synchronized (mUijLock) { 233 final IntArray notifications = mUijNotifications.get(userId, packageName); 234 if (notifications != null) { 235 return notifications.indexOf(notificationId) != -1; 236 } 237 return false; 238 } 239 } 240 isNotificationChannelAssociatedWithAnyUserInitiatedJobs( @onNull String notificationChannel, int userId, @NonNull String packageName)241 boolean isNotificationChannelAssociatedWithAnyUserInitiatedJobs( 242 @NonNull String notificationChannel, int userId, @NonNull String packageName) { 243 synchronized (mUijLock) { 244 final ArraySet<String> channels = mUijNotificationChannels.get(userId, packageName); 245 if (channels != null) { 246 return channels.contains(notificationChannel); 247 } 248 return false; 249 } 250 } 251 isNotificationUsedForAnyUij(int userId, String packageName, int notificationId)252 private boolean isNotificationUsedForAnyUij(int userId, String packageName, 253 int notificationId) { 254 final UserPackage pkgDetails = UserPackage.of(userId, packageName); 255 final SparseSetArray<JobServiceContext> associations = mCurrentAssociations.get(pkgDetails); 256 if (associations == null) { 257 return false; 258 } 259 final ArraySet<JobServiceContext> associatedContexts = associations.get(notificationId); 260 if (associatedContexts == null) { 261 return false; 262 } 263 264 // Check if any UIJs associated with this package are using the same notification 265 for (int i = associatedContexts.size() - 1; i >= 0; i--) { 266 final JobStatus jobStatus = associatedContexts.valueAt(i).getRunningJobLocked(); 267 if (jobStatus != null && jobStatus.startedAsUserInitiatedJob) { 268 return true; 269 } 270 } 271 return false; 272 } 273 maybeDeleteNotificationIdAssociation(int userId, String packageName, int notificationId)274 private void maybeDeleteNotificationIdAssociation(int userId, String packageName, 275 int notificationId) { 276 if (isNotificationUsedForAnyUij(userId, packageName, notificationId)) { 277 return; 278 } 279 280 // Safe to delete - no UIJs for this package are using this notification id 281 synchronized (mUijLock) { 282 final IntArray notifications = mUijNotifications.get(userId, packageName); 283 if (notifications != null) { 284 notifications.remove(notifications.indexOf(notificationId)); 285 if (notifications.size() == 0) { 286 mUijNotifications.delete(userId, packageName); 287 } 288 } 289 } 290 } 291 maybeDeleteNotificationChannelAssociation(int userId, String packageName, String notificationChannel)292 private void maybeDeleteNotificationChannelAssociation(int userId, String packageName, 293 String notificationChannel) { 294 for (int i = mNotificationDetails.size() - 1; i >= 0; i--) { 295 final JobServiceContext jsc = mNotificationDetails.keyAt(i); 296 final NotificationDetails details = mNotificationDetails.get(jsc); 297 // Check if the details for the given notification match and if the associated job 298 // was started as a user initiated job 299 if (details != null 300 && UserHandle.getUserId(details.appUid) == userId 301 && details.userPackage.packageName.equals(packageName) 302 && details.notificationChannel.equals(notificationChannel)) { 303 final JobStatus jobStatus = jsc.getRunningJobLocked(); 304 if (jobStatus != null && jobStatus.startedAsUserInitiatedJob) { 305 return; 306 } 307 } 308 } 309 310 // Safe to delete - no UIJs for this package are using this notification channel 311 synchronized (mUijLock) { 312 ArraySet<String> channels = mUijNotificationChannels.get(userId, packageName); 313 if (channels != null) { 314 channels.remove(notificationChannel); 315 if (channels.isEmpty()) { 316 mUijNotificationChannels.delete(userId, packageName); 317 } 318 } 319 } 320 } 321 322 @GuardedBy("mUijLock") maybeCreateUijNotificationSetsLocked(int userId, String packageName)323 private void maybeCreateUijNotificationSetsLocked(int userId, String packageName) { 324 if (mUijNotifications.get(userId, packageName) == null) { 325 mUijNotifications.add(userId, packageName, new IntArray()); 326 } 327 if (mUijNotificationChannels.get(userId, packageName) == null) { 328 mUijNotificationChannels.add(userId, packageName, new ArraySet<>()); 329 } 330 } 331 validateNotification(@onNull String packageName, int callingUid, @NonNull Notification notification, @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy)332 private void validateNotification(@NonNull String packageName, int callingUid, 333 @NonNull Notification notification, 334 @JobService.JobEndNotificationPolicy int jobEndNotificationPolicy) { 335 if (notification == null) { 336 throw new NullPointerException("notification"); 337 } 338 if (notification.getSmallIcon() == null) { 339 throw new IllegalArgumentException("small icon required"); 340 } 341 if (null == mNotificationManagerInternal.getNotificationChannel( 342 packageName, callingUid, notification.getChannelId())) { 343 throw new IllegalArgumentException("invalid notification channel"); 344 } 345 if (jobEndNotificationPolicy != JOB_END_NOTIFICATION_POLICY_DETACH 346 && jobEndNotificationPolicy != JOB_END_NOTIFICATION_POLICY_REMOVE) { 347 throw new IllegalArgumentException("invalid job end notification policy"); 348 } 349 } 350 } 351