Merge "Implement JS <-> TaskManager user-visible job link."
diff --git a/apex/jobscheduler/framework/java/android/app/JobSchedulerImpl.java b/apex/jobscheduler/framework/java/android/app/JobSchedulerImpl.java
index 2d3201a..4242cf8 100644
--- a/apex/jobscheduler/framework/java/android/app/JobSchedulerImpl.java
+++ b/apex/jobscheduler/framework/java/android/app/JobSchedulerImpl.java
@@ -158,7 +158,10 @@
             android.Manifest.permission.INTERACT_ACROSS_USERS_FULL})
     @Override
     public void registerUserVisibleJobObserver(@NonNull IUserVisibleJobObserver observer) {
-        // TODO(255767350): implement
+        try {
+            mBinder.registerUserVisibleJobObserver(observer);
+        } catch (RemoteException e) {
+        }
     }
 
     @RequiresPermission(allOf = {
@@ -166,7 +169,10 @@
             android.Manifest.permission.INTERACT_ACROSS_USERS_FULL})
     @Override
     public void unregisterUserVisibleJobObserver(@NonNull IUserVisibleJobObserver observer) {
-        // TODO(255767350): implement
+        try {
+            mBinder.unregisterUserVisibleJobObserver(observer);
+        } catch (RemoteException e) {
+        }
     }
 
     @RequiresPermission(allOf = {
@@ -174,6 +180,9 @@
             android.Manifest.permission.INTERACT_ACROSS_USERS_FULL})
     @Override
     public void stopUserVisibleJobsForUser(@NonNull String packageName, int userId) {
-        // TODO(255767350): implement
+        try {
+            mBinder.stopUserVisibleJobsForUser(packageName, userId);
+        } catch (RemoteException e) {
+        }
     }
 }
diff --git a/apex/jobscheduler/framework/java/android/app/job/IJobScheduler.aidl b/apex/jobscheduler/framework/java/android/app/job/IJobScheduler.aidl
index bf29dc9..c87a2af 100644
--- a/apex/jobscheduler/framework/java/android/app/job/IJobScheduler.aidl
+++ b/apex/jobscheduler/framework/java/android/app/job/IJobScheduler.aidl
@@ -16,6 +16,7 @@
 
 package android.app.job;
 
+import android.app.job.IUserVisibleJobObserver;
 import android.app.job.JobInfo;
 import android.app.job.JobSnapshot;
 import android.app.job.JobWorkItem;
@@ -38,4 +39,10 @@
     boolean hasRunLongJobsPermission(String packageName, int userId);
     List<JobInfo> getStartedJobs();
     ParceledListSlice getAllJobSnapshots();
+    @EnforcePermission(allOf={"MANAGE_ACTIVITY_TASKS", "INTERACT_ACROSS_USERS_FULL"})
+    void registerUserVisibleJobObserver(in IUserVisibleJobObserver observer);
+    @EnforcePermission(allOf={"MANAGE_ACTIVITY_TASKS", "INTERACT_ACROSS_USERS_FULL"})
+    void unregisterUserVisibleJobObserver(in IUserVisibleJobObserver observer);
+    @EnforcePermission(allOf={"MANAGE_ACTIVITY_TASKS", "INTERACT_ACROSS_USERS_FULL"})
+    void stopUserVisibleJobsForUser(String packageName, int userId);
 }
diff --git a/apex/jobscheduler/framework/java/android/app/job/JobParameters.java b/apex/jobscheduler/framework/java/android/app/job/JobParameters.java
index ed72530..0205430 100644
--- a/apex/jobscheduler/framework/java/android/app/job/JobParameters.java
+++ b/apex/jobscheduler/framework/java/android/app/job/JobParameters.java
@@ -98,6 +98,12 @@
      */
     public static final int INTERNAL_STOP_REASON_SUCCESSFUL_FINISH =
             JobProtoEnums.INTERNAL_STOP_REASON_SUCCESSFUL_FINISH; // 10.
