Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9fc422b2 authored by Kweku Adams's avatar Kweku Adams
Browse files

Introduce centralized notification coordinator.

Use a centralized notification coordinator so that we can handle special
cases such as an app running separate jobs that each use the same
notification ID.

Bug: 260848384
Test: atest FrameworksMockingServicesTests:JobNotificationCoordinatorTest
Change-Id: I70f82fcb62726a92ba1f78eb366677df9f0eea10
parent 73ce8d4e
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -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).
     *
     * <p>
     * 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
+9 −5
Original line number Diff line number Diff line
@@ -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);
        }
    }
}
+144 −0
Original line number Diff line number Diff line
/*
 * 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<JobServiceContext>} to track which jobs
     * are associated with each app's notifications.
     */
    private final ArrayMap<UserPackage, SparseSetArray<JobServiceContext>> mCurrentAssociations =
            new ArrayMap<>();
    /**
     * Set of NotificationDetails for each running job.
     */
    private final ArrayMap<JobServiceContext, NotificationDetails> 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<JobServiceContext> 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<JobServiceContext> associations =
                mCurrentAssociations.get(details.userPackage);
        if (associations == null || !associations.remove(details.notificationId, hostingContext)) {
            Slog.wtf(TAG, "Association data structures not in sync");
            return;
        }
        ArraySet<JobServiceContext> 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");
        }
    }
}
+7 −40
Original line number Diff line number Diff line
@@ -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);
+2 −1
Original line number Diff line number Diff line
@@ -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<Boolean>) invocationOnMock -> {
Loading