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