+    /**
+     * The user stopped the job via some UI (eg. Task Manager).
+     * @hide
+     */
+    public static final int INTERNAL_STOP_REASON_USER_UI_STOP =
+            JobProtoEnums.INTERNAL_STOP_REASON_USER_UI_STOP; // 11.
 
     /**
      * All the stop reason codes. This should be regarded as an immutable array at runtime.
@@ -121,6 +127,7 @@
             INTERNAL_STOP_REASON_DATA_CLEARED,
             INTERNAL_STOP_REASON_RTC_UPDATED,
             INTERNAL_STOP_REASON_SUCCESSFUL_FINISH,
+            INTERNAL_STOP_REASON_USER_UI_STOP,
     };
 
     /**
@@ -141,6 +148,7 @@
             case INTERNAL_STOP_REASON_DATA_CLEARED: return "data_cleared";
             case INTERNAL_STOP_REASON_RTC_UPDATED: return "rtc_updated";
             case INTERNAL_STOP_REASON_SUCCESSFUL_FINISH: return "successful_finish";
+            case INTERNAL_STOP_REASON_USER_UI_STOP: return "user_ui_stop";
             default: return "unknown:" + reasonCode;
         }
     }
@@ -230,7 +238,7 @@
     public static final int STOP_REASON_APP_STANDBY = 12;
     /**
      * The user stopped the job. This can happen either through force-stop, adb shell commands,
-     * or uninstalling.
+     * uninstalling, or some other UI.
      */
     public static final int STOP_REASON_USER = 13;
     /** The system is doing some processing that requires stopping this job. */
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 47f6890..16201b2 100644
--- a/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java
@@ -1184,6 +1184,22 @@
     }
 
     @GuardedBy("mLock")
+    void stopUserVisibleJobsLocked(int userId, @NonNull String packageName,
+            @JobParameters.StopReason int reason, int internalReasonCode) {
+        for (int i = mActiveServices.size() - 1; i >= 0; --i) {
+            final JobServiceContext jsc = mActiveServices.get(i);
+            final JobStatus jobStatus = jsc.getRunningJobLocked();
+
+            if (jobStatus != null && userId == jobStatus.getSourceUserId()
+                    && jobStatus.getSourcePackageName().equals(packageName)
+                    && jobStatus.isUserVisibleJob()) {
+                jsc.cancelExecutingJobLocked(reason, internalReasonCode,
+                        JobParameters.getInternalReasonCodeDescription(internalReasonCode));
+            }
+        }
+    }
+
+    @GuardedBy("mLock")
     void stopNonReadyActiveJobsLocked() {
         for (int i = 0; i < mActiveServices.size(); i++) {
             JobServiceContext serviceContext = mActiveServices.get(i);
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java
index 9fb2af7..ad6eff0 100644
--- a/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java
@@ -16,11 +16,14 @@
 
 package com.android.server.job;
 
+import static android.Manifest.permission.INTERACT_ACROSS_USERS_FULL;
+import static android.Manifest.permission.MANAGE_ACTIVITY_TASKS;
 import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
 import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER;
 import static android.text.format.DateUtils.HOUR_IN_MILLIS;
 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
 
+import android.annotation.EnforcePermission;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.annotation.UserIdInt;
@@ -31,6 +34,7 @@
 import android.app.IUidObserver;
 import android.app.compat.CompatChanges;
 import android.app.job.IJobScheduler;
+import android.app.job.IUserVisibleJobObserver;
 import android.app.job.JobInfo;
 import android.app.job.JobParameters;
 import android.app.job.JobProtoEnums;
@@ -38,6 +42,7 @@
 import android.app.job.JobService;
 import android.app.job.JobSnapshot;
 import android.app.job.JobWorkItem;
+import android.app.job.UserVisibleJobSummary;
 import android.app.usage.UsageStatsManager;
 import android.app.usage.UsageStatsManagerInternal;
 import android.compat.annotation.ChangeId;
@@ -68,6 +73,7 @@
 import android.os.Message;
 import android.os.ParcelFileDescriptor;
 import android.os.Process;
+import android.os.RemoteCallbackList;
 import android.os.RemoteException;
 import android.os.SystemClock;
 import android.os.UserHandle;
@@ -248,6 +254,8 @@
     static final int MSG_UID_IDLE = 7;
     static final int MSG_CHECK_CHANGED_JOB_LIST = 8;
     static final int MSG_CHECK_MEDIA_EXEMPTION = 9;
+    static final int MSG_INFORM_OBSERVER_OF_ALL_USER_VISIBLE_JOBS = 10;
+    static final int MSG_INFORM_OBSERVERS_OF_USER_VISIBLE_JOB_CHANGE = 11;
 
     /** List of controllers that will notify this service of updates to jobs. */
     final List<StateController> mControllers;
@@ -279,6 +287,9 @@
     @GuardedBy("mLock")
     private final SparseArray<String> mCloudMediaProviderPackages = new SparseArray<>();
 
+    private final RemoteCallbackList<IUserVisibleJobObserver> mUserVisibleJobObservers =
+            new RemoteCallbackList<>();
+
     private final CountQuotaTracker mQuotaTracker;
     private static final String QUOTA_TRACKER_SCHEDULE_PERSISTED_TAG = ".schedulePersisted()";
     private static final String QUOTA_TRACKER_SCHEDULE_LOGGED =
@@ -1501,6 +1512,14 @@
         }
     }
 
+    private void stopUserVisibleJobsInternal(@NonNull String packageName, int userId) {
+        synchronized (mLock) {
+            mConcurrencyManager.stopUserVisibleJobsLocked(userId, packageName,
+                    JobParameters.STOP_REASON_USER,
+                    JobParameters.INTERNAL_STOP_REASON_USER_UI_STOP);
+        }
+    }
+
     private final Consumer<JobStatus> mCancelJobDueToUserRemovalConsumer = (toRemove) -> {
         // There's no guarantee that the process has been stopped by the time we get
         // here, but since this is a user-initiated action, it should be fine to just
@@ -2159,6 +2178,7 @@
         }
         delayMillis =
                 Math.min(delayMillis, JobInfo.MAX_BACKOFF_DELAY_MILLIS);
+        // TODO(255767350): demote all jobs to regular for user stops so they don't keep privileges
         JobStatus newJob = new JobStatus(failureToReschedule,
                 elapsedNowMillis + delayMillis,
                 JobStatus.NO_LATEST_RUNTIME, numFailures, numSystemStops,
@@ -2509,6 +2529,52 @@
                         args.recycle();
                         break;
                     }
+
+                    case MSG_INFORM_OBSERVER_OF_ALL_USER_VISIBLE_JOBS: {
+                        final IUserVisibleJobObserver observer =
+                                (IUserVisibleJobObserver) message.obj;
+                        synchronized (mLock) {
+                            for (int i = mConcurrencyManager.mActiveServices.size() - 1; i >= 0;
+                                    --i) {
+                                JobServiceContext context =
+                                        mConcurrencyManager.mActiveServices.get(i);
+                                final JobStatus jobStatus = context.getRunningJobLocked();
+                                if (jobStatus != null && jobStatus.isUserVisibleJob()) {
+                                    try {
+                                        observer.onUserVisibleJobStateChanged(
+                                                jobStatus.getUserVisibleJobSummary(),
+                                                /* isRunning */ true);
+                                    } catch (RemoteException e) {
+                                        // Will be unregistered automatically by
+                                        // RemoteCallbackList's dead-object tracking,
+                                        // so don't need to remove it here.
+                                        break;
+                                    }
+                                }
+                            }
+                        }
+                        break;
+                    }
+
+                    case MSG_INFORM_OBSERVERS_OF_USER_VISIBLE_JOB_CHANGE: {
+                        final SomeArgs args = (SomeArgs) message.obj;
+                        final JobServiceContext context = (JobServiceContext) args.arg1;
+                        final JobStatus jobStatus = (JobStatus) args.arg2;
+                        final UserVisibleJobSummary summary = jobStatus.getUserVisibleJobSummary();
+                        final boolean isRunning = args.argi1 == 1;
+                        for (int i = mUserVisibleJobObservers.beginBroadcast() - 1; i >= 0; --i) {
+                            try {
+                                mUserVisibleJobObservers.getBroadcastItem(i)
+                                        .onUserVisibleJobStateChanged(summary, isRunning);
+                            } catch (RemoteException e) {
+                                // Will be unregistered automatically by RemoteCallbackList's
+                                // dead-object tracking, so nothing we need to do here.
+                            }
+                        }
+                        mUserVisibleJobObservers.finishBroadcast();
+                        args.recycle();
+                        break;
+                    }
                 }
                 maybeRunPendingJobsLocked();
             }
@@ -3022,6 +3088,16 @@
         return adjustJobBias(bias, job);
     }
 
+    void informObserversOfUserVisibleJobChange(JobServiceContext context, JobStatus jobStatus,
+            boolean isRunning) {
+        SomeArgs args = SomeArgs.obtain();
+        args.arg1 = context;
+        args.arg2 = jobStatus;
+        args.argi1 = isRunning ? 1 : 0;
+        mHandler.obtainMessage(MSG_INFORM_OBSERVERS_OF_USER_VISIBLE_JOB_CHANGE, args)
+                .sendToTarget();
+    }
+
     private final class BatteryStateTracker extends BroadcastReceiver {
         /**
          * Track whether we're "charging", where charging means that we're ready to commit to
@@ -3744,6 +3820,35 @@
                 return new ParceledListSlice<>(snapshots);
             }
         }
+
+        @Override
+        @EnforcePermission(allOf = {MANAGE_ACTIVITY_TASKS, INTERACT_ACROSS_USERS_FULL})
+        public void registerUserVisibleJobObserver(@NonNull IUserVisibleJobObserver observer) {
+            if (observer == null) {
+                throw new NullPointerException("observer");
+            }
+            mUserVisibleJobObservers.register(observer);
+            mHandler.obtainMessage(MSG_INFORM_OBSERVER_OF_ALL_USER_VISIBLE_JOBS, observer)
+                    .sendToTarget();
+        }
+
+        @Override
+        @EnforcePermission(allOf = {MANAGE_ACTIVITY_TASKS, INTERACT_ACROSS_USERS_FULL})
+        public void unregisterUserVisibleJobObserver(@NonNull IUserVisibleJobObserver observer) {
+            if (observer == null) {
+                throw new NullPointerException("observer");
+            }
+            mUserVisibleJobObservers.unregister(observer);
+        }
+
+        @Override
+        @EnforcePermission(allOf = {"MANAGE_ACTIVITY_TASKS", "INTERACT_ACROSS_USERS_FULL"})
+        public void stopUserVisibleJobsForUser(@NonNull String packageName, int userId) {
+            if (packageName == null) {
+                throw new NullPointerException("packageName");
+            }
+            JobSchedulerService.this.stopUserVisibleJobsInternal(packageName, userId);
+        }
     }
 
     // Shell command infrastructure: run the given job immediately
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 b20eedc..fead68e 100644
--- a/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java
@@ -944,6 +944,9 @@
                     return;
                 }
                 scheduleOpTimeOutLocked();
+                if (mRunningJob.isUserVisibleJob()) {
+                    mService.informObserversOfUserVisibleJobChange(this, mRunningJob, true);
+                }
                 break;
             default:
                 Slog.e(TAG, "Handling started job but job wasn't starting! Was "
@@ -1202,6 +1205,9 @@
         mPendingDebugStopReason = null;
         mNotification = null;
         removeOpTimeOutLocked();
+        if (completedJob.isUserVisibleJob()) {
+            mService.informObserversOfUserVisibleJobChange(this, completedJob, false);
+        }
         mCompletedListener.onJobCompletedLocked(completedJob, internalStopReason, reschedule);
         mJobConcurrencyManager.onJobCompletedLocked(this, completedJob, workType);
     }
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/JobStatus.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/JobStatus.java
index af8e727..83b6a8e 100644
--- a/apex/jobscheduler/service/java/com/android/server/job/controllers/JobStatus.java
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/JobStatus.java
@@ -29,11 +29,13 @@
 import static com.android.server.job.controllers.FlexibilityController.SYSTEM_WIDE_FLEXIBLE_CONSTRAINTS;
 
 import android.annotation.ElapsedRealtimeLong;
+import android.annotation.NonNull;
 import android.app.AppGlobals;
 import android.app.job.JobInfo;
 import android.app.job.JobParameters;
 import android.app.job.JobScheduler;
 import android.app.job.JobWorkItem;
+import android.app.job.UserVisibleJobSummary;
 import android.content.ClipData;
 import android.content.ComponentName;
 import android.net.Network;
@@ -454,6 +456,12 @@
      */
     private boolean mExpeditedTareApproved;
 
+    /**
+     * Summary describing this job. Lazily created in {@link #getUserVisibleJobSummary()}
+     * since not every job will need it.
+     */
+    private UserVisibleJobSummary mUserVisibleJobSummary;
+
     /////// Booleans that track if a job is ready to run. They should be updated whenever dependent
     /////// states change.
 
@@ -1337,6 +1345,27 @@
     }
 
     /**
+     * Return a summary that uniquely identifies the underlying job.
+     */
+    @NonNull
+    public UserVisibleJobSummary getUserVisibleJobSummary() {
+        if (mUserVisibleJobSummary == null) {
+            mUserVisibleJobSummary = new UserVisibleJobSummary(
+                    callingUid, getSourceUserId(), getSourcePackageName(), getJobId());
+        }
+        return mUserVisibleJobSummary;
+    }
+
+    /**
+     * @return true if this is a job whose execution should be made visible to the user.
+     */
+    public boolean isUserVisibleJob() {
+        // TODO(255767350): limit to user-initiated jobs
+        // Placeholder implementation until we have the code in
+        return shouldTreatAsExpeditedJob();
+    }
+
+    /**
      * @return true if the job is exempted from Doze restrictions and therefore allowed to run
      * in Doze.
      */